From 6e7c218351164b8cc18aecbb20a4a9ca675c605c Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 16:10:50 +0200 Subject: [PATCH 1/4] feat(evmonly): add eth_getBlockByNumber and eth_getBlockByHash Add the two block-lookup RPCs to the Autobahn EVM-only executor's JSON-RPC server, matching go-ethereum's response shape while sourcing data from the already-existing Backend.Block/BlockByHash and receipt store. - New giga/evmonly/rpc/block.go: blockAPI.GetBlockByNumber/GetBlockByHash. latest/safe/finalized/pending resolve to the current committed block; any other explicit height (including a past one) is served directly, since block/receipt data is retained indefinitely unlike this application's current-only state view; a height outside the committed range, or "earliest" (this go-ethereum fork's literal height 0, which this executor never commits), resolves to null. - gasUsed is read from the last transaction's receipt CumulativeGasUsed rather than summed per transaction, avoiding an all-receipts fetch. Hash-only transaction lists are the Ethereum keccak256 hash of each decoded transaction, not Block.GetTxHashes()'s Tendermint-internal hash. - parentHash/stateRoot/transactionsRoot/receiptsRoot/miner/logsBloom stay zero-valued: Autobahn's translation from its internal block into this RPC's coretypes.ResultBlock shape never populates the header fields they would read from (confirmed by a scratch test against the real translation, not assumed). - Extend Backend with BlockByHash and EvmGasLimit; thread EvmGasLimit through proxy.Proxy (optional-interface pattern, matching EvmChainConfig) and evmOnlyApplication, which never changes the gas limit after InitChain, so the current value is also correct for any past block. - Update integration_test/autobahn/README.md's RPC surface list and add a cast block usage example. --- giga/evmonly/rpc/block.go | 203 ++++++++++ giga/evmonly/rpc/block_test.go | 352 ++++++++++++++++++ giga/evmonly/rpc/server.go | 5 + giga/evmonly/rpc/setup_test.go | 10 + giga/evmonly/rpc/tx.go | 16 +- integration_test/autobahn/README.md | 31 +- sei-tendermint/internal/evmonlyapp/app.go | 10 + .../internal/evmonlyapp/app_test.go | 17 + sei-tendermint/internal/proxy/proxy.go | 17 + sei-tendermint/internal/proxy/proxy_test.go | 26 ++ sei-tendermint/internal/rpc/core/mempool.go | 5 + 11 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 giga/evmonly/rpc/block.go create mode 100644 giga/evmonly/rpc/block_test.go diff --git a/giga/evmonly/rpc/block.go b/giga/evmonly/rpc/block.go new file mode 100644 index 0000000000..0249741fd7 --- /dev/null +++ b/giga/evmonly/rpc/block.go @@ -0,0 +1,203 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "math/big" + + "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" + ethrpc "github.com/ethereum/go-ethereum/rpc" + + receiptpkg "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" + tmbytes "github.com/sei-protocol/sei-chain/sei-tendermint/libs/bytes" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" +) + +type blockAPI struct { + backend Backend + store receiptpkg.ReceiptStore +} + +// GetBlockByNumber returns the block identified by number, or nil if number +// does not resolve to a committed block. +// +// latest/safe/finalized/pending all resolve to the current committed block, +// the same convention eth_getTransactionCount/eth_getBalance/eth_call use: +// pending returns the latest committed block rather than a mempool-pending +// one, because this server tracks no local mempool. Unlike account state, +// block data is retained indefinitely, so every other explicit height, +// including one below the current chain head, is looked up directly rather +// than rejected. "earliest" is this go-ethereum fork's literal height 0, +// which this executor never commits (its first committed height is 1), so it +// resolves like any other height that does not exist: nil, not an error. A +// height above the chain head resolves the same way. +func (api *blockAPI) GetBlockByNumber(ctx context.Context, number ethrpc.BlockNumber, fullTx bool) (map[string]any, error) { + block, err := api.resolveBlockByNumber(ctx, number) + if err != nil || block == nil { + return nil, err + } + return api.encodeBlock(ctx, block, fullTx) +} + +// GetBlockByHash returns the block with the given hash, or nil if hash is +// unknown. +func (api *blockAPI) GetBlockByHash(ctx context.Context, hash common.Hash, fullTx bool) (map[string]any, error) { + block, err := api.backend.BlockByHash(ctx, &coretypes.RequestBlockByHash{Hash: tmbytes.HexBytes(hash.Bytes())}) + if err != nil { + return nil, err + } + if block == nil || block.Block == nil { + return nil, nil + } + return api.encodeBlock(ctx, block, fullTx) +} + +// resolveBlockByNumber translates number into the height format Block +// expects and looks it up, returning a nil block with a nil error for any +// height Block reports as out of range, whether below the committed range +// (an explicit "earliest"/0) or above it (not yet committed). +func (api *blockAPI) resolveBlockByNumber(ctx context.Context, number ethrpc.BlockNumber) (*coretypes.ResultBlock, error) { + var height *coretypes.Int64 + switch number { + case ethrpc.LatestBlockNumber, ethrpc.SafeBlockNumber, ethrpc.FinalizedBlockNumber, ethrpc.PendingBlockNumber: + // nil height resolves to the current committed block. + default: + h := coretypes.Int64(number.Int64()) + height = &h + } + block, err := api.backend.Block(ctx, &coretypes.RequestBlockInfo{Height: height}) + if errors.Is(err, coretypes.ErrHeightExceedsChainHead) || errors.Is(err, coretypes.ErrZeroOrNegativeHeight) { + return nil, nil + } + if err != nil { + return nil, err + } + if block == nil || block.Block == nil { + return nil, nil + } + return block, nil +} + +// encodeBlock renders block as an eth_getBlockBy* response. +// +// Several fields the real chain's evmrpc populates from the CometBFT header +// are always zero here: parentHash, stateRoot, transactionsRoot, +// receiptsRoot, and miner. Autobahn's translation from its own GlobalBlock +// into this coretypes.ResultBlock shape (gigaRouterCommon.translateGlobalBlock) +// only fills in BlockID.Hash and Header.ChainID/Height/Time/Data.Txs; every +// other header field, including the ones those five read from, stays at its +// zero value. logsBloom is also left zero: computing the real value requires +// a receipt per transaction, the same cost eth_getBlockReceipts pays and +// which this method deliberately avoids for gasUsed. +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) + blockUnix, ok := utils.SafeCast[uint64](block.Block.Time.Unix()) + if !ok { + return nil, fmt.Errorf("block %d time is negative: %s", number, block.Block.Time) + } + gasLimit, err := api.backend.EvmGasLimit() + if err != nil { + return nil, err + } + + txs := block.Block.Txs + transactions := make([]any, len(txs)) + var lastReceipt *evmtypes.Receipt + if fullTx { + chainConfig, err := api.backend.EvmChainConfig() + if err != nil { + return nil, err + } + // Must match the base fee EvmCall executes under (evmOnlyBaseFee). + baseFee := new(big.Int) + for i, raw := range txs { + ethtx, err := decodeBlockTx(raw, number, i) + if err != nil { + return nil, err + } + stored, err := api.receiptFor(ctx, ethtx.Hash()) + if err != nil { + return nil, fmt.Errorf("read transaction receipt at block %d index %d: %w", number, i, err) + } + result := export.NewRPCTransaction(ethtx, blockHash, uint64(number), blockUnix, uint64(i), baseFee, chainConfig) //nolint:gosec // G115: number is a validated block height. + if stored != nil { + replaceFrom(result, stored) + } + transactions[i] = result + if i == len(txs)-1 { + lastReceipt = stored + } + } + } else { + for i, raw := range txs { + ethtx, err := decodeBlockTx(raw, number, i) + 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) + } + } + } + } + // 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. + var gasUsed hexutil.Uint64 + if lastReceipt != nil { + gasUsed = hexutil.Uint64(lastReceipt.CumulativeGasUsed) + } + + result := map[string]any{ + "number": (*hexutil.Big)(big.NewInt(number)), + "hash": blockHash, + "parentHash": common.BytesToHash(block.Block.LastBlockID.Hash), + "nonce": ethtypes.BlockNonce{}, // inapplicable to Sei + "mixHash": common.Hash{}, // inapplicable to Sei + "sha3Uncles": ethtypes.EmptyUncleHash, // inapplicable to Sei + "logsBloom": ethtypes.Bloom{}, + "stateRoot": common.BytesToHash(block.Block.AppHash), + "miner": common.BytesToAddress(block.Block.ProposerAddress), + "difficulty": (*hexutil.Big)(big.NewInt(0)), // inapplicable to Sei + "extraData": hexutil.Bytes{}, // inapplicable to Sei + "gasLimit": hexutil.Uint64(gasLimit), + "gasUsed": 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), + "receiptsRoot": common.BytesToHash(block.Block.LastResultsHash), + "size": hexutil.Uint64(block.Block.Size()), //nolint:gosec // G115: block size is positive. + "uncles": []common.Hash{}, // inapplicable to Sei + "transactions": transactions, + // Must match the base fee EvmCall executes under (evmOnlyBaseFee). + "baseFeePerGas": (*hexutil.Big)(new(big.Int)), + } + if fullTx { + result["totalDifficulty"] = (*hexutil.Big)(big.NewInt(0)) // inapplicable to Sei + } + return result, nil +} + +// 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) + if errors.Is(err, receiptpkg.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return stored, nil +} diff --git a/giga/evmonly/rpc/block_test.go b/giga/evmonly/rpc/block_test.go new file mode 100644 index 0000000000..194300d9e4 --- /dev/null +++ b/giga/evmonly/rpc/block_test.go @@ -0,0 +1,352 @@ +package rpc + +import ( + "context" + "fmt" + "math/big" + "net/http/httptest" + "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/crypto" + "github.com/ethereum/go-ethereum/export" + "github.com/ethereum/go-ethereum/params" + 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/libs/utils" + "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" +) + +// secondSignedTransaction returns a transaction distinct from +// testSignedTransaction's: a different sender and a legacy tx with a +// different nonce/recipient, so multi-tx block tests can tell the two apart. +func secondSignedTransaction(t *testing.T) (*ethtypes.Transaction, []byte) { + t.Helper() + key, err := crypto.HexToECDSA("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + require.NoError(t, err) + to := common.HexToAddress("0x3000000000000000000000000000000000000003") + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 2, + GasPrice: big.NewInt(1_000_000_000), + Gas: 21_000, + To: &to, + Value: big.NewInt(2), + }) + tx, err = ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(big.NewInt(713715)), key) + require.NoError(t, err) + raw, err := tx.MarshalBinary() + require.NoError(t, err) + return tx, raw +} + +// fixedGasLimitBackend returns a testBackend with a fixed EvmGasLimit and +// EvmChainConfig, wired with block as its Block lookup. +func fixedGasLimitBackend(t *testing.T, gasLimit uint64, block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error)) *testBackend { + t.Helper() + return &testBackend{ + block: block, + gasLimit: func() (uint64, error) { return gasLimit, nil }, + chainConfig: func() (*params.ChainConfig, error) { return testChainConfig(big.NewInt(713715)), nil }, + } +} + +func TestGetBlockByNumberCurrentStateTagsPassNilHeight(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + blockTime := time.Unix(1_700_000_000, 0) + for _, tag := range []ethrpc.BlockNumber{ + ethrpc.LatestBlockNumber, ethrpc.SafeBlockNumber, ethrpc.FinalizedBlockNumber, ethrpc.PendingBlockNumber, + } { + backend := fixedGasLimitBackend(t, 35_000_000, func(_ context.Context, req *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + require.Nil(t, req.Height) + return &coretypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{Header: tmtypes.Header{Height: 9, Time: blockTime}}, + }, nil + }) + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), tag, false) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, (*hexutil.Big)(big.NewInt(9)), got["number"]) + } +} + +func TestGetBlockByNumberAcceptsAnExplicitHistoricalHeight(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + backend := fixedGasLimitBackend(t, 35_000_000, func(_ context.Context, req *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + require.NotNil(t, req.Height) + require.Equal(t, coretypes.Int64(3), *req.Height) + return &coretypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{Header: tmtypes.Header{Height: 3, Time: time.Unix(1_700_000_000, 0)}}, + }, nil + }) + + // Unlike requireCurrentState (eth_getBalance/eth_getTransactionCount/eth_call), + // GetBlockByNumber serves a past height directly: block and receipt data is + // retained indefinitely, unlike this application's current-only state view. + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.BlockNumber(3), false) + + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, (*hexutil.Big)(big.NewInt(3)), got["number"]) +} + +func TestGetBlockByNumberReturnsNullForAFutureHeight(t *testing.T) { + backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return nil, fmt.Errorf("%w: 100", coretypes.ErrHeightExceedsChainHead) + }) + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.BlockNumber(100), false) + + require.NoError(t, err) + require.Nil(t, got) +} + +func TestGetBlockByNumberEarliestReturnsNullBeforeAnyCommittedBlock(t *testing.T) { + // "earliest" is represented as literal height 0 in this go-ethereum fork, + // a height this executor never commits (its first committed height is 1), + // so Block reports it out of range the same way it would any other + // nonexistent height. + backend := fixedGasLimitBackend(t, 35_000_000, func(_ context.Context, req *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + require.NotNil(t, req.Height) + require.Equal(t, coretypes.Int64(0), *req.Height) + return nil, fmt.Errorf("%w: 0", coretypes.ErrZeroOrNegativeHeight) + }) + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.EarliestBlockNumber, false) + + require.NoError(t, err) + require.Nil(t, got) +} + +func TestGetBlockByHashReturnsNullForUnknownHash(t *testing.T) { + backend := &testBackend{ + blockByHash: func(context.Context, *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) { + return &coretypes.ResultBlock{}, nil + }, + } + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByHash(t.Context(), common.Hash{9}, false) + + require.NoError(t, err) + require.Nil(t, got) +} + +func TestGetBlockByHashReturnsTheMatchingBlock(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + var gotHash []byte + backend := fixedGasLimitBackend(t, 35_000_000, nil) + backend.blockByHash = func(_ context.Context, req *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) { + gotHash = req.Hash + return &coretypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{Header: tmtypes.Header{Height: 5, Time: time.Unix(1_700_000_000, 0)}}, + }, nil + } + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByHash(t.Context(), blockHash, false) + + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, blockHash.Bytes(), gotHash) + require.Equal(t, blockHash, got["hash"]) + require.Equal(t, (*hexutil.Big)(big.NewInt(5)), got["number"]) +} + +func TestEncodeBlockEmptyBlockHasZeroGasUsedAndNoTransactions(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return &coretypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{Header: tmtypes.Header{Height: 4, Time: time.Unix(1_700_000_000, 0)}}, + }, nil + }) + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.LatestBlockNumber, false) + + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(0), got["gasUsed"]) + require.Equal(t, []any{}, got["transactions"]) + require.Equal(t, hexutil.Uint64(35_000_000), got["gasLimit"]) +} + +// multiTxBlock builds a two-transaction ResultBlock and the matching receipt +// store entries, returning the block, its two decoded transactions in order, +// and the store. +func multiTxBlock(t *testing.T, height int64, blockHash common.Hash, blockTime time.Time) (*coretypes.ResultBlock, *ethtypes.Transaction, *ethtypes.Transaction, receipt.ReceiptStore) { + t.Helper() + tx1, raw1 := testSignedTransaction(t) + sender1, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(big.NewInt(713715)), tx1) + require.NoError(t, err) + tx2, raw2 := secondSignedTransaction(t) + sender2, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(big.NewInt(713715)), tx2) + require.NoError(t, err) + + store := evmonly.NewMemoryReceiptStore() + require.NoError(t, store.SetReceipts(sdk.Context{}.WithContext(t.Context()), []receipt.ReceiptRecord{ + { + TxHash: tx1.Hash(), + Receipt: &evmtypes.Receipt{ + TxHashHex: tx1.Hash().Hex(), + BlockNumber: uint64(height), //nolint:gosec // G115: test height is positive. + TransactionIndex: 0, + From: sender1.Hex(), + CumulativeGasUsed: 21_000, + }, + }, + { + TxHash: tx2.Hash(), + Receipt: &evmtypes.Receipt{ + TxHashHex: tx2.Hash().Hex(), + BlockNumber: uint64(height), //nolint:gosec // G115: test height is positive. + TransactionIndex: 1, + From: sender2.Hex(), + CumulativeGasUsed: 43_500, + }, + }, + })) + + block := &coretypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{ + Header: tmtypes.Header{Height: height, Time: blockTime}, + Data: tmtypes.Data{Txs: tmtypes.Txs{raw1, raw2}}, + }, + } + return block, tx1, tx2, store +} + +func TestEncodeBlockHashOnlyListMatchesGetTransactionByHash(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + blockTime := time.Unix(1_700_000_000, 0) + block, tx1, tx2, store := multiTxBlock(t, 9, blockHash, blockTime) + backend := fixedGasLimitBackend(t, 35_000_000, func(_ context.Context, req *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + require.NotNil(t, req.Height) + require.Equal(t, coretypes.Int64(9), *req.Height) + return block, nil + }) + + got, err := (&blockAPI{backend: backend, store: store}).GetBlockByNumber(t.Context(), ethrpc.BlockNumber(9), false) + require.NoError(t, err) + gotTxs, ok := got["transactions"].([]any) + require.True(t, ok) + require.Equal(t, []any{tx1.Hash(), tx2.Hash()}, gotTxs) + // The last transaction's receipt already carries the block's total gas used. + require.Equal(t, hexutil.Uint64(43_500), got["gasUsed"]) + + // Cross-check against eth_getTransactionByHash for the same transactions: + // GetBlockByNumber must not use a different hash derivation (e.g. the + // Tendermint-internal raw-bytes hash from Block.GetTxHashes). + txAPI := &txAPI{backend: backend, store: store} + byHash1, err := txAPI.GetTransactionByHash(t.Context(), tx1.Hash()) + require.NoError(t, err) + require.Equal(t, tx1.Hash(), byHash1.Hash) + byHash2, err := txAPI.GetTransactionByHash(t.Context(), tx2.Hash()) + require.NoError(t, err) + require.Equal(t, tx2.Hash(), byHash2.Hash) +} + +func TestEncodeBlockFullTxIncludesDecodedTransactionsInOrder(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + blockTime := time.Unix(1_700_000_000, 0) + block, tx1, tx2, store := multiTxBlock(t, 9, blockHash, blockTime) + backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return block, nil + }) + + got, err := (&blockAPI{backend: backend, store: store}).GetBlockByNumber(t.Context(), ethrpc.LatestBlockNumber, true) + + require.NoError(t, err) + gotTxs, ok := got["transactions"].([]any) + require.True(t, ok) + require.Len(t, gotTxs, 2) + first, ok := gotTxs[0].(*export.RPCTransaction) + require.True(t, ok) + require.Equal(t, tx1.Hash(), first.Hash) + require.Equal(t, hexutil.Uint64(0), *first.TransactionIndex) + second, ok := gotTxs[1].(*export.RPCTransaction) + require.True(t, ok) + require.Equal(t, tx2.Hash(), second.Hash) + require.Equal(t, hexutil.Uint64(1), *second.TransactionIndex) + require.Equal(t, hexutil.Uint64(43_500), got["gasUsed"]) + require.Equal(t, (*hexutil.Big)(big.NewInt(0)), got["totalDifficulty"]) +} + +// TestEncodeBlockDocumentedHeaderGaps pins the fields Autobahn's translation +// from a GlobalBlock to this coretypes.ResultBlock shape +// (gigaRouterCommon.translateGlobalBlock) never populates: parentHash, +// stateRoot, transactionsRoot, receiptsRoot, and miner all stay at their zero +// value, confirmed by reading that translation directly rather than assumed. +func TestEncodeBlockDocumentedHeaderGaps(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return &coretypes.ResultBlock{ + // Matches translateGlobalBlock's output shape exactly: only + // BlockID.Hash and Header.ChainID/Height/Time/Data.Txs are ever + // populated; every other field is left at its zero value. + BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, + Block: &tmtypes.Block{ + Header: tmtypes.Header{ChainID: "evmonly-test", Height: 4, Time: time.Unix(1_700_000_000, 0)}, + LastCommit: &tmtypes.Commit{}, + }, + }, nil + }) + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.LatestBlockNumber, false) + + require.NoError(t, err) + require.Equal(t, common.Hash{}, got["parentHash"]) + require.Equal(t, common.Hash{}, got["stateRoot"]) + require.Equal(t, common.Hash{}, got["transactionsRoot"]) + require.Equal(t, common.Hash{}, got["receiptsRoot"]) + require.Equal(t, common.Address{}, got["miner"]) + require.Equal(t, ethtypes.Bloom{}, got["logsBloom"]) +} + +func TestGetBlockByNumberEndToEnd(t *testing.T) { + blockHash := common.HexToHash("0xabcd") + blockTime := time.Unix(1_700_000_000, 0) + block, tx1, _, store := multiTxBlock(t, 9, blockHash, blockTime) + backend := fixedGasLimitBackend(t, 35_000_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.proxy = utils.None[*ethrpc.Client]() + handler, err := newHandler(backend, store) + require.NoError(t, err) + t.Cleanup(handler.Stop) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := ethrpc.DialHTTP(server.URL) + require.NoError(t, err) + t.Cleanup(client.Close) + + var byNumber map[string]any + require.NoError(t, client.CallContext(t.Context(), &byNumber, "eth_getBlockByNumber", "latest", false)) + require.Equal(t, "0x9", byNumber["number"]) + txHashes, ok := byNumber["transactions"].([]any) + require.True(t, ok) + require.Equal(t, tx1.Hash().Hex(), txHashes[0]) + + var byHash map[string]any + require.NoError(t, client.CallContext(t.Context(), &byHash, "eth_getBlockByHash", blockHash, false)) + require.Equal(t, "0x9", byHash["number"]) + + var missing map[string]any + backend.blockByHash = func(context.Context, *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) { + return &coretypes.ResultBlock{}, nil + } + require.NoError(t, client.CallContext(t.Context(), &missing, "eth_getBlockByHash", common.Hash{9}, false)) + require.Nil(t, missing) +} diff --git a/giga/evmonly/rpc/server.go b/giga/evmonly/rpc/server.go index d75c9eb6e4..6c1d0b98a4 100644 --- a/giga/evmonly/rpc/server.go +++ b/giga/evmonly/rpc/server.go @@ -35,6 +35,7 @@ var logger = seilog.NewLogger("giga", "evmonly", "rpc") // is false every transaction is broadcast locally without recovering its sender. type Backend interface { Block(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + BlockByHash(context.Context, *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) BroadcastTx(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) EvmBalance(common.Address) uint256.Int EvmBaseFee() (*big.Int, error) @@ -42,6 +43,7 @@ type Backend interface { EvmCall(context.Context, *core.Message) (*core.ExecutionResult, error) EvmChainConfig() (*params.ChainConfig, error) EvmChainID() uint64 + EvmGasLimit() (uint64, error) EvmProxy(common.Address) utils.Option[*ethrpc.Client] EvmProxyEnabled() bool EvmTransactionCount(common.Address) uint64 @@ -95,6 +97,9 @@ func newHandler(backend Backend, receiptStore receipt.ReceiptStore) (*ethrpc.Ser if err := rpcServer.RegisterName("eth", &callAPI{backend: backend}); err != nil { return nil, fmt.Errorf("register EVM-only call RPC: %w", err) } + if err := rpcServer.RegisterName("eth", &blockAPI{backend: backend, store: receiptStore}); err != nil { + return nil, fmt.Errorf("register EVM-only block RPC: %w", err) + } return rpcServer, nil } diff --git a/giga/evmonly/rpc/setup_test.go b/giga/evmonly/rpc/setup_test.go index 3cf2958ea6..d000949de8 100644 --- a/giga/evmonly/rpc/setup_test.go +++ b/giga/evmonly/rpc/setup_test.go @@ -18,11 +18,13 @@ type testBackend struct { balance func(common.Address) uint256.Int baseFee func() (*big.Int, error) block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + blockByHash func(context.Context, *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) blockNumber func() uint64 broadcast func(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) call func(context.Context, *core.Message) (*core.ExecutionResult, error) chainConfig func() (*params.ChainConfig, error) chainID func() uint64 + gasLimit func() (uint64, error) proxy utils.Option[*ethrpc.Client] proxyCalls int transactionCount func(common.Address) uint64 @@ -36,6 +38,10 @@ func (b *testBackend) Block(ctx context.Context, req *coretypes.RequestBlockInfo return b.block(ctx, req) } +func (b *testBackend) BlockByHash(ctx context.Context, req *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) { + return b.blockByHash(ctx, req) +} + func (b *testBackend) EvmProxy(common.Address) utils.Option[*ethrpc.Client] { b.proxyCalls++ return b.proxy @@ -69,6 +75,10 @@ func (b *testBackend) EvmChainID() uint64 { return b.chainID() } +func (b *testBackend) EvmGasLimit() (uint64, error) { + return b.gasLimit() +} + func (b *testBackend) EvmTransactionCount(address common.Address) uint64 { return b.transactionCount(address) } diff --git a/giga/evmonly/rpc/tx.go b/giga/evmonly/rpc/tx.go index 0440be6715..0f187cebf7 100644 --- a/giga/evmonly/rpc/tx.go +++ b/giga/evmonly/rpc/tx.go @@ -56,9 +56,9 @@ func (api *txAPI) GetTransactionByHash(ctx context.Context, hash common.Hash) (* return nil, fmt.Errorf("receipt transaction index %d exceeds block %d transaction count %d", stored.TransactionIndex, stored.BlockNumber, len(block.Block.Txs)) } - ethtx := new(ethtypes.Transaction) - if err := ethtx.UnmarshalBinary(block.Block.Txs[stored.TransactionIndex]); err != nil { - return nil, fmt.Errorf("decode transaction at block %d index %d: %w", stored.BlockNumber, stored.TransactionIndex, err) + ethtx, err := decodeBlockTx(block.Block.Txs[stored.TransactionIndex], block.Block.Height, int(stored.TransactionIndex)) + if err != nil { + return nil, err } chainConfig, err := api.backend.EvmChainConfig() if err != nil { @@ -122,6 +122,16 @@ func replaceFrom(tx *export.RPCTransaction, stored *evmtypes.Receipt) { } } +// decodeBlockTx decodes the raw transaction bytes stored at index in block +// blockNumber, identifying the failing position in the returned error. +func decodeBlockTx(raw []byte, blockNumber int64, index int) (*ethtypes.Transaction, error) { + ethtx := new(ethtypes.Transaction) + if err := ethtx.UnmarshalBinary(raw); err != nil { + return nil, fmt.Errorf("decode transaction at block %d index %d: %w", blockNumber, index, err) + } + return ethtx, nil +} + func encodeReceipt(hash common.Hash, stored *evmtypes.Receipt, blockHash common.Hash) map[string]any { logs := make([]*ethtypes.Log, 0, len(stored.Logs)) for _, storedLog := range stored.Logs { diff --git a/integration_test/autobahn/README.md b/integration_test/autobahn/README.md index 2a6f47441f..47cbfb5691 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -377,7 +377,9 @@ The public EVM JSON-RPC surface intentionally contains only: - `eth_getTransactionCount`, for the current committed nonce; - `eth_blockNumber`, for the current committed block height; - `eth_chainId`, for the configured EVM chain ID; -- `eth_call`, for a read-only message call against current committed state. +- `eth_call`, for a read-only message call against current committed state; +- `eth_getBlockByNumber` and `eth_getBlockByHash`, for a finalized block, by + height (including any past height) or by hash. All other `eth_*` methods currently return JSON-RPC method-not-found. A lookup for a pending or unknown hash returns `null`. @@ -493,10 +495,31 @@ blocks) and `blockhash(current-1)` and further back are unavailable (only the current block's own hash is tracked outside of block execution). A view function that depends on either reads a placeholder rather than a real value. +### Fetch a block with `cast block` + +`cast block` works by height or by hash. Unlike `eth_getBalance`, +`eth_getTransactionCount`, and `eth_call`, an explicit height is not +historical-state-restricted: block and receipt data is retained indefinitely, +so any past height works the same as `latest`: + +```sh +cast block --rpc-url http://127.0.0.1:8545 latest +cast block --rpc-url http://127.0.0.1:8545 1 +cast block --rpc-url http://127.0.0.1:8545 0xYOUR_BLOCK_HASH +``` + +A height above the current chain head, or `earliest` (this executor's first +committed height is 1, not 0), returns `null` rather than an error, matching +`eth_getTransactionByHash`'s treatment of an unknown hash. `parentHash`, +`stateRoot`, `transactionsRoot`, `receiptsRoot`, and `miner` are always the +zero value: Autobahn's translation from its internal block representation +into the shape this RPC reads from never populates them. `logsBloom` is also +always empty. `gasUsed` and the transaction list are real, decoded the same +way `eth_getTransactionByHash` decodes a transaction. + The remaining `cast` gaps are RPC gaps, not receipt-decoding gaps. `sei-load` -does not currently print every submitted hash, and there is still no block API -(`eth_getBlockByNumber`/`eth_getBlockByHash`, or the by-block-and-index -transaction lookups) to discover a `sei-load` transfer hash independently. +does not currently print every submitted hash, and there are still no +by-block-and-index transaction lookups or block-transaction-count methods. There are also no fee-estimation, gas-estimation, log, or WebSocket subscription methods. Commands that depend on those queries cannot operate normally; raw transactions must provide gas limit and gas price offline as in diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index d091a16af7..58ac26051d 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -227,6 +227,16 @@ func (a *evmOnlyApplication) LastBlockHeight() int64 { panic("unreachable") } +// EvmGasLimit returns the gas limit of the most recently committed block. +// This application never changes it after InitChain, so it is also the gas +// limit of every earlier committed block. +func (a *evmOnlyApplication) EvmGasLimit() uint64 { + for state := range a.cursor.Lock() { + return state.committed.gasLimit + } + panic("unreachable") +} + func (a *evmOnlyApplication) GetValidators() []abci.ValidatorUpdate { return slices.Clone(a.validators) } diff --git a/sei-tendermint/internal/evmonlyapp/app_test.go b/sei-tendermint/internal/evmonlyapp/app_test.go index 5f9cddd05c..cd64f0de59 100644 --- a/sei-tendermint/internal/evmonlyapp/app_test.go +++ b/sei-tendermint/internal/evmonlyapp/app_test.go @@ -412,3 +412,20 @@ func TestEVMOnlyApplicationReturnsConfiguredValidators(t *testing.T) { first[0].Power = 13 require.Equal(t, []abci.ValidatorUpdate{{Power: 7}}, app.GetValidators()) } + +// evmGasLimiter is implemented by an application that exposes its committed +// block gas limit, matching proxy.evmGasLimitProvider. +type evmGasLimiter interface { + EvmGasLimit() uint64 +} + +func TestEVMOnlyApplicationEvmGasLimitReflectsConsensusParams(t *testing.T) { + app := newInitializedEVMOnlyTestApp(t) + gasLimiter, ok := app.(evmGasLimiter) + require.True(t, ok) + require.Equal(t, uint64(30_000_000), gasLimiter.EvmGasLimit()) + + finalizeAndCommitEVMOnlyTestBlock(t, app, evmOnlyTestBlock(1)) + + require.Equal(t, uint64(30_000_000), gasLimiter.EvmGasLimit()) +} diff --git a/sei-tendermint/internal/proxy/proxy.go b/sei-tendermint/internal/proxy/proxy.go index 68c9ff88bc..3396c5d741 100644 --- a/sei-tendermint/internal/proxy/proxy.go +++ b/sei-tendermint/internal/proxy/proxy.go @@ -114,6 +114,23 @@ func (app *Proxy) EvmBaseFee() (*big.Int, error) { return provider.EvmBaseFee(), nil } +// evmGasLimitProvider is implemented by applications that expose the gas +// limit of their most recently committed block. +type evmGasLimitProvider interface { + EvmGasLimit() uint64 +} + +// EvmGasLimit returns the wrapped application's most recently committed +// block gas limit. It errors if that application does not expose one. +func (app *Proxy) EvmGasLimit() (uint64, error) { + defer addTimeSample(Global.MethodTimingAt("evm_gas_limit", "sync"))() + provider, ok := app.app.(evmGasLimitProvider) + if !ok { + return 0, fmt.Errorf("application does not expose an EVM gas limit") + } + return provider.EvmGasLimit(), nil +} + func (app *Proxy) Commit(ctx context.Context) (*types.ResponseCommit, error) { defer addTimeSample(Global.MethodTimingAt("commit", "sync"))() return app.app.Commit(ctx) diff --git a/sei-tendermint/internal/proxy/proxy_test.go b/sei-tendermint/internal/proxy/proxy_test.go index b2162de797..15ca6ad107 100644 --- a/sei-tendermint/internal/proxy/proxy_test.go +++ b/sei-tendermint/internal/proxy/proxy_test.go @@ -147,6 +147,14 @@ func TestEvmBaseFeeErrorsWhenApplicationDoesNotSupportIt(t *testing.T) { require.Error(t, err) } +func TestEvmGasLimitErrorsWhenApplicationDoesNotSupportIt(t *testing.T) { + proxyApp := New(testApp{}) + + _, err := proxyApp.EvmGasLimit() + + require.Error(t, err) +} + type testEvmBaseFeeApp struct { testApp baseFee *big.Int @@ -165,3 +173,21 @@ func TestEvmBaseFeeDelegatesToASupportingApplication(t *testing.T) { require.NoError(t, err) require.Same(t, want, got) } + +type testEvmGasLimitApp struct { + testApp + gasLimit uint64 +} + +func (app testEvmGasLimitApp) EvmGasLimit() uint64 { + return app.gasLimit +} + +func TestEvmGasLimitDelegatesToASupportingApplication(t *testing.T) { + proxyApp := New(testEvmGasLimitApp{gasLimit: 35_000_000}) + + got, err := proxyApp.EvmGasLimit() + + require.NoError(t, err) + require.Equal(t, uint64(35_000_000), got) +} diff --git a/sei-tendermint/internal/rpc/core/mempool.go b/sei-tendermint/internal/rpc/core/mempool.go index 000a295b35..8046b89977 100644 --- a/sei-tendermint/internal/rpc/core/mempool.go +++ b/sei-tendermint/internal/rpc/core/mempool.go @@ -334,6 +334,11 @@ func (env *Environment) EvmChainConfig() (*params.ChainConfig, error) { return env.App.EvmChainConfig() } +// EvmGasLimit returns the gas limit of the most recently committed block. +func (env *Environment) EvmGasLimit() (uint64, error) { + return env.App.EvmGasLimit() +} + // EvmCall executes msg as a read-only call against the current committed EVM // state, without creating a transaction or persisting any state change. func (env *Environment) EvmCall(ctx context.Context, msg *ethcore.Message) (*ethcore.ExecutionResult, error) { From 0fc7e5329b1e32e93197ca1e59f1b5962fc3d73d Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 16:56:37 +0200 Subject: [PATCH 2/4] Read block base fee from EvmBaseFee, matching tx.go after the giga-1 rebase giga-1's eth_getTransactionByHash gained a real EvmBaseFee() accessor during review (replacing a hardcoded zero) after this branch's eth_call/tx work was written. Align eth_getBlockByNumber/eth_getBlockByHash's full-tx encoding and baseFeePerGas field the same way, and fix the test fixture the rebase otherwise leaves with a nil EvmBaseFee panic. --- giga/evmonly/rpc/block.go | 9 +++++---- giga/evmonly/rpc/block_test.go | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/giga/evmonly/rpc/block.go b/giga/evmonly/rpc/block.go index 0249741fd7..b97e4a045c 100644 --- a/giga/evmonly/rpc/block.go +++ b/giga/evmonly/rpc/block.go @@ -106,6 +106,10 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo if err != nil { return nil, err } + baseFee, err := api.backend.EvmBaseFee() + if err != nil { + return nil, err + } txs := block.Block.Txs transactions := make([]any, len(txs)) @@ -115,8 +119,6 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo if err != nil { return nil, err } - // Must match the base fee EvmCall executes under (evmOnlyBaseFee). - baseFee := new(big.Int) for i, raw := range txs { ethtx, err := decodeBlockTx(raw, number, i) if err != nil { @@ -180,8 +182,7 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo "size": hexutil.Uint64(block.Block.Size()), //nolint:gosec // G115: block size is positive. "uncles": []common.Hash{}, // inapplicable to Sei "transactions": transactions, - // Must match the base fee EvmCall executes under (evmOnlyBaseFee). - "baseFeePerGas": (*hexutil.Big)(new(big.Int)), + "baseFeePerGas": (*hexutil.Big)(baseFee), } if fullTx { result["totalDifficulty"] = (*hexutil.Big)(big.NewInt(0)) // inapplicable to Sei diff --git a/giga/evmonly/rpc/block_test.go b/giga/evmonly/rpc/block_test.go index 194300d9e4..bb5b20c789 100644 --- a/giga/evmonly/rpc/block_test.go +++ b/giga/evmonly/rpc/block_test.go @@ -56,6 +56,7 @@ func fixedGasLimitBackend(t *testing.T, gasLimit uint64, block func(context.Cont block: block, gasLimit: func() (uint64, error) { return gasLimit, nil }, chainConfig: func() (*params.ChainConfig, error) { return testChainConfig(big.NewInt(713715)), nil }, + baseFee: func() (*big.Int, error) { return new(big.Int), nil }, } } From a12cba7dc77c366936dd43a41201fe99382a935c Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 17:05:50 +0200 Subject: [PATCH 3/4] Trim comments in block.go/block_test.go to state, not rationale Godocs and test comments here read as design narration (which convention this matches, why an internal function was picked, what the underlying Autobahn translation does and doesn't populate). Cut to what each function returns and what a reader needs to know; the git history and this PR's description carry the rest. --- giga/evmonly/rpc/block.go | 35 ++++++++-------------------------- giga/evmonly/rpc/block_test.go | 21 +++----------------- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/giga/evmonly/rpc/block.go b/giga/evmonly/rpc/block.go index b97e4a045c..14255d77ee 100644 --- a/giga/evmonly/rpc/block.go +++ b/giga/evmonly/rpc/block.go @@ -25,18 +25,9 @@ type blockAPI struct { } // GetBlockByNumber returns the block identified by number, or nil if number -// does not resolve to a committed block. -// -// latest/safe/finalized/pending all resolve to the current committed block, -// the same convention eth_getTransactionCount/eth_getBalance/eth_call use: -// pending returns the latest committed block rather than a mempool-pending -// one, because this server tracks no local mempool. Unlike account state, -// block data is retained indefinitely, so every other explicit height, -// including one below the current chain head, is looked up directly rather -// than rejected. "earliest" is this go-ethereum fork's literal height 0, -// which this executor never commits (its first committed height is 1), so it -// resolves like any other height that does not exist: nil, not an error. A -// height above the chain head resolves the same way. +// does not resolve to a committed block. latest/safe/finalized/pending all +// resolve to the current committed block; any other height, past or future, +// is looked up directly. func (api *blockAPI) GetBlockByNumber(ctx context.Context, number ethrpc.BlockNumber, fullTx bool) (map[string]any, error) { block, err := api.resolveBlockByNumber(ctx, number) if err != nil || block == nil { @@ -58,10 +49,8 @@ func (api *blockAPI) GetBlockByHash(ctx context.Context, hash common.Hash, fullT return api.encodeBlock(ctx, block, fullTx) } -// resolveBlockByNumber translates number into the height format Block -// expects and looks it up, returning a nil block with a nil error for any -// height Block reports as out of range, whether below the committed range -// (an explicit "earliest"/0) or above it (not yet committed). +// resolveBlockByNumber looks up number, returning a nil block and nil error +// for any height outside the committed range. func (api *blockAPI) resolveBlockByNumber(ctx context.Context, number ethrpc.BlockNumber) (*coretypes.ResultBlock, error) { var height *coretypes.Int64 switch number { @@ -84,17 +73,9 @@ func (api *blockAPI) resolveBlockByNumber(ctx context.Context, number ethrpc.Blo return block, nil } -// encodeBlock renders block as an eth_getBlockBy* response. -// -// Several fields the real chain's evmrpc populates from the CometBFT header -// are always zero here: parentHash, stateRoot, transactionsRoot, -// receiptsRoot, and miner. Autobahn's translation from its own GlobalBlock -// into this coretypes.ResultBlock shape (gigaRouterCommon.translateGlobalBlock) -// only fills in BlockID.Hash and Header.ChainID/Height/Time/Data.Txs; every -// other header field, including the ones those five read from, stays at its -// zero value. logsBloom is also left zero: computing the real value requires -// a receipt per transaction, the same cost eth_getBlockReceipts pays and -// which this method deliberately avoids for gasUsed. +// encodeBlock renders block as an eth_getBlockBy* response. parentHash, +// stateRoot, transactionsRoot, receiptsRoot, miner, and logsBloom are always +// zero: this execution path's block translation never populates them. 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) diff --git a/giga/evmonly/rpc/block_test.go b/giga/evmonly/rpc/block_test.go index bb5b20c789..875d5fd0b7 100644 --- a/giga/evmonly/rpc/block_test.go +++ b/giga/evmonly/rpc/block_test.go @@ -91,9 +91,6 @@ func TestGetBlockByNumberAcceptsAnExplicitHistoricalHeight(t *testing.T) { }, nil }) - // Unlike requireCurrentState (eth_getBalance/eth_getTransactionCount/eth_call), - // GetBlockByNumber serves a past height directly: block and receipt data is - // retained indefinitely, unlike this application's current-only state view. got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.BlockNumber(3), false) require.NoError(t, err) @@ -113,10 +110,6 @@ func TestGetBlockByNumberReturnsNullForAFutureHeight(t *testing.T) { } func TestGetBlockByNumberEarliestReturnsNullBeforeAnyCommittedBlock(t *testing.T) { - // "earliest" is represented as literal height 0 in this go-ethereum fork, - // a height this executor never commits (its first committed height is 1), - // so Block reports it out of range the same way it would any other - // nonexistent height. backend := fixedGasLimitBackend(t, 35_000_000, func(_ context.Context, req *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { require.NotNil(t, req.Height) require.Equal(t, coretypes.Int64(0), *req.Height) @@ -244,9 +237,6 @@ func TestEncodeBlockHashOnlyListMatchesGetTransactionByHash(t *testing.T) { // The last transaction's receipt already carries the block's total gas used. require.Equal(t, hexutil.Uint64(43_500), got["gasUsed"]) - // Cross-check against eth_getTransactionByHash for the same transactions: - // GetBlockByNumber must not use a different hash derivation (e.g. the - // Tendermint-internal raw-bytes hash from Block.GetTxHashes). txAPI := &txAPI{backend: backend, store: store} byHash1, err := txAPI.GetTransactionByHash(t.Context(), tx1.Hash()) require.NoError(t, err) @@ -282,18 +272,13 @@ func TestEncodeBlockFullTxIncludesDecodedTransactionsInOrder(t *testing.T) { require.Equal(t, (*hexutil.Big)(big.NewInt(0)), got["totalDifficulty"]) } -// TestEncodeBlockDocumentedHeaderGaps pins the fields Autobahn's translation -// from a GlobalBlock to this coretypes.ResultBlock shape -// (gigaRouterCommon.translateGlobalBlock) never populates: parentHash, -// stateRoot, transactionsRoot, receiptsRoot, and miner all stay at their zero -// value, confirmed by reading that translation directly rather than assumed. +// TestEncodeBlockDocumentedHeaderGaps pins the header fields +// gigaRouterCommon.translateGlobalBlock never populates, confirmed by reading +// that translation rather than assumed. func TestEncodeBlockDocumentedHeaderGaps(t *testing.T) { blockHash := common.HexToHash("0xabcd") backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { return &coretypes.ResultBlock{ - // Matches translateGlobalBlock's output shape exactly: only - // BlockID.Hash and Header.ChainID/Height/Time/Data.Txs are ever - // populated; every other field is left at its zero value. BlockID: tmtypes.BlockID{Hash: blockHash.Bytes()}, Block: &tmtypes.Block{ Header: tmtypes.Header{ChainID: "evmonly-test", Height: 4, Time: time.Unix(1_700_000_000, 0)}, From ee8de7bdcdd279215cd80e3cf09f6754a0dbf8ac Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 21:58:56 +0200 Subject: [PATCH 4/4] fix(evmonly): return null for pruned heights, correct block.go docs - resolveBlockByNumber now maps ErrHeightNotAvailable to null, matching the null treatment already given to future/zero heights - fix stale encodeBlock/README claims that block data is retained indefinitely and that parentHash/stateRoot/etc are always zero - note gasUsed/cumulativeGasUsed is scoped to one Autobahn lane, not every lane executing concurrently; revisit with superblocks - defer the per-tx receipt read in the fullTx path pending a bulk receipt-load API Co-Authored-By: Claude Sonnet 5 --- giga/evmonly/rpc/block.go | 28 +++++++++++++++++++-------- giga/evmonly/rpc/block_test.go | 11 +++++++++++ integration_test/autobahn/README.md | 30 ++++++++++++++++++----------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/giga/evmonly/rpc/block.go b/giga/evmonly/rpc/block.go index 14255d77ee..2e650fb082 100644 --- a/giga/evmonly/rpc/block.go +++ b/giga/evmonly/rpc/block.go @@ -25,9 +25,10 @@ type blockAPI struct { } // GetBlockByNumber returns the block identified by number, or nil if number -// does not resolve to a committed block. latest/safe/finalized/pending all -// resolve to the current committed block; any other height, past or future, -// is looked up directly. +// does not resolve to a committed, still-retained block. latest/safe/finalized/pending +// all resolve to the current committed block; any other height, past or future, +// is looked up directly, and a height the node has since pruned returns nil rather +// than an error. func (api *blockAPI) GetBlockByNumber(ctx context.Context, number ethrpc.BlockNumber, fullTx bool) (map[string]any, error) { block, err := api.resolveBlockByNumber(ctx, number) if err != nil || block == nil { @@ -50,7 +51,7 @@ func (api *blockAPI) GetBlockByHash(ctx context.Context, hash common.Hash, fullT } // resolveBlockByNumber looks up number, returning a nil block and nil error -// for any height outside the committed range. +// for any height outside the committed range or since pruned from retention. func (api *blockAPI) resolveBlockByNumber(ctx context.Context, number ethrpc.BlockNumber) (*coretypes.ResultBlock, error) { var height *coretypes.Int64 switch number { @@ -61,7 +62,9 @@ func (api *blockAPI) resolveBlockByNumber(ctx context.Context, number ethrpc.Blo height = &h } block, err := api.backend.Block(ctx, &coretypes.RequestBlockInfo{Height: height}) - if errors.Is(err, coretypes.ErrHeightExceedsChainHead) || errors.Is(err, coretypes.ErrZeroOrNegativeHeight) { + if errors.Is(err, coretypes.ErrHeightExceedsChainHead) || + errors.Is(err, coretypes.ErrZeroOrNegativeHeight) || + errors.Is(err, coretypes.ErrHeightNotAvailable) { return nil, nil } if err != nil { @@ -73,9 +76,11 @@ func (api *blockAPI) resolveBlockByNumber(ctx context.Context, number ethrpc.Blo return block, nil } -// encodeBlock renders block as an eth_getBlockBy* response. parentHash, -// stateRoot, transactionsRoot, receiptsRoot, miner, and logsBloom are always -// zero: this execution path's block translation never populates them. +// 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. 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) @@ -100,6 +105,8 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo if err != nil { return nil, err } + // One receipt read per transaction here, not just for the last one: + // deferred pending a bulk receipt-load API on the receipt store. for i, raw := range txs { ethtx, err := decodeBlockTx(raw, number, i) if err != nil { @@ -137,6 +144,11 @@ func (api *blockAPI) encodeBlock(ctx context.Context, block *coretypes.ResultBlo // 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 { gasUsed = hexutil.Uint64(lastReceipt.CumulativeGasUsed) diff --git a/giga/evmonly/rpc/block_test.go b/giga/evmonly/rpc/block_test.go index 875d5fd0b7..9d5f655963 100644 --- a/giga/evmonly/rpc/block_test.go +++ b/giga/evmonly/rpc/block_test.go @@ -122,6 +122,17 @@ func TestGetBlockByNumberEarliestReturnsNullBeforeAnyCommittedBlock(t *testing.T require.Nil(t, got) } +func TestGetBlockByNumberReturnsNullForAPrunedHeight(t *testing.T) { + backend := fixedGasLimitBackend(t, 35_000_000, func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) { + return nil, coretypes.WrapErrHeightNotAvailable(1, utils.None[int64]()) + }) + + got, err := (&blockAPI{backend: backend, store: evmonly.NewMemoryReceiptStore()}).GetBlockByNumber(t.Context(), ethrpc.BlockNumber(1), false) + + require.NoError(t, err) + require.Nil(t, got) +} + func TestGetBlockByHashReturnsNullForUnknownHash(t *testing.T) { backend := &testBackend{ blockByHash: func(context.Context, *coretypes.RequestBlockByHash) (*coretypes.ResultBlock, error) { diff --git a/integration_test/autobahn/README.md b/integration_test/autobahn/README.md index 47cbfb5691..5d319e7f9a 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -379,7 +379,8 @@ The public EVM JSON-RPC surface intentionally contains only: - `eth_chainId`, for the configured EVM chain ID; - `eth_call`, for a read-only message call against current committed state; - `eth_getBlockByNumber` and `eth_getBlockByHash`, for a finalized block, by - height (including any past height) or by hash. + height (including any height still within the node's retention window) or + by hash. All other `eth_*` methods currently return JSON-RPC method-not-found. A lookup for a pending or unknown hash returns `null`. @@ -499,8 +500,8 @@ function that depends on either reads a placeholder rather than a real value. `cast block` works by height or by hash. Unlike `eth_getBalance`, `eth_getTransactionCount`, and `eth_call`, an explicit height is not -historical-state-restricted: block and receipt data is retained indefinitely, -so any past height works the same as `latest`: +historical-state-restricted: any past height still within the node's +retention window works the same as `latest`: ```sh cast block --rpc-url http://127.0.0.1:8545 latest @@ -508,14 +509,21 @@ cast block --rpc-url http://127.0.0.1:8545 1 cast block --rpc-url http://127.0.0.1:8545 0xYOUR_BLOCK_HASH ``` -A height above the current chain head, or `earliest` (this executor's first -committed height is 1, not 0), returns `null` rather than an error, matching -`eth_getTransactionByHash`'s treatment of an unknown hash. `parentHash`, -`stateRoot`, `transactionsRoot`, `receiptsRoot`, and `miner` are always the -zero value: Autobahn's translation from its internal block representation -into the shape this RPC reads from never populates them. `logsBloom` is also -always empty. `gasUsed` and the transaction list are real, decoded the same -way `eth_getTransactionByHash` decodes a transaction. +A height above the current chain head, `earliest` (this executor's first +committed height is 1, not 0), or a height the node has since pruned all +return `null` rather than an error, matching `eth_getTransactionByHash`'s +treatment of an unknown hash. `nonce`, `mixHash`, `sha3Uncles`, `difficulty`, +`extraData`, `uncles`, and `totalDifficulty` are always their +Ethereum-inapplicable zero value, matching `eth_getBlockByNumber` on the +regular (non-EVM-only) RPC. `logsBloom` is always empty, unlike the regular +RPC, which aggregates it from a receipt per transaction. `gasUsed` and the +transaction list are real, decoded the same way `eth_getTransactionByHash` +decodes a transaction, but `gasUsed` is scoped to the one Autobahn lane this +block belongs to: four lanes execute concurrently, each advancing its own +block sequence, so this total does not cover every lane's activity 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. The remaining `cast` gaps are RPC gaps, not receipt-decoding gaps. `sei-load` does not currently print every submitted hash, and there are still no