This commit is contained in:
Dario Gabriel Lipicar 2024-09-10 22:07:46 -03:00
parent fb150f3d16
commit 953ad55f19
No known key found for this signature in database
GPG Key ID: 9625E9494309D203
8 changed files with 692 additions and 22 deletions

170
rpc/chain/cached_client.go Normal file
View File

@ -0,0 +1,170 @@
package chain
import (
"context"
"database/sql"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
)
type CachedClient struct {
*ClientWithFallback
db *DB
}
func NewCachedClient(ethClients []*EthClient, chainID uint64, db *sql.DB) *CachedClient {
return &CachedClient{
ClientWithFallback: NewClient(ethClients, chainID),
db: NewDB(db),
}
}
func (c *CachedClient) HeaderByHash(ctx context.Context, hash common.Hash) (*types.Header, error) {
header, err := c.db.GetBlockHeaderByHash(c.NetworkID(), hash)
if err == nil {
return header, nil
} else if err != sql.ErrNoRows {
// Soft error, we can continue
log.Error("Failed to get header from cache", "error", err)
}
header, err = c.ClientWithFallback.HeaderByHash(ctx, hash)
if err != nil {
return nil, err
}
err = c.db.PutBlockHeader(c.NetworkID(), header)
if err != nil {
// Soft error, we can continue
log.Error("Failed to put header into cache", "error", err)
}
return header, nil
}
func (c *CachedClient) BlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) {
block, err := c.db.GetBlockByHash(c.NetworkID(), hash)
if err == nil {
return block, nil
} else if err != sql.ErrNoRows {
// Soft error, we can continue
log.Error("Failed to get block from cache", "error", err)
}
block, err = c.ClientWithFallback.BlockByHash(ctx, hash)
if err != nil {
return nil, err
}
if block != nil {
err = c.db.PutBlock(c.NetworkID(), block)
if err != nil {
// Soft error, we can continue
log.Error("Failed to put block into cache", "error", err)
}
err = c.db.PutTransactions(c.NetworkID(), block.Transactions())
if err != nil {
// Soft error, we can continue
log.Error("Failed to put transactions into cache", "error", err)
}
}
return block, nil
}
func (c *CachedClient) BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) {
block, err := c.db.GetBlockByNumber(c.NetworkID(), number)
if err == nil {
return block, nil
} else if err != sql.ErrNoRows {
// Soft error, we can continue
log.Error("Failed to get block from cache", "error", err)
}
block, err = c.ClientWithFallback.BlockByNumber(ctx, number)
if err != nil {
return nil, err
}
err = c.db.PutBlock(c.NetworkID(), block)
if err != nil {
// Soft error, we can continue
log.Error("Failed to put block into cache", "error", err)
}
return block, nil
}
func (c *CachedClient) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) {
header, err := c.db.GetBlockHeaderByNumber(c.NetworkID(), number)
if err == nil {
return header, nil
} else if err != sql.ErrNoRows {
// Soft error, we can continue
log.Error("Failed to get header from cache", "error", err)
}
header, err = c.ClientWithFallback.HeaderByNumber(ctx, number)
if err != nil {
return nil, err
}
err = c.db.PutBlockHeader(c.NetworkID(), header)
if err != nil {
// Soft error, we can continue
log.Error("Failed to put header into cache", "error", err)
}
return header, nil
}
func (c *CachedClient) TransactionByHash(ctx context.Context, hash common.Hash) (*types.Transaction, bool, error) {
transaction, err := c.db.GetTransactionByHash(c.NetworkID(), hash)
if err == nil {
return transaction, false, nil
} else if err != sql.ErrNoRows {
// Soft error, we can continue
log.Error("Failed to get transaction from cache", "error", err)
}
transaction, pending, err := c.ClientWithFallback.TransactionByHash(ctx, hash)
if err != nil {
return nil, pending, err
}
if !pending {
err = c.db.PutTransactions(c.NetworkID(), types.Transactions{transaction})
if err != nil {
// Soft error, we can continue
log.Error("Failed to put transaction into cache", "error", err)
}
}
return transaction, pending, nil
}
func (c *CachedClient) TransactionReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) {
receipt, err := c.db.GetTransactionReceipt(c.NetworkID(), hash)
if err == nil {
return receipt, nil
} else if err != sql.ErrNoRows {
// Soft error, we can continue
log.Error("Failed to get transaction receipt from cache", "error", err)
}
receipt, err = c.ClientWithFallback.TransactionReceipt(ctx, hash)
if err != nil {
return nil, err
}
err = c.db.PutTransactionReceipt(c.NetworkID(), receipt)
if err != nil {
// Soft error, we can continue
log.Error("Failed to put transaction receipt into cache", "error", err)
}
return receipt, nil
}

View File

@ -0,0 +1,56 @@
package chain
import (
"math/big"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/status-im/status-go/t/helpers"
"github.com/status-im/status-go/t/helpers"
"github.com/status-im/status-go/walletdatabase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupDBTest(t *testing.T) (*DB, func()) {
db, err := helpers.SetupTestMemorySQLDB(walletdatabase.DbInitializer{})
require.NoError(t, err)
return NewDB(db), func() {
require.NoError(t, db.Close())
}
}
setupCachedClient(t *testing.T) (*CachedClient, func()) {
db, closeDB := setupDBTest(t)
db, err := helpers.SetupTestMemorySQLDB(walletdatabase.DbInitializer{})
require.NoError(t, err)
return NewDB(db), func() {
require.NoError(t, db.Close())
}
}
func TestGetTransactionByHash(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
txHash := common.HexToHash("0x123456789abcdef")
tx := types.NewTransaction(0, common.HexToAddress("0x1"), big.NewInt(1), 100000, big.NewInt(1), nil)
receipt := &types.Receipt{
TxHash: txHash,
GasUsed: 100000,
Status: types.ReceiptStatusSuccessful,
}
err := db.PutTransaction(chainID, tx)
require.NoError(t, err)
retrievedTx, err := db.GetTransactionByHash(chainID, txHash)
require.NoError(t, err)
assert.Equal(t, tx, retrievedTx)
}

View File

@ -0,0 +1,94 @@
package chain
import (
"math/big"
"math/rand"
crypto_rand "crypto/rand"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
func getRandomTransaction() *types.Transaction {
nonce := rand.Uint64()
gasLimit := rand.Uint64()
gasPrice := rand.Uint64()
to := common.Address{}
crypto_rand.Read(to[:])
value := rand.Uint64()
data := make([]byte, 32*rand.Intn(10))
crypto_rand.Read(data)
tx := types.NewTransaction(nonce, to, big.NewInt(int64(value)), gasLimit, big.NewInt(int64(gasPrice)), data)
return tx
}
func getRandomBlockHeader() *types.Header {
header := &types.Header{
Number: big.NewInt(rand.Int63()),
Time: rand.Uint64(),
Difficulty: big.NewInt(rand.Int63()),
ParentHash: common.Hash{},
Nonce: types.BlockNonce{},
MixDigest: common.Hash{},
}
crypto_rand.Read(header.ParentHash[:])
crypto_rand.Read(header.Nonce[:])
crypto_rand.Read(header.MixDigest[:])
return header
}
func getRandomLog() *types.Log {
log := &types.Log{
Address: common.Address{},
Topics: []common.Hash{},
Data: []byte{},
BlockNumber: rand.Uint64(),
TxHash: common.Hash{},
TxIndex: uint(rand.Uint64()),
}
crypto_rand.Read(log.Address[:])
crypto_rand.Read(log.TxHash[:])
for i := 0; i < rand.Intn(10); i++ {
hash := common.Hash{}
crypto_rand.Read(hash[:])
log.Topics = append(log.Topics, hash)
}
crypto_rand.Read(log.Data)
return log
}
func getRandomReceipt() *types.Receipt {
receipt := &types.Receipt{
Status: rand.Uint64(),
CumulativeGasUsed: rand.Uint64(),
Bloom: types.Bloom{},
Logs: []*types.Log{},
}
crypto_rand.Read(receipt.Bloom[:])
for i := 0; i < rand.Intn(10); i++ {
receipt.Logs = append(receipt.Logs, getRandomLog())
}
return receipt
}
func getRandomBlock() *types.Block {
header := getRandomBlockHeader()
txs := []*types.Transaction{}
for i := 0; i < rand.Intn(10); i++ {
txs = append(txs, getRandomTransaction())
}
receipts := []*types.Receipt{}
for i := 0; i < rand.Intn(10); i++ {
receipts = append(receipts, getRandomReceipt())
}
return types.NewBlock(header, txs, nil, receipts, nil)
}

View File

@ -161,25 +161,6 @@ var propagateErrors = []error{
bind.ErrNoCode,
}
func NewSimpleClient(ethClient EthClient, chainID uint64) *ClientWithFallback {
cbConfig := circuitbreaker.Config{
Timeout: 20000,
MaxConcurrentRequests: 100,
SleepWindow: 300000,
ErrorPercentThreshold: 25,
}
isConnected := &atomic.Bool{}
isConnected.Store(true)
return &ClientWithFallback{
ChainID: chainID,
ethClients: []*EthClient{&ethClient},
isConnected: isConnected,
LastCheckedAt: time.Now().Unix(),
circuitbreaker: circuitbreaker.NewCircuitBreaker(cbConfig),
}
}
func NewClient(ethClients []*EthClient, chainID uint64) *ClientWithFallback {
cbConfig := circuitbreaker.Config{
Timeout: 20000,

177
rpc/chain/db.go Normal file
View File

@ -0,0 +1,177 @@
package chain
import (
"database/sql"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
type DB struct {
db *sql.DB
}
func NewDB(db *sql.DB) *DB {
return &DB{db: db}
}
func (b *DB) GetBlockByNumber(chainID uint64, blockNumber *big.Int) (*types.Block, error) {
row := b.db.QueryRow("SELECT block_json FROM blockchain_data_blocks WHERE chain_id = ? AND block_number = ?", chainID, blockNumber)
var block types.Block
err := row.Scan(&block)
if err != nil {
return nil, err
}
return &block, nil
}
func (b *DB) GetBlockByHash(chainID uint64, blockHash common.Hash) (*types.Block, error) {
row := b.db.QueryRow("SELECT block_json FROM blockchain_data_blocks WHERE chain_id = ? AND block_hash = ?", chainID, blockHash)
var block types.Block
err := row.Scan(&block)
if err != nil {
return nil, err
}
return &block, nil
}
func (b *DB) GetBlockHeaderByNumber(chainID uint64, blockNumber *big.Int) (*types.Header, error) {
row := b.db.QueryRow("SELECT block_json FROM blockchain_data_blocks WHERE chain_id = ? AND block_number = ?", chainID, blockNumber)
var blockHeader types.Header
err := row.Scan(&blockHeader)
if err != nil {
return nil, err
}
return &blockHeader, nil
}
func (b *DB) GetBlockHeaderByHash(chainID uint64, blockHash common.Hash) (*types.Header, error) {
row := b.db.QueryRow("SELECT block_json FROM blockchain_data_blocks WHERE chain_id = ? AND block_hash = ?", chainID, blockHash)
var blockHeader types.Header
err := row.Scan(&blockHeader)
if err != nil {
return nil, err
}
return &blockHeader, nil
}
func (b *DB) PutBlock(chainID uint64, block *types.Block) error {
_, err := b.db.Exec("INSERT INTO blockchain_data_blocks (chain_id, block_number, block_hash, block_header_json, block_json) VALUES (?, ?, ?, ?, ?)", chainID, block.Number(), block.Hash(), block.Header(), block)
if err != nil {
return err
}
return b.PutTransactions(chainID, block.Transactions())
}
func (b *DB) PutBlockHeader(chainID uint64, blockHeader *types.Header) error {
_, err := b.db.Exec("INSERT INTO blockchain_data_blocks (chain_id, block_number, block_hash, block_header_json) VALUES (?, ?, ?, ?)", chainID, blockHeader.Number, blockHeader.Hash(), blockHeader)
if err != nil {
return err
}
return nil
}
func (t *DB) GetTransactionsByBlockHash(chainID uint64, blockHash common.Hash) (types.Transactions, error) {
rows, err := t.db.Query("SELECT transaction_json FROM blockchain_data_transactions WHERE chain_id = ? AND block_hash = ?", chainID, blockHash)
if err != nil {
return nil, err
}
defer rows.Close()
var transactions types.Transactions
for rows.Next() {
var transaction types.Transaction
err := rows.Scan(&transaction)
if err != nil {
return nil, err
}
transactions = append(transactions, &transaction)
}
return transactions, nil
}
func (t *DB) GetTransactionsByBlockNumber(chainID uint64, blockNumber *big.Int) (types.Transactions, error) {
rows, err := t.db.Query("SELECT transaction_json FROM blockchain_data_transactions WHERE chain_id = ? AND block_number = ?", chainID, blockNumber)
if err != nil {
return nil, err
}
defer rows.Close()
var transactions types.Transactions
for rows.Next() {
var transaction types.Transaction
err := rows.Scan(&transaction)
if err != nil {
return nil, err
}
transactions = append(transactions, &transaction)
}
return transactions, nil
}
func (t *DB) GetTransactionByHash(chainID uint64, transactionHash common.Hash) (*types.Transaction, error) {
row := t.db.QueryRow("SELECT transaction_json FROM blockchain_data_transactions WHERE chain_id = ? AND transaction_hash = ?", chainID, transactionHash)
var transaction types.Transaction
err := row.Scan(&transaction)
if err != nil {
return nil, err
}
return &transaction, nil
}
func (t *DB) PutTransactions(chainID uint64, transactions types.Transactions) error {
tx, err := t.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO blockchain_data_transactions (chain_id, transaction_hash, transaction_json) VALUES (?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
for _, transaction := range transactions {
_, err = stmt.Exec(chainID, transaction.Hash(), transaction)
if err != nil {
return err
}
}
return tx.Commit()
}
func (t *DB) GetTransactionReceipt(chainID uint64, transactionHash common.Hash) (*types.Receipt, error) {
row := t.db.QueryRow("SELECT receipt_json FROM blockchain_data_transactions_receipts WHERE chain_id = ? AND transaction_hash = ?", chainID, transactionHash)
var receipt types.Receipt
err := row.Scan(&receipt)
if err != nil {
return nil, err
}
return &receipt, nil
}
func (t *DB) PutTransactionReceipt(chainID uint64, receipt *types.Receipt) error {
tx, err := t.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO blockchain_data_transactions_receipts (chain_id, transaction_hash, receipt_json) VALUES (?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(chainID, receipt.TxHash, receipt)
if err != nil {
return err
}
return tx.Commit()
}

153
rpc/chain/db_test.go Normal file
View File

@ -0,0 +1,153 @@
package chain
import (
"math/big"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/status-im/status-go/t/helpers"
"github.com/status-im/status-go/walletdatabase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupDBTest(t *testing.T) (*DB, func()) {
db, err := helpers.SetupTestMemorySQLDB(walletdatabase.DbInitializer{})
require.NoError(t, err)
return NewDB(db), func() {
require.NoError(t, db.Close())
}
}
func TestGetBlockByNumber(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
blockNumber := big.NewInt(123)
block := types.NewBlock(&types.Header{Number: blockNumber}, nil, nil, nil, nil)
err := db.PutBlock(chainID, block)
require.NoError(t, err)
retrievedBlock, err := db.GetBlockByNumber(chainID, blockNumber)
assert.NoError(t, err)
assert.NotNil(t, retrievedBlock)
assert.Equal(t, block.Hash(), retrievedBlock.Hash())
}
func TestGetBlockByHash(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
blockNumber := big.NewInt(123)
block := types.NewBlock(&types.Header{Number: blockNumber}, nil, nil, nil, nil)
err := db.PutBlock(chainID, block)
require.NoError(t, err)
retrievedBlock, err := db.GetBlockByHash(chainID, block.Hash())
assert.NoError(t, err)
assert.NotNil(t, retrievedBlock)
assert.Equal(t, block.Number(), retrievedBlock.Number())
}
func TestGetBlockHeaderByNumber(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
blockNumber := big.NewInt(123)
header := &types.Header{Number: blockNumber}
block := types.NewBlock(header, nil, nil, nil, nil)
err := db.PutBlock(chainID, block)
require.NoError(t, err)
retrievedHeader, err := db.GetBlockHeaderByNumber(chainID, blockNumber)
assert.NoError(t, err)
assert.NotNil(t, retrievedHeader)
assert.Equal(t, header.Hash(), retrievedHeader.Hash())
}
func TestGetBlockHeaderByHash(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
blockNumber := big.NewInt(123)
header := &types.Header{Number: blockNumber}
block := types.NewBlock(header, nil, nil, nil, nil)
err := db.PutBlock(chainID, block)
require.NoError(t, err)
retrievedHeader, err := db.GetBlockHeaderByHash(chainID, block.Hash())
assert.NoError(t, err)
assert.NotNil(t, retrievedHeader)
assert.Equal(t, header.Number, retrievedHeader.Number)
}
func TestPutAndGetTransactions(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
blockNumber := big.NewInt(123)
tx1 := types.NewTransaction(0, common.Address{}, big.NewInt(100), 21000, big.NewInt(1), nil)
tx2 := types.NewTransaction(1, common.Address{}, big.NewInt(200), 21000, big.NewInt(1), nil)
txs := types.Transactions{tx1, tx2}
block := types.NewBlock(&types.Header{Number: blockNumber}, txs, nil, nil, nil)
err := db.PutBlock(chainID, block)
require.NoError(t, err)
// Test GetTransactionsByBlockHash
retrievedTxs, err := db.GetTransactionsByBlockHash(chainID, block.Hash())
assert.NoError(t, err)
assert.Len(t, retrievedTxs, 2)
assert.Equal(t, tx1.Hash(), retrievedTxs[0].Hash())
assert.Equal(t, tx2.Hash(), retrievedTxs[1].Hash())
// Test GetTransactionsByBlockNumber
retrievedTxs, err = db.GetTransactionsByBlockNumber(chainID, blockNumber)
assert.NoError(t, err)
assert.Len(t, retrievedTxs, 2)
assert.Equal(t, tx1.Hash(), retrievedTxs[0].Hash())
assert.Equal(t, tx2.Hash(), retrievedTxs[1].Hash())
// Test GetTransactionByHash
retrievedTx, err := db.GetTransactionByHash(chainID, tx1.Hash())
assert.NoError(t, err)
assert.NotNil(t, retrievedTx)
assert.Equal(t, tx1.Hash(), retrievedTx.Hash())
}
func TestPutAndGetTransactionReceipt(t *testing.T) {
db, cleanup := setupDBTest(t)
defer cleanup()
chainID := uint64(1)
tx := types.NewTransaction(0, common.Address{}, big.NewInt(100), 21000, big.NewInt(1), nil)
receipt := &types.Receipt{
TxHash: tx.Hash(),
GasUsed: 21000,
Status: types.ReceiptStatusSuccessful,
BlockNumber: big.NewInt(123),
}
err := db.PutTransactionReceipt(chainID, receipt)
require.NoError(t, err)
retrievedReceipt, err := db.GetTransactionReceipt(chainID, tx.Hash())
assert.NoError(t, err)
assert.NotNil(t, retrievedReceipt)
assert.Equal(t, receipt.TxHash, retrievedReceipt.TxHash)
assert.Equal(t, receipt.GasUsed, retrievedReceipt.GasUsed)
assert.Equal(t, receipt.Status, retrievedReceipt.Status)
}
// Add more test functions as needed...

View File

@ -104,6 +104,8 @@ type Client struct {
walletNotifier func(chainID uint64, message string)
providerConfigs []params.ProviderConfig
db *sql.DB
}
// Is initialized in a build-tag-dependent module
@ -136,6 +138,7 @@ func NewClient(client *gethrpc.Client, upstreamChainID uint64, upstream params.U
limiterPerProvider: make(map[string]*chain.RPCRpsLimiter),
log: log,
providerConfigs: providerConfigs,
db: db,
}
var opts []gethrpc.ClientOption
@ -165,7 +168,10 @@ func NewClient(client *gethrpc.Client, upstreamChainID uint64, upstream params.U
// Include the chain-id in the rpc client
rpcName := fmt.Sprintf("%s-chain-id-%d", hostPortUpstream, upstreamChainID)
c.upstream = chain.NewSimpleClient(*chain.NewEthClient(ethclient.NewClient(upstreamClient), limiter, upstreamClient, rpcName), upstreamChainID)
ethClients := []*chain.EthClient{
chain.NewEthClient(ethclient.NewClient(upstreamClient), limiter, upstreamClient, rpcName),
}
c.upstream = chain.NewCachedClient(ethClients, upstreamChainID, db)
}
c.router = newRouter(c.upstreamEnabled)
@ -233,7 +239,7 @@ func (c *Client) getClientUsingCache(chainID uint64) (chain.ClientInterface, err
return nil, fmt.Errorf("could not find any RPC URL for chain: %d", chainID)
}
client := chain.NewClient(ethClients, chainID)
client := chain.NewCachedClient(ethClients, chainID, c.db)
client.WalletNotifier = c.walletNotifier
c.rpcClients[chainID] = client
return client, nil
@ -371,7 +377,12 @@ func (c *Client) UpdateUpstreamURL(url string) error {
if err != nil {
hostPortUpstream = "upstream"
}
c.upstream = chain.NewSimpleClient(*chain.NewEthClient(ethclient.NewClient(rpcClient), rpsLimiter, rpcClient, hostPortUpstream), c.UpstreamChainID)
ethClients := []*chain.EthClient{
chain.NewEthClient(chain.NewEthClient(ethclient.NewClient(rpcClient), rpsLimiter, rpcClient, hostPortUpstream)),
}
c.upstream = chain.NewCachedClient(ethClients, c.UpstreamChainID, c.db)
c.upstreamURL = url
c.Unlock()

View File

@ -0,0 +1,28 @@
-- store raw block headers
CREATE TABLE IF NOT EXISTS blockchain_data_blocks (
chain_id UNSIGNED BIGINT NOT NULL,
block_number BLOB NOT NULL,
block_hash BLOB NOT NULL,
block_header_json JSON NOT NULL,
block_json JSON,
CONSTRAINT unique_block_header_per_chain_per_block_number UNIQUE (chain_id,block_number) ON CONFLICT REPLACE,
CONSTRAINT unique_block_header_per_chain_per_block_hash UNIQUE (chain_id,block_hash) ON CONFLICT REPLACE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_blockchain_data_block_headers_chain_id_block_number ON blockchain_data_block_headers (chain_id, block_number);
CREATE INDEX IF NOT EXISTS idx_blockchain_data_block_headers_chain_id_block_hash ON blockchain_data_block_headers (chain_id, block_hash);
-- store raw transactions
CREATE TABLE IF NOT EXISTS blockchain_data_transactions (
chain_id UNSIGNED BIGINT NOT NULL,
block_hash BLOB NOT NULL,
transaction_hash BLOB NOT NULL,
transaction_json JSON NOT NULL,
receipt_json JSON,
CONSTRAINT unique_transaction_per_chain_per_transaction_hash UNIQUE (chain_id, transaction_hash) ON CONFLICT REPLACE,
FOREIGN KEY(chain_id, block_hash) REFERENCES blockchain_data_block_headers(chain_id, block_hash)
ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_blockchain_data_transactions_chain_id_transaction_hash ON blockchain_data_transactions (chain_id, transaction_hash);
CREATE INDEX IF NOT EXISTS idx_blockchain_data_transactions_chain_id_block_hash ON blockchain_data_transactions (chain_id, block_hash);