diff --git a/giga/evmonly/rpc/block.go b/giga/evmonly/rpc/block.go new file mode 100644 index 0000000000..2e650fb082 --- /dev/null +++ b/giga/evmonly/rpc/block.go @@ -0,0 +1,197 @@ +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, 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 { + 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 looks up number, returning a nil block and nil error +// 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 { + 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) || + errors.Is(err, coretypes.ErrHeightNotAvailable) { + 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. 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) + 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 + } + baseFee, err := api.backend.EvmBaseFee() + 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 + } + // 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 { + 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. + // + // 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) + } + + 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, + "baseFeePerGas": (*hexutil.Big)(baseFee), + } + 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..9d5f655963 --- /dev/null +++ b/giga/evmonly/rpc/block_test.go @@ -0,0 +1,349 @@ +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 }, + baseFee: func() (*big.Int, error) { return new(big.Int), 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 + }) + + 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) { + 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 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) { + 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"]) + + 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 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{ + 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..5d319e7f9a 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -377,7 +377,10 @@ 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 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`. @@ -493,10 +496,38 @@ 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: 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 +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, `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 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) {