feat_: added eth client cache for nonce and balance

This commit is contained in:
Dario Gabriel Lipicar 2024-10-07 20:29:28 -03:00
parent f263fdfad7
commit ca5e93f778
No known key found for this signature in database
GPG Key ID: 9625E9494309D203
9 changed files with 499 additions and 1 deletions

View File

@ -275,3 +275,71 @@ func (c *CachedEthClient) TransactionReceipt(ctx context.Context, hash common.Ha
return r, nil
}
func (c *CachedEthClient) callGetBalance(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) {
var result *hexutil.Big
err := c.CallContext(ctx, &result, "eth_getBalance", account, toBlockNumArg(blockNumber))
if err != nil {
return nil, err
}
return result.ToInt(), nil
}
func (c *CachedEthClient) BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) {
var result *big.Int
cacheValid := true
result, err := c.storage.GetBalance(account, blockNumber)
if err != nil && err != sql.ErrNoRows {
return nil, err
} else if err == sql.ErrNoRows {
cacheValid = false
result, err = c.callGetBalance(ctx, account, blockNumber)
if err != nil {
return nil, err
}
}
if !cacheValid && isConcreteBlockNumber(blockNumber) {
if err := c.storage.PutBalance(account, blockNumber, result); err != nil {
return nil, err
}
}
return result, nil
}
func (c *CachedEthClient) callGetTransactionCount(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) {
var result hexutil.Uint64
err := c.CallContext(ctx, &result, "eth_getTransactionCount", account, toBlockNumArg(blockNumber))
if err != nil {
return 0, err
}
return uint64(result), nil
}
func (c *CachedEthClient) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) {
var result uint64
cacheValid := true
result, err := c.storage.GetTransactionCount(account, blockNumber)
if err != nil && err != sql.ErrNoRows {
return 0, err
} else if err == sql.ErrNoRows {
cacheValid = false
result, err = c.callGetTransactionCount(ctx, account, blockNumber)
if err != nil {
return 0, err
}
}
if !cacheValid && isConcreteBlockNumber(blockNumber) {
if err := c.storage.PutTransactionCount(account, blockNumber, result); err != nil {
return 0, err
}
}
return result, nil
}

View File

@ -23,6 +23,8 @@ type EthClientStorageReader interface {
GetBlockUncleJSONByHashAndIndex(chainID uint64, blockHash common.Hash, index uint64) (json.RawMessage, error)
GetTransactionJSONByHash(chainID uint64, transactionHash common.Hash) (json.RawMessage, error)
GetTransactionReceiptJSONByHash(chainID uint64, transactionHash common.Hash) (json.RawMessage, error)
GetBalance(chainID uint64, account common.Address, blockNumber *big.Int) (*big.Int, error)
GetTransactionCount(chainID uint64, account common.Address, blockNumber *big.Int) (uint64, error)
}
type EthClientStorageWriter interface {
@ -30,6 +32,8 @@ type EthClientStorageWriter interface {
PutBlockUnclesJSON(chainID uint64, blockHash common.Hash, unclesJSON []json.RawMessage) error
PutTransactionsJSON(chainID uint64, transactionsJSON []json.RawMessage) error
PutTransactionReceiptsJSON(chainID uint64, receiptsJSON []json.RawMessage) error
PutBalance(chainID uint64, account common.Address, blockNumber *big.Int, balance *big.Int) error
PutTransactionCount(chainID uint64, account common.Address, blockNumber *big.Int, txCount uint64) error
}
type EthClientStorage interface {
@ -145,6 +149,54 @@ func (b *DB) GetTransactionReceiptJSONByHash(chainID uint64, transactionHash com
return receiptJSON, nil
}
func (b *DB) GetBalance(chainID uint64, account common.Address, blockNumber *big.Int) (*big.Int, error) {
if !isConcreteBlockNumber(blockNumber) {
return nil, sql.ErrNoRows
}
q := sq.Select("balance").
From("blockchain_data_balances").
Where(sq.Eq{"chain_id": chainID, "account": account, "block_number": (*bigint.SQLBigIntBytes)(blockNumber)})
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
balanceInt := (*bigint.SQLBigIntBytes)(big.NewInt(0))
err = b.db.QueryRow(query, args...).Scan(&balanceInt)
if err != nil {
return nil, err
}
return (*big.Int)(balanceInt), nil
}
func (b *DB) GetTransactionCount(chainID uint64, account common.Address, blockNumber *big.Int) (uint64, error) {
if !isConcreteBlockNumber(blockNumber) {
return 0, sql.ErrNoRows
}
q := sq.Select("transaction_count").
From("blockchain_data_transaction_counts").
Where(sq.Eq{"chain_id": chainID, "account": account, "block_number": (*bigint.SQLBigIntBytes)(blockNumber)})
query, args, err := q.ToSql()
if err != nil {
return 0, err
}
txCount := uint64(0)
err = b.db.QueryRow(query, args...).Scan(&txCount)
if err != nil {
return 0, err
}
return txCount, nil
}
func (b *DB) PutBlockJSON(chainID uint64, blkJSON json.RawMessage, transactionDetailsFlag bool) (err error) {
var tx *sql.Tx
tx, err = b.db.Begin()
@ -234,6 +286,42 @@ func (b *DB) PutTransactionReceiptsJSON(chainID uint64, receiptsJSON []json.RawM
return
}
func (b *DB) PutBalance(chainID uint64, account common.Address, blockNumber *big.Int, balance *big.Int) (err error) {
var tx *sql.Tx
tx, err = b.db.Begin()
if err != nil {
return
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
err = putBalance(tx, chainID, account, blockNumber, balance)
return
}
func (b *DB) PutTransactionCount(chainID uint64, account common.Address, blockNumber *big.Int, txCount uint64) (err error) {
var tx *sql.Tx
tx, err = b.db.Begin()
if err != nil {
return
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
err = putTransactionCount(tx, chainID, account, blockNumber, txCount)
return
}
func putBlockJSON(creator sqlite.StatementCreator, chainID uint64, blkJSON json.RawMessage, transactionDetailsFlag bool) error {
var rpcBlock rpcBlock
if err := json.Unmarshal(blkJSON, &rpcBlock); err != nil {
@ -346,3 +434,55 @@ func putReceiptJSON(creator sqlite.StatementCreator, chainID uint64, receiptJSON
return err
}
func putBalance(creator sqlite.StatementCreator, chainID uint64, account common.Address, blockNumber *big.Int, balance *big.Int) error {
if !isConcreteBlockNumber(blockNumber) {
return errors.New("invalid block number")
}
q := sq.Replace("blockchain_data_balances").
SetMap(sq.Eq{"chain_id": chainID, "account": account, "block_number": (*bigint.SQLBigIntBytes)(blockNumber),
"balance": (*bigint.SQLBigIntBytes)(balance),
})
query, args, err := q.ToSql()
if err != nil {
return err
}
stmt, err := creator.Prepare(query)
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(args...)
return err
}
func putTransactionCount(creator sqlite.StatementCreator, chainID uint64, account common.Address, blockNumber *big.Int, txCount uint64) error {
if !isConcreteBlockNumber(blockNumber) {
return errors.New("invalid block number")
}
q := sq.Replace("blockchain_data_transaction_counts").
SetMap(sq.Eq{"chain_id": chainID, "account": account, "block_number": (*bigint.SQLBigIntBytes)(blockNumber),
"transaction_count": txCount,
})
query, args, err := q.ToSql()
if err != nil {
return err
}
stmt, err := creator.Prepare(query)
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(args...)
return err
}

View File

@ -13,6 +13,8 @@ type EthClientChainStorageReader interface {
GetBlockUncleJSONByHashAndIndex(blockHash common.Hash, index uint64) (json.RawMessage, error)
GetTransactionJSONByHash(transactionHash common.Hash) (json.RawMessage, error)
GetTransactionReceiptJSONByHash(transactionHash common.Hash) (json.RawMessage, error)
GetBalance(account common.Address, blockNumber *big.Int) (*big.Int, error)
GetTransactionCount(account common.Address, blockNumber *big.Int) (uint64, error)
}
type EthClientChainStorageWriter interface {
@ -20,6 +22,8 @@ type EthClientChainStorageWriter interface {
PutBlockUnclesJSON(blockHash common.Hash, unclesJSON []json.RawMessage) error
PutTransactionsJSON(transactionsJSON []json.RawMessage) error
PutTransactionReceiptsJSON(receiptsJSON []json.RawMessage) error
PutBalance(account common.Address, blockNumber *big.Int, balance *big.Int) error
PutTransactionCount(account common.Address, blockNumber *big.Int, txCount uint64) error
}
type EthClientChainStorage interface {
@ -59,6 +63,14 @@ func (b *DBChain) GetTransactionReceiptJSONByHash(transactionHash common.Hash) (
return b.s.GetTransactionReceiptJSONByHash(b.chainID, transactionHash)
}
func (b *DBChain) GetBalance(account common.Address, blockNumber *big.Int) (*big.Int, error) {
return b.s.GetBalance(b.chainID, account, blockNumber)
}
func (b *DBChain) GetTransactionCount(account common.Address, blockNumber *big.Int) (uint64, error) {
return b.s.GetTransactionCount(b.chainID, account, blockNumber)
}
func (b *DBChain) PutBlockJSON(blkJSON json.RawMessage, transactionDetailsFlag bool) error {
return b.s.PutBlockJSON(b.chainID, blkJSON, transactionDetailsFlag)
}
@ -74,3 +86,11 @@ func (b *DBChain) PutTransactionsJSON(transactionsJSON []json.RawMessage) error
func (b *DBChain) PutTransactionReceiptsJSON(receiptsJSON []json.RawMessage) error {
return b.s.PutTransactionReceiptsJSON(b.chainID, receiptsJSON)
}
func (b *DBChain) PutBalance(account common.Address, blockNumber *big.Int, balance *big.Int) error {
return b.s.PutBalance(b.chainID, account, blockNumber, balance)
}
func (b *DBChain) PutTransactionCount(account common.Address, blockNumber *big.Int, txCount uint64) error {
return b.s.PutTransactionCount(b.chainID, account, blockNumber, txCount)
}

View File

@ -3,9 +3,13 @@ package ethclient_test
import (
"context"
"encoding/json"
"errors"
"math/big"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/rpc"
ethclient "github.com/status-im/status-go/rpc/chain/ethclient"
mock_ethclient "github.com/status-im/status-go/rpc/chain/ethclient/mock/client/ethclient"
@ -33,6 +37,16 @@ func setupCachedEthClientTest(t *testing.T) (*ethclient.CachedEthClient, *mock_e
return cachedEthClient, ethClient, cleanup
}
func specialBlockNumbers() []*big.Int {
return []*big.Int{
nil,
big.NewInt(int64(rpc.LatestBlockNumber)),
big.NewInt(int64(rpc.PendingBlockNumber)),
big.NewInt(int64(rpc.PendingBlockNumber)),
big.NewInt(int64(rpc.SafeBlockNumber)),
}
}
func TestGetBlock(t *testing.T) {
client, ethClient, cleanup := setupCachedEthClientTest(t)
defer cleanup()
@ -83,6 +97,20 @@ func TestGetBlock(t *testing.T) {
}).Times(1)
_, err = client.BlockByHash(ctx, common.HexToHash("0x1234"))
require.Error(t, err)
// Calls with non-concrete block numbers always go to chain
for i := 0; i < 3; i++ {
for _, blockNumber := range specialBlockNumbers() {
newBlkJSON, _, _ := getTestBlockJSONWithTxDetails()
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*json.RawMessage) = newBlkJSON
return nil
}).Times(1)
_, err = client.BlockByNumber(ctx, blockNumber)
require.NoError(t, err)
}
}
}
func TestGetHeader(t *testing.T) {
@ -135,6 +163,20 @@ func TestGetHeader(t *testing.T) {
}).Times(1)
_, err = client.BlockByHash(ctx, common.HexToHash("0x1234"))
require.Error(t, err)
// Calls with non-concrete block numbers always go to chain
for i := 0; i < 3; i++ {
for _, blockNumber := range specialBlockNumbers() {
newBlkJSON, _, _ := getTestBlockJSONWithTxDetails()
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*json.RawMessage) = newBlkJSON
return nil
}).Times(1)
_, err = client.HeaderByNumber(ctx, blockNumber)
require.NoError(t, err)
}
}
}
func TestGetTransaction(t *testing.T) {
@ -222,3 +264,159 @@ func TestGetReceipt(t *testing.T) {
_, err = client.TransactionReceipt(ctx, common.HexToHash("0x1234"))
require.Error(t, err)
}
func TestGetBalance(t *testing.T) {
client, ethClient, cleanup := setupCachedEthClientTest(t)
defer cleanup()
ctx := context.Background()
account := common.HexToAddress("0x1234")
blockNumber := big.NewInt(1234)
valueHex, valueInt := getTestBalance()
// First call goes to the chain, through raw endpoint
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(**hexutil.Big) = valueHex
return nil
}).Times(1)
res, err := client.BalanceAt(ctx, account, blockNumber)
require.NoError(t, err)
require.Equal(t, valueInt, res)
// Next calls are read from cache
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
res, err = client.BalanceAt(ctx, account, blockNumber)
require.NoError(t, err)
require.Equal(t, valueInt, res)
// Fetching a different account goes to the chain
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(**hexutil.Big) = nil
return errors.New("Some error")
}).Times(1)
_, err = client.BalanceAt(ctx, common.HexToAddress("0x4567"), blockNumber)
require.Error(t, err)
// No cache due to error, should hit chain again
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(**hexutil.Big) = nil
return errors.New("Some error")
}).Times(1)
_, err = client.BalanceAt(ctx, common.HexToAddress("0x4567"), blockNumber)
require.Error(t, err)
// Fetching a different block goes to the chain
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(**hexutil.Big) = nil
return errors.New("Some error")
}).Times(1)
_, err = client.BalanceAt(ctx, account, big.NewInt(5))
require.Error(t, err)
// No cache due to error, should hit chain again
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(**hexutil.Big) = nil
return errors.New("Some error")
}).Times(1)
_, err = client.BalanceAt(ctx, account, big.NewInt(5))
require.Error(t, err)
// Non-concrete block numbers always go to chain
for i := 0; i < 3; i++ {
newValueHex, newValueInt := getTestBalance()
for _, blockNumber := range specialBlockNumbers() {
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(**hexutil.Big) = newValueHex
return nil
}).Times(1)
res, err := client.BalanceAt(ctx, account, blockNumber)
require.NoError(t, err)
require.Equal(t, newValueInt, res)
}
}
}
func TestGetTransactionCount(t *testing.T) {
client, ethClient, cleanup := setupCachedEthClientTest(t)
defer cleanup()
ctx := context.Background()
account := common.HexToAddress("0x1234")
blockNumber := big.NewInt(1234)
valueHex, valueInt := getTestTransactionCount()
// First call goes to the chain, through raw endpoint
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*hexutil.Uint64) = valueHex
return nil
}).Times(1)
res, err := client.NonceAt(ctx, account, blockNumber)
require.NoError(t, err)
require.Equal(t, valueInt, res)
// Next calls are read from cache
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
res, err = client.NonceAt(ctx, account, blockNumber)
require.NoError(t, err)
require.Equal(t, valueInt, res)
// Fetching a different account goes to the chain
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*hexutil.Uint64) = 0
return errors.New("Some error")
}).Times(1)
_, err = client.NonceAt(ctx, common.HexToAddress("0x4567"), blockNumber)
require.Error(t, err)
// No cache due to error, should hit chain again
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*hexutil.Uint64) = 0
return errors.New("Some error")
}).Times(1)
_, err = client.NonceAt(ctx, common.HexToAddress("0x4567"), blockNumber)
require.Error(t, err)
// Fetching a different block goes to the chain
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*hexutil.Uint64) = 0
return errors.New("Some error")
}).Times(1)
_, err = client.NonceAt(ctx, account, big.NewInt(5))
require.Error(t, err)
// No cache due to error, should hit chain again
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*hexutil.Uint64) = 0
return errors.New("Some error")
}).Times(1)
_, err = client.NonceAt(ctx, account, big.NewInt(5))
require.Error(t, err)
// Non-concrete block numbers always go to chain
for i := 0; i < 3; i++ {
newValueHex, newValueInt := getTestTransactionCount()
for _, blockNumber := range specialBlockNumbers() {
ethClient.EXPECT().CallContext(ctx, gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, result interface{}, method string, args ...interface{}) error {
*result.(*hexutil.Uint64) = newValueHex
return nil
}).Times(1)
res, err := client.NonceAt(ctx, account, blockNumber)
require.NoError(t, err)
require.Equal(t, newValueInt, res)
}
}
}

View File

@ -3,8 +3,10 @@ package ethclient_test
import (
"encoding/json"
"math/big"
"math/rand"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
)
@ -71,3 +73,16 @@ func getTestBlockUnclesJSON() []json.RawMessage {
return []json.RawMessage{json.RawMessage(uncle0)}
}
func getTestBalance() (*hexutil.Big, *big.Int) {
balance := uint64(rand.Uint32()) // nolint: gosec
balanceInt := new(big.Int).SetUint64(balance)
return (*hexutil.Big)(balanceInt), balanceInt
}
func getTestTransactionCount() (hexutil.Uint64, uint64) {
txCount := uint64(rand.Uint32()) // nolint: gosec
return (hexutil.Uint64)(txCount), txCount
}

View File

@ -125,3 +125,7 @@ type txExtraInfo struct {
BlockHash *common.Hash `json:"blockHash,omitempty"`
From *common.Address `json:"from,omitempty"`
}
func isConcreteBlockNumber(blockNumber *big.Int) bool {
return blockNumber != nil && blockNumber.Cmp(big.NewInt(0)) >= 0
}

View File

@ -149,3 +149,23 @@ func (pa *PrivateAPI) BlockNumber(ctx context.Context, chainId uint64) (uint64,
return client.BlockNumber(ctx)
}
func (pa *PrivateAPI) BalanceAt(ctx context.Context, chainId uint64, account common.Address, blockNumber *hexutil.Big) (*hexutil.Big, error) {
client, err := pa.client.EthClient(chainId)
if err != nil {
return nil, err
}
balance, err := client.BalanceAt(ctx, account, (*big.Int)(blockNumber))
return (*hexutil.Big)(balance), err
}
func (pa *PrivateAPI) NonceAt(ctx context.Context, chainId uint64, account common.Address, blockNumber *hexutil.Big) (uint64, error) {
client, err := pa.client.EthClient(chainId)
if err != nil {
return 0, err
}
nonce, err := client.NonceAt(ctx, account, (*big.Int)(blockNumber))
return nonce, err
}

View File

@ -1,5 +1,6 @@
import pytest
from conftest import user_1
from test_cases import EthRpcTestCase
@ -42,6 +43,14 @@ class TestEth(EthRpcTestCase):
def test_suggest_gas_price(self, tx_data, iterations):
self.rpc_client.rpc_valid_request("ethclient_suggestGasPrice", [self.network_id])
def test_balance_at(self, tx_data, iterations):
self.rpc_client.rpc_valid_request("ethclient_balanceAt", [self.network_id, user_1.address, "0x0"])
self.rpc_client.rpc_valid_request("ethclient_balanceAt", [self.network_id, user_1.address, tx_data.block_number])
def test_nonce_at(self, tx_data, iterations):
self.rpc_client.rpc_valid_request("ethclient_nonceAt", [self.network_id, user_1.address, "0x0"])
self.rpc_client.rpc_valid_request("ethclient_nonceAt", [self.network_id, user_1.address, tx_data.block_number])
def test_header_by_number(self, tx_data, iterations):
response = self.rpc_client.rpc_valid_request("ethclient_headerByNumber",
[self.network_id, tx_data.block_number])

View File

@ -45,3 +45,27 @@ CREATE TABLE IF NOT EXISTS blockchain_data_receipts (
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_blockchain_data_receipts_chain_id_transaction_hash ON blockchain_data_receipts (chain_id, transaction_hash);
-- store balances
CREATE TABLE IF NOT EXISTS blockchain_data_balances (
chain_id UNSIGNED BIGINT NOT NULL,
account BLOB NOT NULL,
block_number BLOB NOT NULL,
balance BLOB NOT NULL,
PRIMARY KEY (chain_id, account, block_number),
CONSTRAINT unique_balance_per_chain_per_account_per_block_number UNIQUE (chain_id, account, block_number) ON CONFLICT REPLACE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_blockchain_data_balances_chain_id_account_block_number ON blockchain_data_balances (chain_id, account, block_number);
-- store transaction counts
CREATE TABLE IF NOT EXISTS blockchain_data_transaction_counts (
chain_id UNSIGNED BIGINT NOT NULL,
account BLOB NOT NULL,
block_number BLOB NOT NULL,
transaction_count BIGINT NOT NULL,
PRIMARY KEY (chain_id, account, block_number),
CONSTRAINT unique_transaction_count_per_chain_per_account_per_block_number UNIQUE (chain_id, account, block_number) ON CONFLICT REPLACE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_blockchain_data_transaction_count_chain_id_account_block_number ON blockchain_data_transaction_counts (chain_id, account, block_number);