diff --git a/giga/evmonly/executor_test.go b/giga/evmonly/executor_test.go index 43c9f8388b..e2a109f633 100644 --- a/giga/evmonly/executor_test.go +++ b/giga/evmonly/executor_test.go @@ -1268,29 +1268,6 @@ func TestExecutorValidationFailuresAbortBlock(t *testing.T) { require.Equal(t, big.NewInt(0), state.GetBalance(recipient)) }) - t.Run("nonce too low", func(t *testing.T) { - key, err := crypto.GenerateKey() - require.NoError(t, err) - sender := crypto.PubkeyToAddress(key.PublicKey) - - state := NewMemoryState() - state.SetBalance(sender, big.NewInt(1_000_000_000_000_000)) - state.SetNonce(sender, 1) - rawTx := signLegacyTx(t, key, chainID, 0, &recipient, big.NewInt(1), nil) - executor := NewExecutor(Config{}, withTestState(state)) - - result, err := executor.ExecuteBlock(t.Context(), BlockRequest{ - Context: blockContext(chainID), - Txs: [][]byte{rawTx}, - }) - - require.Error(t, err) - require.True(t, errors.Is(err, core.ErrNonceTooLow)) - require.Nil(t, result) - require.Equal(t, uint64(1), state.GetNonce(sender)) - require.Equal(t, big.NewInt(0), state.GetBalance(recipient)) - }) - t.Run("insufficient balance", func(t *testing.T) { key, err := crypto.GenerateKey() require.NoError(t, err) diff --git a/giga/evmonly/receipt.go b/giga/evmonly/receipt.go index c9dfba3570..3737586622 100644 --- a/giga/evmonly/receipt.go +++ b/giga/evmonly/receipt.go @@ -2,9 +2,11 @@ package evmonly import ( "context" + "errors" "fmt" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" @@ -67,7 +69,10 @@ func encodeReceiptRecord(blockNumber uint64, result *BlockResult, i int, records if txResult.Err != nil { stored.VmError = txResult.Err.Error() } - records[i] = receipt.ReceiptRecord{TxHash: ethReceipt.TxHash, Receipt: stored} + records[i] = receipt.ReceiptRecord{ + TxHash: ethReceipt.TxHash, Receipt: stored, + KeepExisting: errors.Is(txResult.Err, core.ErrNonceTooLow), + } } return nil } diff --git a/giga/evmonly/receipt_store.go b/giga/evmonly/receipt_store.go index 910edb9d91..224b924b60 100644 --- a/giga/evmonly/receipt_store.go +++ b/giga/evmonly/receipt_store.go @@ -127,8 +127,9 @@ func (s *MemoryReceiptStore) SetReceipts(ctx sdk.Context, records []receipt.Rece latestVersion = blockVersion } stored = append(stored, receipt.ReceiptRecord{ - TxHash: record.TxHash, - Receipt: cloneStoredReceipt(record.Receipt), + TxHash: record.TxHash, + Receipt: cloneStoredReceipt(record.Receipt), + KeepExisting: record.KeepExisting, }) } if err := receiptContextError(ctx); err != nil { @@ -148,6 +149,13 @@ func (s *MemoryReceiptStore) storeRecords(ctx sdk.Context, stored []receipt.Rece if err := receiptContextError(ctx); err != nil { return err } + stored, err := receipt.FilterExistingReceipts(stored, func(hash common.Hash) (bool, error) { + _, found := s.byTxHash[hash] + return found, nil + }) + if err != nil { + return err + } for _, record := range stored { if previous, ok := s.byTxHash[record.TxHash]; ok { delete(s.blocks[previous.blockNumber], record.TxHash) diff --git a/giga/evmonly/rpc/block.go b/giga/evmonly/rpc/block.go index 98a79240bf..13aa70d834 100644 --- a/giga/evmonly/rpc/block.go +++ b/giga/evmonly/rpc/block.go @@ -79,8 +79,7 @@ func resolveBlockByNumber(ctx context.Context, backend Backend, number ethrpc.Bl // encodeBlock renders block as an eth_getBlockBy* response. nonce, mixHash, // sha3Uncles, difficulty, extraData, uncles, and totalDifficulty are always // their Ethereum-inapplicable zero value, matching evmrpc's v2 encoder. -// logsBloom is always zero: unlike v2, this execution path does not read a -// receipt per transaction to aggregate one. +// logsBloom is always zero; receipt blooms are not aggregated here. func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlock, fullTx bool) (map[string]any, error) { number := block.Block.Height blockHash := common.BytesToHash(block.BlockID.Hash) @@ -99,7 +98,6 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo txs := block.Block.Txs transactions := make([]any, len(txs)) - var lastReceipt *evmtypes.Receipt if fullTx { chainConfig, err := api.backend.EvmChainConfig() if err != nil { @@ -114,7 +112,7 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo if err != nil { return nil, err } - stored, err := api.receiptFor(ctx, ethtx.Hash()) + stored, err := receiptFor(ctx, api.store, ethtx.Hash()) if err != nil { return nil, fmt.Errorf("read transaction receipt at block %d index %d: %w", number, i, err) } @@ -123,9 +121,6 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo replaceFrom(result, stored) } transactions[i] = result - if i == len(txs)-1 { - lastReceipt = stored - } } } else { for i, raw := range txs { @@ -133,27 +128,12 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo if err != nil { return nil, err } - hash := ethtx.Hash() - transactions[i] = hash - if i == len(txs)-1 { - lastReceipt, err = api.receiptFor(ctx, hash) - if err != nil { - return nil, fmt.Errorf("read last transaction receipt for block %d: %w", number, err) - } - } + transactions[i] = ethtx.Hash() } } - // The last transaction's CumulativeGasUsed already equals the whole - // block's gas used; summing every transaction's own GasUsed would need a - // receipt per transaction instead of one. - // - // This total is scoped to the one Autobahn lane this block belongs to, - // not every lane executing concurrently at this point in the chain. - // Revisit once superblocks merge lanes into a single block; punted for - // now since a block today is exactly one lane's transactions. - var gasUsed hexutil.Uint64 - if lastReceipt != nil && lastReceipt.BlockNumber == uint64(number) { //nolint:gosec // G115: number is a validated block height. - gasUsed = hexutil.Uint64(lastReceipt.CumulativeGasUsed) + gasUsed, err := blockGasUsed(ctx, api.store, block) + if err != nil { + return nil, err } result := map[string]any{ @@ -169,7 +149,7 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo "difficulty": (*hexutil.Big)(big.NewInt(0)), // inapplicable to Sei "extraData": hexutil.Bytes{}, // inapplicable to Sei "gasLimit": hexutil.Uint64(gasLimit), - "gasUsed": gasUsed, + "gasUsed": hexutil.Uint64(gasUsed), "timestamp": hexutil.Uint64(blockUnix), "milliTimestamp": hexutil.Uint64(block.Block.Time.UnixMilli()), //nolint:gosec // G115: block timestamps are positive. "transactionsRoot": common.BytesToHash(block.Block.DataHash), @@ -187,8 +167,8 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo // receiptFor returns hash's stored receipt, or nil with a nil error when no // receipt is stored for it. -func (api *blockAPI) receiptFor(ctx context.Context, hash common.Hash) (*evmtypes.Receipt, error) { - stored, err := api.store.GetReceipt(receiptContext(ctx), hash) +func receiptFor(ctx context.Context, store receiptpkg.ReceiptStore, hash common.Hash) (*evmtypes.Receipt, error) { + stored, err := store.GetReceipt(receiptContext(ctx), hash) if errors.Is(err, receiptpkg.ErrNotFound) { return nil, nil } @@ -197,3 +177,31 @@ func (api *blockAPI) receiptFor(ctx context.Context, hash common.Hash) (*evmtype } return stored, nil } + +// blockGasUsed returns the cumulative gas from the last receipt belonging to +// block at its original index, or zero when none is available. +func blockGasUsed(ctx context.Context, store receiptpkg.ReceiptStore, block *coretypes.ResultBlock) (uint64, error) { + // A trailing stale transaction may have no receipt or retain one from another + // block. Walk backwards to the last transaction with a receipt for this block. + // The total covers this lane's block; superblocks merging lanes would need a + // combined total instead. + number := block.Block.Height + if store == nil || number > store.LatestVersion() || number < store.EarliestVersion() { + return 0, nil + } + txs := block.Block.Txs + for i := len(txs) - 1; i >= 0; i-- { + tx, err := decodeBlockTx(txs[i], number, i) + if err != nil { + return 0, err + } + stored, err := receiptFor(ctx, store, tx.Hash()) + if err != nil { + return 0, fmt.Errorf("read transaction receipt at block %d index %d: %w", number, i, err) + } + if stored != nil && stored.BlockNumber == uint64(number) && uint64(stored.TransactionIndex) == uint64(i) { //nolint:gosec // G115: height and index are non-negative. + return stored.CumulativeGasUsed, nil + } + } + return 0, nil +} diff --git a/giga/evmonly/rpc/block_gas_test.go b/giga/evmonly/rpc/block_gas_test.go new file mode 100644 index 0000000000..aafdc2ce2a --- /dev/null +++ b/giga/evmonly/rpc/block_gas_test.go @@ -0,0 +1,166 @@ +package rpc + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" + tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" +) + +func TestBlockGasRPCsUseLastReceiptAtMatchingHeight(t *testing.T) { + for _, tc := range []struct { + name string + first bool + lastHeight uint64 + replayFirst bool + want uint64 + }{ + {name: "last receipt matches", first: true, lastHeight: 9, want: 43_500}, + {name: "trailing replay of first transaction", first: true, lastHeight: 9, replayFirst: true, want: 43_500}, + {name: "trailing receipt missing", first: true, want: 21_000}, + {name: "trailing receipt from earlier block", first: true, lastHeight: 8, want: 21_000}, + {name: "trailing receipt from later block", first: true, lastHeight: 10, want: 21_000}, + {name: "no matching receipts", lastHeight: 8}, + {name: "all receipts missing"}, + } { + t.Run(tc.name, func(t *testing.T) { + tx1, raw1 := testSignedTransaction(t) + tx2, raw2 := secondSignedTransaction(t) + store := evmonly.NewMemoryReceiptStore() + var records []receipt.ReceiptRecord + if tc.first { + records = append(records, receipt.ReceiptRecord{TxHash: tx1.Hash(), Receipt: &evmtypes.Receipt{ + TxHashHex: tx1.Hash().Hex(), BlockNumber: 9, CumulativeGasUsed: 21_000, + }}) + } + if tc.lastHeight != 0 { + records = append(records, receipt.ReceiptRecord{TxHash: tx2.Hash(), Receipt: &evmtypes.Receipt{ + TxHashHex: tx2.Hash().Hex(), BlockNumber: tc.lastHeight, TransactionIndex: 1, CumulativeGasUsed: 43_500, + }}) + } + require.NoError(t, store.SetReceipts(sdk.Context{}.WithContext(t.Context()).WithBlockHeight(9), records)) + trailing := raw2 + if tc.replayFirst { + trailing = raw1 + } + blockHash := common.HexToHash("0x9") + block := &coretypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{ + Header: tmtypes.Header{Height: 9, Time: time.Unix(1_700_000_000, 0)}, + Data: tmtypes.Data{Txs: tmtypes.Txs{raw1, raw2, trailing}}, + }, + } + backend := fixedGasLimitBackend(t, 100_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return block, nil + }) + backend.blockByHash = func(context.Context, *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) { + return block, nil + } + backend.minGasPrice = func() (*big.Int, error) { return big.NewInt(1_000_000_000), nil } + blocks := &blockAPI{backend: backend, store: store} + for _, fullTx := range []bool{false, true} { + byNumber, err := blocks.GetBlockByNumber(t.Context(), 9, fullTx) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(tc.want), byNumber["gasUsed"]) + byHash, err := blocks.GetBlockByHash(t.Context(), blockHash, fullTx) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(tc.want), byHash["gasUsed"]) + } + history, err := (&infoAPI{backend: backend, store: store}).FeeHistory(t.Context(), 1, ethrpc.BlockNumber(9), nil) + require.NoError(t, err) + require.Equal(t, []float64{float64(tc.want) / 100_000}, history.GasUsedRatio) + }) + } +} + +type failingGasReceiptStore struct { + receipt.ReceiptStore + hash common.Hash + err error +} + +func (s failingGasReceiptStore) GetReceipt(ctx sdk.Context, hash common.Hash) (*evmtypes.Receipt, error) { + if hash == s.hash { + return nil, s.err + } + return s.ReceiptStore.GetReceipt(ctx, hash) +} + +func TestBlockGasRPCsPropagateReceiptReadErrors(t *testing.T) { + tx, raw := testSignedTransaction(t) + _, trailing := secondSignedTransaction(t) + readErr := errors.New("receipt read failed") + memory := evmonly.NewMemoryReceiptStore() + require.NoError(t, memory.SetLatestVersion(9)) + store := failingGasReceiptStore{ReceiptStore: memory, hash: tx.Hash(), err: readErr} + block := &coretypes.ResultBlock{Block: &tmtypes.Block{ + Header: tmtypes.Header{Height: 9, Time: time.Unix(1_700_000_000, 0)}, + Data: tmtypes.Data{Txs: tmtypes.Txs{raw, trailing}}, + }} + backend := fixedGasLimitBackend(t, 100_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return block, nil + }) + backend.minGasPrice = func() (*big.Int, error) { return big.NewInt(1_000_000_000), nil } + for _, fullTx := range []bool{false, true} { + _, err := (&blockAPI{backend: backend, store: store}).GetBlockByNumber(t.Context(), 9, fullTx) + require.ErrorIs(t, err, readErr) + } + _, err := (&infoAPI{backend: backend, store: store}).FeeHistory(t.Context(), 1, 9, nil) + require.ErrorIs(t, err, readErr) +} + +func TestBlockGasRPCsSkipUnavailableReceiptHeights(t *testing.T) { + for _, tc := range []struct { + name string + latest int64 + earliest int64 + }{ + {name: "pending", latest: 8}, + {name: "pruned", latest: 10, earliest: 10}, + } { + t.Run(tc.name, func(t *testing.T) { + memory := evmonly.NewMemoryReceiptStore() + require.NoError(t, memory.SetLatestVersion(tc.latest)) + require.NoError(t, memory.SetEarliestVersion(tc.earliest)) + store := &countingReceiptStore{ReceiptStore: memory} + _, raw := testSignedTransaction(t) + block := &coretypes.ResultBlock{Block: &tmtypes.Block{ + Header: tmtypes.Header{Height: 9, Time: time.Unix(1_700_000_000, 0)}, + Data: tmtypes.Data{Txs: make(tmtypes.Txs, 100)}, + }} + for i := range block.Block.Txs { + block.Block.Txs[i] = raw + } + backend := fixedGasLimitBackend(t, 100_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return block, nil + }) + backend.minGasPrice = func() (*big.Int, error) { return big.NewInt(1_000_000_000), nil } + got, err := (&blockAPI{backend: backend, store: store}).GetBlockByNumber(t.Context(), 9, false) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(0), got["gasUsed"]) + history, err := (&infoAPI{backend: backend, store: store}).FeeHistory(t.Context(), 1, 9, nil) + require.NoError(t, err) + require.Equal(t, []float64{0}, history.GasUsedRatio) + require.Zero(t, store.reads) + // Gas lookup also skips decoding transactions for unavailable heights. + block.Block.Txs[99] = []byte("invalid transaction") + gas, err := blockGasUsed(t.Context(), store, block) + require.NoError(t, err) + require.Zero(t, gas) + }) + } +} diff --git a/giga/evmonly/rpc/info.go b/giga/evmonly/rpc/info.go index ae9029bd0f..a83e8678d6 100644 --- a/giga/evmonly/rpc/info.go +++ b/giga/evmonly/rpc/info.go @@ -166,7 +166,7 @@ func (api *infoAPI) walkFeeHistoryRange(ctx context.Context, end, blockCount int if result.OldestBlock == nil { result.OldestBlock = (*hexutil.Big)(big.NewInt(height)) } - ratio, err := api.lastTxGasUsedRatio(ctx, block, gasLimit) + ratio, err := api.blockGasUsedRatio(ctx, block, gasLimit) if err != nil { return nil, err } @@ -217,27 +217,15 @@ func validateRewardPercentiles(percentiles []float64) error { return nil } -// lastTxGasUsedRatio returns block's gas-used ratio, read from its last -// transaction's receipt. Returns 0 both for an empty block and for one whose -// last receipt is missing or stale; the two are indistinguishable here. -func (api *infoAPI) lastTxGasUsedRatio(ctx context.Context, block *coretypes.ResultBlock, gasLimit uint64) (float64, error) { - txs := block.Block.Txs - if len(txs) == 0 || gasLimit == 0 { +// blockGasUsedRatio returns block's gas-used ratio, or zero when its gas limit +// is zero or no receipt belongs to the block. +func (api *infoAPI) blockGasUsedRatio(ctx context.Context, block *coretypes.ResultBlock, gasLimit uint64) (float64, error) { + if gasLimit == 0 { return 0, nil } - lastTx, err := decodeBlockTx(txs[len(txs)-1], block.Block.Height, len(txs)-1) + gasUsed, err := blockGasUsed(ctx, api.store, block) if err != nil { return 0, err } - stored, err := api.store.GetReceipt(receiptContext(ctx), lastTx.Hash()) - if errors.Is(err, receiptpkg.ErrNotFound) { - return 0, nil - } - if err != nil { - return 0, fmt.Errorf("read last transaction receipt for block %d: %w", block.Block.Height, err) - } - if stored.BlockNumber != uint64(block.Block.Height) { //nolint:gosec // G115: block height is positive. - return 0, nil - } - return float64(stored.CumulativeGasUsed) / float64(gasLimit), nil + return float64(gasUsed) / float64(gasLimit), nil } diff --git a/giga/evmonly/rpc/stale_nonce_test.go b/giga/evmonly/rpc/stale_nonce_test.go new file mode 100644 index 0000000000..fd029f3d4a --- /dev/null +++ b/giga/evmonly/rpc/stale_nonce_test.go @@ -0,0 +1,89 @@ +package rpc + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/export" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" + tmtypes "github.com/sei-protocol/sei-chain/sei-tendermint/types" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" +) + +type countingReceiptStore struct { + receipt.ReceiptStore + reads int +} + +func (s *countingReceiptStore) GetReceipt(ctx sdk.Context, hash common.Hash) (*evmtypes.Receipt, error) { + s.reads++ + return s.ReceiptStore.GetReceipt(ctx, hash) +} + +func TestStaleReceiptRPCsKeepStoredPositions(t *testing.T) { + tx, raw := testSignedTransaction(t) + const index = 99 + store := &countingReceiptStore{ReceiptStore: evmonly.NewMemoryReceiptStore()} + require.NoError(t, store.SetReceipts(sdk.Context{}.WithContext(t.Context()), []receipt.ReceiptRecord{{ + TxHash: tx.Hash(), Receipt: &evmtypes.Receipt{ + BlockNumber: 9, TransactionIndex: index, CumulativeGasUsed: 42_000, + Status: uint32(ethtypes.ReceiptStatusFailed), VmError: "nonce too low", + }, + }})) + blockHash := common.HexToHash("0x9") + block := &coretypes.ResultBlock{BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, Block: &tmtypes.Block{ + Header: tmtypes.Header{Height: 9, Time: time.Unix(1_700_000_000, 0)}, + Data: tmtypes.Data{Txs: make(tmtypes.Txs, index+1)}, + }} + for i := range block.Block.Txs { + block.Block.Txs[i] = raw + } + backend := fixedGasLimitBackend(t, 100_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return block, nil + }) + backend.minGasPrice = func() (*big.Int, error) { return big.NewInt(1_000_000_000), nil } + txs := &txAPI{backend: backend, store: store} + gotReceipt, err := txs.GetTransactionReceipt(t.Context(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, 1, store.reads) + require.Equal(t, hexutil.Uint64(index), gotReceipt["transactionIndex"]) + require.Equal(t, hexutil.Uint64(ethtypes.ReceiptStatusFailed), gotReceipt["status"]) + require.Equal(t, hexutil.Uint64(0), gotReceipt["gasUsed"]) + + store.reads = 0 + gotTx, err := txs.GetTransactionByHash(t.Context(), tx.Hash()) + require.NoError(t, err) + require.Equal(t, 1, store.reads) + require.Equal(t, hexutil.Uint64(index), *gotTx.TransactionIndex) + + store.reads = 0 + gotBlock, err := (&blockAPI{backend: backend, store: store}).GetBlockByNumber(t.Context(), 9, false) + require.NoError(t, err) + require.Equal(t, 1, store.reads) + require.Len(t, gotBlock["transactions"], index+1) + require.Equal(t, hexutil.Uint64(42_000), gotBlock["gasUsed"]) + + fullBlock, err := (&blockAPI{backend: backend, store: store}).GetBlockByNumber(t.Context(), 9, true) + require.NoError(t, err) + transactions := fullBlock["transactions"].([]any) + require.Len(t, transactions, index+1) + for i, transaction := range transactions { + require.Equal(t, hexutil.Uint64(i), *transaction.(*export.RPCTransaction).TransactionIndex) + } + + store.reads = 0 + history, err := (&infoAPI{backend: backend, store: store}).FeeHistory(t.Context(), 1, 9, nil) + require.NoError(t, err) + require.Equal(t, 1, store.reads) + require.Equal(t, []float64{0.42}, history.GasUsedRatio) +} diff --git a/giga/evmonly/stale_nonce_test.go b/giga/evmonly/stale_nonce_test.go new file mode 100644 index 0000000000..687e4f54c9 --- /dev/null +++ b/giga/evmonly/stale_nonce_test.go @@ -0,0 +1,121 @@ +package evmonly + +import ( + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestExecutorStaleNoncesDoNotAbortBlock(t *testing.T) { + for _, workers := range []int{1, 4} { + t.Run(fmt.Sprintf("workers=%d", workers), func(t *testing.T) { + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + recipient := testAddress(0xa7) + state := NewMemoryState() + initialBalance := big.NewInt(1_000_000_000_000_000) + state.SetBalance(sender, initialBalance) + state.SetNonce(sender, 127) + store := NewMemoryStore(state) + receipts := NewMemoryReceiptStore() + executor := NewExecutor(Config{OCCWorkers: workers, RejectUnappliableTxs: true}, withTestStores(store, receipts, store.EncodeChangeSet)) + t.Cleanup(executor.Close) + + // The stale creation also exceeds the block gas limit. Nonce rejection + // must happen before gas reservation and must not create a contract. + stale := signLegacyTx(t, key, chainID, 126, nil, big.NewInt(1), []byte{0x00}) + first := signLegacyTxWithGas(t, key, chainID, 127, &recipient, big.NewInt(1), nil, 21_000) + next := signLegacyTxWithGas(t, key, chainID, 128, &recipient, big.NewInt(1), nil, 21_000) + block := blockContext(chainID) + block.Number = 1 + block.GasLimit = 42_000 + result, err := executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: block, + Txs: [][]byte{stale, first, first, next, next}, + }) + require.NoError(t, err) + defer result.Release() + require.Len(t, result.Txs, 5) + require.Len(t, result.Receipts, 5) + require.Equal(t, uint64(42_000), result.GasUsed) + for _, i := range []int{0, 2, 4} { + require.ErrorIs(t, result.Txs[i].Err, core.ErrNonceTooLow) + require.True(t, result.Txs[i].Rejected) + require.Zero(t, result.Txs[i].GasUsed) + require.Equal(t, ethtypes.ReceiptStatusFailed, result.Receipts[i].Status) + require.Empty(t, result.Receipts[i].Logs) + require.Equal(t, common.Address{}, result.Receipts[i].ContractAddress) + } + for i, cumulative := range []uint64{0, 21_000, 21_000, 42_000, 42_000} { + require.Equal(t, cumulative, result.Receipts[i].CumulativeGasUsed) + require.Equal(t, uint(i), result.Receipts[i].TransactionIndex) + } + view := store.OpenView() + require.Equal(t, uint64(129), view.GetNonce(sender)) + require.Equal(t, big.NewInt(2), new(big.Int).SetBytes(view.GetBalance(recipient).Bytes())) + wantBalance := new(big.Int).Sub(initialBalance, big.NewInt(42_000*testGasPriceWei+2)) + require.Equal(t, wantBalance, new(big.Int).SetBytes(view.GetBalance(sender).Bytes())) + view.Close() + staleReceipt, err := receipts.GetReceipt(newReceiptContext(t.Context(), 1), decodeTx(t, stale).Hash()) + require.NoError(t, err) + require.Equal(t, uint32(ethtypes.ReceiptStatusFailed), staleReceipt.Status) + require.Zero(t, staleReceipt.GasUsed) + require.NotEmpty(t, staleReceipt.VmError) + firstHash := decodeTx(t, first).Hash() + original, err := receipts.GetReceipt(newReceiptContext(t.Context(), 1), firstHash) + require.NoError(t, err) + require.Equal(t, uint32(ethtypes.ReceiptStatusSuccessful), original.Status) + require.Equal(t, uint64(21_000), original.GasUsed) + require.Empty(t, original.VmError) + require.Equal(t, uint32(1), original.TransactionIndex) + + // A replay must preserve the receipt of the original execution. + block.Number = 2 + replayed, err := executor.ExecuteBlock(t.Context(), BlockRequest{Context: block, Txs: [][]byte{first}}) + require.NoError(t, err) + defer replayed.Release() + require.Empty(t, replayed.ChangeSet) + require.Zero(t, replayed.GasUsed) + latest, err := receipts.GetReceipt(newReceiptContext(t.Context(), 2), firstHash) + require.NoError(t, err) + require.Equal(t, original, latest) + }) + } +} + +func TestReceiptRecordsIncludeStaleNonces(t *testing.T) { + for _, count := range []int{2, occParallelReceiptThreshold} { + t.Run(fmt.Sprintf("count=%d", count), func(t *testing.T) { + executor := NewExecutor(Config{OCCWorkers: 4}) + t.Cleanup(executor.Close) + result := &BlockResult{} + for i := range count { + txResult := TxResult{} + if i%2 == 1 { + txResult.Err = fmt.Errorf("replay: %w", core.ErrNonceTooLow) + } + result.Txs = append(result.Txs, txResult) + result.Receipts = append(result.Receipts, ðtypes.Receipt{TransactionIndex: uint(i)}) + } + records, err := executor.receiptRecordsParallel(t.Context(), 1, result) + require.NoError(t, err) + require.Len(t, records, count) + for i, record := range records { + require.Equal(t, uint32(i), record.Receipt.TransactionIndex) + require.Equal(t, i%2 == 1, record.KeepExisting) + if i%2 == 1 { + require.Equal(t, "replay: "+core.ErrNonceTooLow.Error(), record.Receipt.VmError) + require.Zero(t, record.Receipt.GasUsed) + } + } + }) + } +} diff --git a/sei-db/ledger_db/receipt/conditional.go b/sei-db/ledger_db/receipt/conditional.go new file mode 100644 index 0000000000..52a29def78 --- /dev/null +++ b/sei-db/ledger_db/receipt/conditional.go @@ -0,0 +1,39 @@ +package receipt + +import ( + "fmt" + "slices" + + "github.com/ethereum/go-ethereum/common" +) + +// FilterExistingReceipts omits conditional records whose hash is already stored or +// appears earlier in records. Callers must serialize this check with their writes. +func FilterExistingReceipts(records []ReceiptRecord, exists func(common.Hash) (bool, error)) ([]ReceiptRecord, error) { + if !slices.ContainsFunc(records, func(record ReceiptRecord) bool { return record.KeepExisting }) { + return records, nil + } + filtered := make([]ReceiptRecord, 0, len(records)) + seen := make(map[common.Hash]struct{}, len(records)) + for _, record := range records { + if record.Receipt == nil { + continue + } + _, repeated := seen[record.TxHash] + seen[record.TxHash] = struct{}{} + if record.KeepExisting { + if repeated { + continue + } + found, err := exists(record.TxHash) + if err != nil { + return nil, fmt.Errorf("check existing receipt %s: %w", record.TxHash, err) + } + if found { + continue + } + } + filtered = append(filtered, record) + } + return filtered, nil +} diff --git a/sei-db/ledger_db/receipt/conditional_test.go b/sei-db/ledger_db/receipt/conditional_test.go new file mode 100644 index 0000000000..56b201fa42 --- /dev/null +++ b/sei-db/ledger_db/receipt/conditional_test.go @@ -0,0 +1,98 @@ +package receipt_test + +import ( + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth/filters" + storetypes "github.com/sei-protocol/sei-chain/sei-cosmos/store/types" + "github.com/sei-protocol/sei-chain/sei-cosmos/testutil" + dbconfig "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + "github.com/sei-protocol/sei-chain/x/evm/types" + "github.com/stretchr/testify/require" +) + +func TestConditionalReceiptsPreserveCommittedHistory(t *testing.T) { + for _, backend := range []string{"littidx", "pebble"} { + for _, buffer := range []int{0, 16} { + t.Run(fmt.Sprintf("%s/buffer=%d", backend, buffer), func(t *testing.T) { + key := storetypes.NewKVStoreKey("evm") + ctx := testutil.DefaultContext(key, storetypes.NewTransientStoreKey("evm_transient")) + cfg := dbconfig.DefaultReceiptStoreConfig() + cfg.Backend, cfg.DBDirectory, cfg.KeepRecent, cfg.AsyncWriteBuffer = backend, t.TempDir(), 0, buffer + store, err := receipt.NewReceiptStore(cfg, key) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + addr, topic := common.HexToAddress("0xabc"), common.HexToHash("0xdef") + original := litReceipt(1, 0, addr, topic) + original.Receipt.Status = uint32(ethtypes.ReceiptStatusSuccessful) + original.Receipt.CumulativeGasUsed = 21_000 + stale := receipt.ReceiptRecord{TxHash: original.TxHash, KeepExisting: true, Receipt: &types.Receipt{ + TxHashHex: original.TxHash.Hex(), BlockNumber: 1, TransactionIndex: 1, + CumulativeGasUsed: 21_000, VmError: "nonce too low", + }} + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(1), []receipt.ReceiptRecord{original, stale})) + replayed := stale + copyReceipt := *stale.Receipt + copyReceipt.BlockNumber, copyReceipt.TransactionIndex, copyReceipt.CumulativeGasUsed = 2, 0, 0 + replayed.Receipt = ©Receipt + // Queue the replay without waiting for the original block to land. + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(2), []receipt.ReceiptRecord{replayed})) + unknown := replayed + unknown.TxHash = common.HexToHash("0x1234") + unknownReceipt := copyReceipt + unknownReceipt.TxHashHex, unknownReceipt.BlockNumber = unknown.TxHash.Hex(), 3 + unknown.Receipt = &unknownReceipt + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(3), []receipt.ReceiptRecord{unknown})) + require.Eventually(t, func() bool { return store.LatestVersion() == 3 }, 5*time.Second, time.Millisecond) + require.NoError(t, store.Close()) + store, err = receipt.NewReceiptStore(cfg, key) + require.NoError(t, err) + // Preservation must survive reopening, including a replay after restart. + restarted := replayed + restartedReceipt := copyReceipt + restartedReceipt.BlockNumber = 4 + restarted.Receipt = &restartedReceipt + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(4), []receipt.ReceiptRecord{restarted})) + require.Eventually(t, func() bool { return store.LatestVersion() == 4 }, 5*time.Second, time.Millisecond) + got, err := store.GetReceipt(ctx, original.TxHash) + require.NoError(t, err) + require.Equal(t, original.Receipt, got) + got, err = store.GetReceipt(ctx, unknown.TxHash) + require.NoError(t, err) + require.Equal(t, unknown.Receipt, got) + if backend == "littidx" { + logs, err := store.FilterLogs(ctx, 1, 3, filters.FilterCriteria{ + Addresses: []common.Address{addr}, Topics: [][]common.Hash{{topic}}, + }, nil) + require.NoError(t, err) + require.Len(t, logs, 1) + require.Equal(t, original.TxHash, logs[0].TxHash) + require.Equal(t, uint64(1), logs[0].BlockNumber) + require.Equal(t, []walked{{block: 1, txHash: original.TxHash}, {block: 3, txHash: unknown.TxHash}}, drainReceipts(t, store, 1)) + } + }) + } + } +} + +func TestConditionalReceiptDoesNotReuseRetainedLittKey(t *testing.T) { + store, ctx := setupLittIdxSync(t) + original := litReceipt(1, 0, common.HexToAddress("0xabc")) + require.NoError(t, store.SetReceipts(ctx, []receipt.ReceiptRecord{original})) + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(2), nil)) + require.NoError(t, store.(receipt.VersionPinner).SetEarliestVersion(2)) + _, err := store.GetReceipt(ctx, original.TxHash) + require.ErrorIs(t, err, receipt.ErrNotFound) + require.NoError(t, store.SetReceipts(ctx.WithBlockHeight(3), []receipt.ReceiptRecord{{ + TxHash: original.TxHash, KeepExisting: true, + Receipt: &types.Receipt{TxHashHex: original.TxHash.Hex(), BlockNumber: 3, VmError: "nonce too low"}, + }})) + _, err = store.GetReceipt(ctx, original.TxHash) + require.ErrorIs(t, err, receipt.ErrNotFound) + require.Empty(t, drainReceipts(t, store, 2)) +} diff --git a/sei-db/ledger_db/receipt/litt_receipt_store.go b/sei-db/ledger_db/receipt/litt_receipt_store.go index edce136fcd..e9a28e9b59 100644 --- a/sei-db/ledger_db/receipt/litt_receipt_store.go +++ b/sei-db/ledger_db/receipt/litt_receipt_store.go @@ -67,6 +67,7 @@ import ( // Writes are applied in the background, so a receipt is not necessarily readable when SetReceipts // returns. LatestVersion is the watermark of what has been applied; Close waits for the queue. type littReceiptStore struct { + writeMu sync.Mutex values litt.DB receipts litt.Table index dbtypes.KeyValueDB @@ -383,6 +384,8 @@ func (s *littReceiptStore) applyReceipts(height int64, receipts []ReceiptRecord) // writeReceipts writes a block's receipt bodies, log index and version marker. The bodies go to // litt first, so an indexed block always has its values written. func (s *littReceiptStore) writeReceipts(height int64, receipts []ReceiptRecord) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() if height < 0 { return fmt.Errorf("receipt block height must not be negative: %d", height) } @@ -431,6 +434,14 @@ func (s *littReceiptStore) writeReceipts(height int64, receipts []ReceiptRecord) // part slot, so a normal block writes part 0 and legacy migration appends. func (s *littReceiptStore) writeBlock(batch dbtypes.Batch, blockNumber uint64, records []ReceiptRecord) error { sortRecordsByTxIndex(records) + // Check physical keys on the writer, including receipts below the retention + // floor that LittDB has not collected yet. Reusing those keys is also unsafe. + records, err := FilterExistingReceipts(records, func(hash common.Hash) (bool, error) { + return s.receipts.Exists(hash[:]) + }) + if err != nil { + return err + } s.writePhases.SetPhase("probe_part_index") partIndex, err := s.nextPartIndex(blockNumber) @@ -447,6 +458,7 @@ func (s *littReceiptStore) writeBlock(batch dbtypes.Batch, blockNumber uint64, r s.writePhases.SetPhase("encode_values") value := make([]byte, 0) secondaryKeys := make([]*litttypes.SecondaryKey, 0, len(records)) + indexedHashes := make(map[common.Hash]struct{}, len(records)) for _, record := range records { body, err := marshaledReceipt(record) if err != nil { @@ -463,6 +475,13 @@ func (s *littReceiptStore) writeBlock(batch dbtypes.Batch, blockNumber uint64, r partOffset := uint32(len(value)) //nolint:gosec // block regions fit within uint32 value = append(value, bz...) + // LittDB requires unique keys within one Put. Keep the first occurrence + // so a later stale duplicate cannot hide its executed receipt and logs. + if _, exists := indexedHashes[record.TxHash]; exists { + continue + } + indexedHashes[record.TxHash] = struct{}{} + txHash := make([]byte, common.HashLength) copy(txHash, record.TxHash[:]) secondaryKeys = append(secondaryKeys, &litttypes.SecondaryKey{ diff --git a/sei-db/ledger_db/receipt/littidx_test.go b/sei-db/ledger_db/receipt/littidx_test.go index 254cf3fce2..4c9119fc53 100644 --- a/sei-db/ledger_db/receipt/littidx_test.go +++ b/sei-db/ledger_db/receipt/littidx_test.go @@ -513,3 +513,52 @@ func TestLittIdxFilterLogsParallelOrder(t *testing.T) { require.NoError(t, store.Close()) } } + +func TestLittIdxDuplicateHashPreservesExecutedReceipt(t *testing.T) { + store, ctx := setupLittIdxSync(t) + addr := common.HexToAddress("0xabc") + original := litReceipt(1, 0, addr, common.HexToHash("0xdead")) + original.Receipt.Status = uint32(ethtypes.ReceiptStatusSuccessful) + other := litReceipt(1, 1, addr, common.HexToHash("0xdead")) + stale := litReceipt(1, 2, addr) + stale.TxHash = original.TxHash + stale.Receipt.TxHashHex = original.TxHash.Hex() + stale.Receipt.Status = uint32(ethtypes.ReceiptStatusFailed) + stale.Receipt.GasUsed = 0 + stale.Receipt.Logs = nil + stale.Receipt.VmError = "nonce too low" + require.NoError(t, store.SetReceipts(ctx, []receipt.ReceiptRecord{stale, other, original})) + + got, err := store.GetReceipt(ctx, original.TxHash) + require.NoError(t, err) + require.Equal(t, original.Receipt, got) + got, err = store.GetReceipt(ctx, other.TxHash) + require.NoError(t, err) + require.Equal(t, other.Receipt, got) + + logs, err := store.FilterLogs(ctx, 1, 1, filters.FilterCriteria{ + Addresses: []common.Address{addr}, + Topics: [][]common.Hash{{common.HexToHash("0xdead")}}, + }, nil) + require.NoError(t, err) + require.Len(t, logs, 2) + require.Equal(t, original.TxHash, logs[0].TxHash) + require.Equal(t, uint(0), logs[0].TxIndex) + require.Equal(t, []byte{0xde, 0xad}, logs[0].Data) + require.Equal(t, other.TxHash, logs[1].TxHash) + + it, err := store.IterateReceipts(1) + require.NoError(t, err) + defer func() { require.NoError(t, it.Close()) }() + for _, record := range []receipt.ReceiptRecord{original, other} { + ok, err := it.Next() + require.NoError(t, err) + require.True(t, ok) + got, err := it.Receipt() + require.NoError(t, err) + require.Equal(t, record.Receipt, got) + } + ok, err := it.Next() + require.NoError(t, err) + require.False(t, ok) +} diff --git a/sei-db/ledger_db/receipt/receipt_store.go b/sei-db/ledger_db/receipt/receipt_store.go index e4eb8aef5c..2b5ecb34d1 100644 --- a/sei-db/ledger_db/receipt/receipt_store.go +++ b/sei-db/ledger_db/receipt/receipt_store.go @@ -136,6 +136,8 @@ type ReceiptRecord struct { TxHash common.Hash Receipt *types.Receipt ReceiptBytes []byte // Optional pre-marshaled receipt (must match Receipt if set) + // KeepExisting makes this record an insert-only write for its transaction hash. + KeepExisting bool // TxOffset and TxLength locate the raw transaction within its block's stored // value in the block store (the sub-range holding this tx). They are written // into the receipt value's metadata prefix so a receipt lookup can find the @@ -159,6 +161,7 @@ type ReceiptReadMetrics interface { var _ ReceiptStore = (*receiptStore)(nil) type receiptStore struct { + writeMu sync.Mutex db seidbtypes.StateStore storeKey sdk.StoreKey stopPruning chan struct{} @@ -324,6 +327,24 @@ func (s *receiptStore) GetReceiptFromStore(_ sdk.Context, txHash common.Hash) (* } func (s *receiptStore) SetReceipts(ctx sdk.Context, receipts []ReceiptRecord) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + var err error + drained := false + receipts, err = FilterExistingReceipts(receipts, func(hash common.Hash) (bool, error) { + // A conditional read must see every previously queued write. + if !drained { + if waiter, ok := s.db.(seidbtypes.PendingWriteWaiter); ok { + waiter.WaitForPendingWrites() + } + drained = true + } + value, err := s.db.Get(types.ReceiptStoreKey, s.db.GetLatestVersion(), types.ReceiptKey(hash)) + return value != nil, err + }) + if err != nil { + return err + } pairs := make([]*proto.KVPair, 0, len(receipts)) for _, record := range receipts { if record.Receipt == nil { diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index 5362316e33..a0f4b81e6b 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -481,6 +481,31 @@ func TestEVMOnlyApplicationEvmGasLimitReflectsConsensusParams(t *testing.T) { require.Equal(t, uint64(30_000_000), gasLimiter.EvmGasLimit()) } +func TestEVMOnlyApplicationCommitsBlockWithStaleNonce(t *testing.T) { + app := newInitializedEVMOnlyTestApp(t) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + first := signedEVMOnlyTestTxFrom(t, key, evmOnlyTestChainID, 0) + next := signedEVMOnlyTestTxFrom(t, key, evmOnlyTestChainID, 1) + finalizeAndCommitEVMOnlyTestBlock(t, app, evmOnlyTestBlock(1, first)) + + response, err := app.FinalizeBlock(t.Context(), evmOnlyTestBlock(2, first, next, next)) + require.NoError(t, err) + require.Len(t, response.TxResults, 3) + for _, i := range []int{0, 2} { + require.Equal(t, uint32(abci.CodeTypeOK), response.TxResults[i].Code) + require.Equal(t, int64(0), response.TxResults[i].GasUsed) + require.True(t, response.TxResults[i].Log != "") + } + require.Equal(t, int64(21_000), response.TxResults[1].GasUsed) + _, err = app.Commit(t.Context()) + require.NoError(t, err) + require.Equal(t, int64(2), app.LastBlockHeight()) + require.Equal(t, uint64(2), app.EvmNonce(sender)) + finalizeAndCommitEVMOnlyTestBlock(t, app, evmOnlyTestBlock(3)) +} + // TestHashRawTxsMatchesKeccak256Hash pins hashRawTxs to crypto.Keccak256Hash, which keys the sender cache. func TestHashRawTxsMatchesKeccak256Hash(t *testing.T) { for _, count := range []int{0, 1, 2, 17, 64, 65, 200, 1848} {