From 79e37fc4902ea8b0fc52b4bf71e8ab429e679056 Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 07:01:05 +0200 Subject: [PATCH 1/5] feat(evmonly): add eth_call RPC to the EVM-only executor Adds a read-only eth_call to the Autobahn giga/evmonly RPC surface, reusing the executor's existing EVM-running machinery through a new Executor.Call that opens a store snapshot, builds a vm.EVM, and discards the overlay after execution without ever committing it. - giga/evmonly: Executor.Call runs a core.Message against current state via a fresh store snapshot; nothing it does is ever persisted. - evmonlyapp: evmOnlyApplication.EvmCall builds the call's block context from currently tracked state, extended with a persisted lastBlockTime (mirroring appHash/parentHash) so Time/PrevRandao can be reproduced for a call. - proxy: Proxy.EvmCall reaches the application through a narrow evmCaller capability check instead of abci.Application, since only the EVM-only application can run a call. - rpc: new callAPI registers eth_call, reusing go-ethereum's export.TransactionArgs for arg decoding/gas-cap defaulting and a local revertError matching evmrpc's JSON-RPC revert shape. - docs: update the autobahn README's RPC surface and known-gaps lists. --- giga/evmonly/call.go | 47 ++++ giga/evmonly/call_test.go | 210 ++++++++++++++++++ giga/evmonly/rpc/call.go | 82 +++++++ giga/evmonly/rpc/call_test.go | 183 +++++++++++++++ giga/evmonly/rpc/send_test.go | 6 +- giga/evmonly/rpc/server.go | 5 + giga/evmonly/rpc/setup_test.go | 6 + integration_test/autobahn/README.md | 34 ++- sei-tendermint/internal/evmonlyapp/app.go | 58 ++++- .../internal/evmonlyapp/call_test.go | 148 ++++++++++++ sei-tendermint/internal/proxy/proxy.go | 22 ++ sei-tendermint/internal/proxy/proxy_test.go | 36 +++ sei-tendermint/internal/rpc/core/mempool.go | 7 + 13 files changed, 836 insertions(+), 8 deletions(-) create mode 100644 giga/evmonly/call.go create mode 100644 giga/evmonly/call_test.go create mode 100644 giga/evmonly/rpc/call.go create mode 100644 giga/evmonly/rpc/call_test.go create mode 100644 sei-tendermint/internal/evmonlyapp/call_test.go diff --git a/giga/evmonly/call.go b/giga/evmonly/call.go new file mode 100644 index 0000000000..27eb9ac2b9 --- /dev/null +++ b/giga/evmonly/call.go @@ -0,0 +1,47 @@ +package evmonly + +import ( + "context" + "errors" + + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/vm" +) + +// Call executes msg as a read-only EVM message call against the current +// committed state and returns the execution result. It builds its own state +// overlay from a fresh store snapshot and discards that overlay when it +// returns, so a call can never persist a state change or become visible to +// another caller. +func (e *Executor) Call(ctx context.Context, blockCtx BlockContext, msg *core.Message) (*core.ExecutionResult, error) { + chainConfig := e.chainConfig(blockCtx) + if err := validateBlockContext(chainConfig, blockCtx); err != nil { + return nil, err + } + if e.stateStore == nil { + return nil, errMissingStateStore + } + if err := ctx.Err(); err != nil { + return nil, err + } + + snapshot := e.stateStore.OpenView() + if snapshot == nil { + return nil, errors.New("giga store returned a nil snapshot") + } + defer snapshot.Close() + + stateDB := e.acquireStateDB(gigaSnapshotStateReader{snapshot: snapshot, missingState: e.missingState}) + defer e.releaseStateDB(stateDB) + + evm := vm.NewEVM(buildBlockContext(blockCtx), stateDB, chainConfig, vm.Config{}, customPrecompileMap(e.cfg.CustomPrecompiles)) + stateDB.SetEVM(evm) + evm.SetTxContext(core.NewEVMTxContext(msg)) + + gasPool := new(core.GasPool).AddGas(msg.GasLimit) + result, err := core.ApplyMessage(evm, msg, gasPool) + if stateErr := stateDB.Error(); stateErr != nil { + return nil, stateErr + } + return result, err +} diff --git a/giga/evmonly/call_test.go b/giga/evmonly/call_test.go new file mode 100644 index 0000000000..0c9acad20d --- /dev/null +++ b/giga/evmonly/call_test.go @@ -0,0 +1,210 @@ +package evmonly + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func callMessage(from common.Address, to *common.Address) *core.Message { + return &core.Message{ + From: from, + To: to, + GasLimit: 200_000, + GasPrice: new(big.Int), + GasFeeCap: new(big.Int), + GasTipCap: new(big.Int), + Value: new(big.Int), + SkipNonceChecks: true, + SkipFromEOACheck: true, + } +} + +// sloadReturnCode returns runtime bytecode that reads storage slot key and +// returns its 32-byte value, mirroring a view function such as ERC20 +// balanceOf. +func sloadReturnCode(key common.Hash) []byte { + code := []byte{0x7f} // PUSH32 key + code = append(code, key.Bytes()...) + code = append(code, 0x54) // SLOAD + code = append(code, 0x60, 0x00, 0x52) // PUSH1 0, MSTORE + code = append(code, 0x60, 0x20, 0x60, 0x00, 0xf3) // PUSH1 32, PUSH1 0, RETURN + return code +} + +// revertReasonRuntime returns runtime bytecode that always reverts with the +// ABI-encoded Error(string) selector and reason, matching a Solidity +// `require(false, reason)`. +func revertReasonRuntime(reason string) []byte { + selector := crypto.Keccak256([]byte("Error(string)"))[:4] + payload := append(append([]byte{}, selector...), abiEncodeString(reason)...) + return revertCodeForPayload(payload) +} + +func abiEncodeString(s string) []byte { + data := []byte(s) + offset := make([]byte, 32) + offset[31] = 32 + length := make([]byte, 32) + new(big.Int).SetUint64(uint64(len(data))).FillBytes(length) + padded := make([]byte, ((len(data)+31)/32)*32) + copy(padded, data) + out := append(append([]byte{}, offset...), length...) + return append(out, padded...) +} + +// revertCodeForPayload returns runtime bytecode that copies payload out of its +// own code (via CODECOPY) and REVERTs with it, for constructing EVM code that +// reverts with an arbitrary ABI-encoded reason. +func revertCodeForPayload(payload []byte) []byte { + const preambleLen = 14 + if len(payload) > 0xffff { + panic("payload too large for test helper") + } + hi := byte(len(payload) >> 8) //nolint:gosec // bounded by the check above. + lo := byte(len(payload) & 0xff) //nolint:gosec // bounded by the check above. + code := []byte{ + 0x61, hi, lo, // PUSH2 len(payload) + 0x60, preambleLen, // PUSH1 offset (start of payload in this code) + 0x60, 0x00, // PUSH1 0 (destination memory offset) + 0x39, // CODECOPY + 0x61, hi, lo, // PUSH2 len(payload) + 0x60, 0x00, // PUSH1 0 + 0xfd, // REVERT + } + if len(code) != preambleLen { + panic("preamble length mismatch") + } + return append(code, payload...) +} + +func TestExecutorCallReturnsViewFunctionResult(t *testing.T) { + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + slot := testHash(0x11) + value := testHash(0x22) + readRuntime := sloadReturnCode(slot) + contractAddr := crypto.CreateAddress(sender, 0) + + state := NewMemoryState() + state.SetBalance(sender, big.NewInt(2_000_000_000_000_000)) + store := NewMemoryStore(state) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet)) + + deployRead := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(readRuntime), 300_000) + _, err = executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: blockContext(chainID), + Txs: [][]byte{deployRead}, + }) + require.NoError(t, err) + // Seed the slot directly, standing in for a prior committed transaction's + // SSTORE; the view function under test only reads it back. + state.SetState(contractAddr, slot, value) + + result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &contractAddr)) + + require.NoError(t, err) + require.False(t, result.Failed()) + require.Equal(t, value.Bytes(), result.Return()) +} + +func TestExecutorCallSurfacesRevertReason(t *testing.T) { + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + runtime := revertReasonRuntime("insufficient balance") + contractAddr := crypto.CreateAddress(sender, 0) + + state := NewMemoryState() + state.SetBalance(sender, big.NewInt(2_000_000_000_000_000)) + store := NewMemoryStore(state) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet)) + + deploy := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(runtime), 300_000) + _, err = executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: blockContext(chainID), + Txs: [][]byte{deploy}, + }) + require.NoError(t, err) + + result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &contractAddr)) + + require.NoError(t, err) + require.ErrorIs(t, result.Err, vm.ErrExecutionReverted) + reason, unpackErr := abi.UnpackRevert(result.Revert()) + require.NoError(t, unpackErr) + require.Equal(t, "insufficient balance", reason) +} + +func TestExecutorCallToNonexistentContractSucceedsWithEmptyReturnData(t *testing.T) { + chainID := big.NewInt(testChainID) + sender := testAddress(0xa1) + target := testAddress(0xb2) + + state := NewMemoryState() + state.SetBalance(sender, big.NewInt(2_000_000_000_000_000)) + store := NewMemoryStore(state) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet)) + + result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &target)) + + require.NoError(t, err) + require.False(t, result.Failed()) + require.Empty(t, result.Return()) +} + +func TestExecutorCallDoesNotMutateCommittedState(t *testing.T) { + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + slot := testHash(0x44) + writtenValue := testHash(0x55) + // This contract unconditionally SSTOREs on every invocation; a call must + // never let that write reach committed state. + runtime := storeCode(slot, writtenValue) + contractAddr := crypto.CreateAddress(sender, 0) + + state := NewMemoryState() + state.SetBalance(sender, big.NewInt(2_000_000_000_000_000)) + store := NewMemoryStore(state) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet)) + + deploy := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(runtime), 300_000) + _, err = executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: blockContext(chainID), + Txs: [][]byte{deploy}, + }) + require.NoError(t, err) + + beforeView := store.OpenView() + before := beforeView.GetStorage(contractAddr, slot) + beforeView.Close() + require.Equal(t, common.Hash{}, before) + + result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &contractAddr)) + require.NoError(t, err) + require.False(t, result.Failed()) + + afterView := store.OpenView() + defer afterView.Close() + require.Equal(t, common.Hash{}, afterView.GetStorage(contractAddr, slot), + "eth_call-style execution must never persist a state change") +} + +func TestExecutorCallRejectsMissingStateStore(t *testing.T) { + executor := NewExecutor(Config{}) + + _, err := executor.Call(t.Context(), blockContext(big.NewInt(testChainID)), callMessage(testAddress(0x01), nil)) + + require.ErrorIs(t, err, errMissingStateStore) +} diff --git a/giga/evmonly/rpc/call.go b/giga/evmonly/rpc/call.go new file mode 100644 index 0000000000..e16050492e --- /dev/null +++ b/giga/evmonly/rpc/call.go @@ -0,0 +1,82 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/export" + ethrpc "github.com/ethereum/go-ethereum/rpc" +) + +// defaultCallGasCap bounds the gas an eth_call may consume: it is used both to +// fill in a caller-omitted gas limit and to cap one the caller supplied. It +// matches evmrpc's simulation_gas_limit default so the two EVM JSON-RPC +// surfaces this node serves behave the same way for an unbounded caller. It is +// not derived from the block gas limit: a call's gas pool is a standalone +// budget for that one execution, not a share of a block's real capacity. +const defaultCallGasCap = 10_000_000 + +type callAPI struct { + backend Backend +} + +// Call executes args as a read-only EVM message call against the current +// committed state and returns the return data. It creates no transaction and +// persists no state change. +func (api *callAPI) Call(ctx context.Context, args export.TransactionArgs, block ethrpc.BlockNumberOrHash) (hexutil.Bytes, error) { + if err := requireCurrentState(block); err != nil { + return nil, err + } + baseFee := new(big.Int) + chainID := new(big.Int).SetUint64(api.backend.EvmChainID()) + if err := args.CallDefaults(defaultCallGasCap, baseFee, chainID); err != nil { + return nil, err + } + msg := args.ToMessage(baseFee, true, true) + + result, err := api.backend.EvmCall(ctx, msg) + if err != nil { + return nil, err + } + if len(result.Revert()) > 0 { + return nil, newRevertError(result) + } + if result.Err != nil { + return nil, result.Err + } + return result.Return(), nil +} + +// newRevertError builds the JSON-RPC error eth_call returns for a reverted +// call, matching evmrpc's SimulationAPI.Call error shape: code 3 with the raw +// revert data, and the ABI-decoded reason in the message when possible. +func newRevertError(result *core.ExecutionResult) *revertError { + reason, errUnpack := abi.UnpackRevert(result.Revert()) + err := errors.New("execution reverted") + if errUnpack == nil { + err = fmt.Errorf("execution reverted: %v", reason) + } + return &revertError{error: err, reason: hexutil.Encode(result.Revert())} +} + +// revertError is a JSON-RPC error carrying an EVM revert reason. +type revertError struct { + error + reason string // revert reason, hex encoded +} + +// ErrorCode returns the JSON-RPC error code for a revert. +// See: https://github.com/ethereum/wiki/wiki/JSON-RPC-Error-Codes-Improvement-Proposal +func (e *revertError) ErrorCode() int { + return 3 +} + +// ErrorData returns the hex encoded revert reason. +func (e *revertError) ErrorData() any { + return e.reason +} diff --git a/giga/evmonly/rpc/call_test.go b/giga/evmonly/rpc/call_test.go new file mode 100644 index 0000000000..8ab1dd67ea --- /dev/null +++ b/giga/evmonly/rpc/call_test.go @@ -0,0 +1,183 @@ +package rpc + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/export" + ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +// revertData ABI-encodes reason the same way a Solidity `require(false, reason)` +// would, i.e. the Error(string) selector followed by the encoded string. +func revertData(reason string) []byte { + selector := crypto.Keccak256([]byte("Error(string)"))[:4] + data := []byte(reason) + offset := make([]byte, 32) + offset[31] = 32 + length := make([]byte, 32) + length[31] = byte(len(data)) //nolint:gosec // test-only, reason is short. + padded := make([]byte, ((len(data)+31)/32)*32) + copy(padded, data) + out := append(append([]byte{}, selector...), offset...) + out = append(out, length...) + return append(out, padded...) +} + +func TestCallHappyPath(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + from := common.HexToAddress("0x2000000000000000000000000000000000000002") + var gotMsg *core.Message + backend := &testBackend{ + chainID: func() uint64 { return 713715 }, + call: func(_ context.Context, msg *core.Message) (*core.ExecutionResult, error) { + gotMsg = msg + return &core.ExecutionResult{ReturnData: []byte{0x2a}}, nil + }, + } + api := &callAPI{backend: backend} + args := export.TransactionArgs{From: &from, To: &to} + + got, err := api.Call(t.Context(), args, ethrpc.BlockNumberOrHashWithNumber(ethrpc.LatestBlockNumber)) + + require.NoError(t, err) + require.Equal(t, hexutil.Bytes{0x2a}, got) + require.NotNil(t, gotMsg) + require.Equal(t, from, gotMsg.From) + require.Equal(t, &to, gotMsg.To) + require.True(t, gotMsg.SkipNonceChecks) + require.True(t, gotMsg.SkipFromEOACheck) + require.Equal(t, uint64(defaultCallGasCap), gotMsg.GasLimit) +} + +func TestCallCapsExplicitGasAboveDefault(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + var gotMsg *core.Message + backend := &testBackend{ + chainID: func() uint64 { return 713715 }, + call: func(_ context.Context, msg *core.Message) (*core.ExecutionResult, error) { + gotMsg = msg + return &core.ExecutionResult{}, nil + }, + } + api := &callAPI{backend: backend} + requested := hexutil.Uint64(defaultCallGasCap * 10) + args := export.TransactionArgs{To: &to, Gas: &requested} + + _, err := api.Call(t.Context(), args, ethrpc.BlockNumberOrHashWithNumber(ethrpc.LatestBlockNumber)) + + require.NoError(t, err) + require.Equal(t, uint64(defaultCallGasCap), gotMsg.GasLimit) +} + +func TestCallPreservesExplicitGasBelowDefault(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + var gotMsg *core.Message + backend := &testBackend{ + chainID: func() uint64 { return 713715 }, + call: func(_ context.Context, msg *core.Message) (*core.ExecutionResult, error) { + gotMsg = msg + return &core.ExecutionResult{}, nil + }, + } + api := &callAPI{backend: backend} + requested := hexutil.Uint64(21_000) + args := export.TransactionArgs{To: &to, Gas: &requested} + + _, err := api.Call(t.Context(), args, ethrpc.BlockNumberOrHashWithNumber(ethrpc.LatestBlockNumber)) + + require.NoError(t, err) + require.Equal(t, uint64(21_000), gotMsg.GasLimit) +} + +func TestCallSurfacesRevertReason(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + revert := revertData("insufficient balance") + backend := &testBackend{ + chainID: func() uint64 { return 713715 }, + call: func(context.Context, *core.Message) (*core.ExecutionResult, error) { + return &core.ExecutionResult{Err: vm.ErrExecutionReverted, ReturnData: revert}, nil + }, + } + api := &callAPI{backend: backend} + + got, err := api.Call(t.Context(), export.TransactionArgs{To: &to}, ethrpc.BlockNumberOrHashWithNumber(ethrpc.LatestBlockNumber)) + + require.Nil(t, got) + require.Error(t, err) + require.Contains(t, err.Error(), "insufficient balance") + var revertErr *revertError + require.ErrorAs(t, err, &revertErr) + require.Equal(t, 3, revertErr.ErrorCode()) + require.Equal(t, hexutil.Encode(revert), revertErr.ErrorData()) +} + +func TestCallPassesThroughNonRevertExecutionError(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + wantErr := errors.New("out of gas") + backend := &testBackend{ + chainID: func() uint64 { return 713715 }, + call: func(context.Context, *core.Message) (*core.ExecutionResult, error) { + return &core.ExecutionResult{Err: wantErr}, nil + }, + } + api := &callAPI{backend: backend} + + got, err := api.Call(t.Context(), export.TransactionArgs{To: &to}, ethrpc.BlockNumberOrHashWithNumber(ethrpc.LatestBlockNumber)) + + require.Nil(t, got) + require.ErrorIs(t, err, wantErr) +} + +func TestCallRejectsHistoricalState(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + backend := &testBackend{ + call: func(context.Context, *core.Message) (*core.ExecutionResult, error) { + t.Fatal("historical call request reached the backend") + return nil, nil + }, + } + api := &callAPI{backend: backend} + + for _, block := range []ethrpc.BlockNumberOrHash{ + ethrpc.BlockNumberOrHashWithNumber(ethrpc.EarliestBlockNumber), + ethrpc.BlockNumberOrHashWithNumber(7), + ethrpc.BlockNumberOrHashWithHash(common.Hash{2}, true), + } { + got, err := api.Call(t.Context(), export.TransactionArgs{To: &to}, block) + require.ErrorIs(t, err, errHistoricalStateUnsupported) + require.Nil(t, got) + } +} + +func TestHandlerServesCall(t *testing.T) { + to := common.HexToAddress("0x1000000000000000000000000000000000000001") + backend := &testBackend{ + chainID: func() uint64 { return 713715 }, + call: func(context.Context, *core.Message) (*core.ExecutionResult, error) { + return &core.ExecutionResult{ReturnData: []byte{0x01, 0x02}}, nil + }, + } + handler, err := newHandler(backend, evmonly.NewMemoryReceiptStore()) + 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 got hexutil.Bytes + require.NoError(t, client.CallContext(t.Context(), &got, "eth_call", map[string]any{"to": to}, "latest")) + require.Equal(t, hexutil.Bytes{0x01, 0x02}, got) +} diff --git a/giga/evmonly/rpc/send_test.go b/giga/evmonly/rpc/send_test.go index bce1cae435..f1e3ea3df6 100644 --- a/giga/evmonly/rpc/send_test.go +++ b/giga/evmonly/rpc/send_test.go @@ -42,9 +42,9 @@ func TestSendRawTransaction(t *testing.T) { require.Equal(t, tx.Hash(), got) require.Equal(t, raw, broadcastRaw) - var callResult hexutil.Bytes - err = client.CallContext(t.Context(), &callResult, "eth_call") - require.ErrorContains(t, err, "method eth_call does not exist") + var estimateResult hexutil.Uint64 + err = client.CallContext(t.Context(), &estimateResult, "eth_estimateGas") + require.ErrorContains(t, err, "method eth_estimateGas does not exist") err = client.CallContext(t.Context(), nil, "status") require.ErrorContains(t, err, "method status does not exist") } diff --git a/giga/evmonly/rpc/server.go b/giga/evmonly/rpc/server.go index 73b54cea87..e9b09093aa 100644 --- a/giga/evmonly/rpc/server.go +++ b/giga/evmonly/rpc/server.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" ethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/holiman/uint256" @@ -33,6 +34,7 @@ type Backend interface { BroadcastTx(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) EvmBalance(common.Address) uint256.Int EvmBlockNumber() uint64 + EvmCall(context.Context, *core.Message) (*core.ExecutionResult, error) EvmChainID() uint64 EvmProxy(common.Address) utils.Option[*ethrpc.Client] EvmTransactionCount(common.Address) uint64 @@ -83,6 +85,9 @@ func newHandler(backend Backend, receiptStore receipt.ReceiptStore) (*ethrpc.Ser if err := rpcServer.RegisterName("eth", &infoAPI{backend: backend}); err != nil { return nil, fmt.Errorf("register EVM-only info RPC: %w", err) } + if err := rpcServer.RegisterName("eth", &callAPI{backend: backend}); err != nil { + return nil, fmt.Errorf("register EVM-only call RPC: %w", err) + } return rpcServer, nil } diff --git a/giga/evmonly/rpc/setup_test.go b/giga/evmonly/rpc/setup_test.go index 82facca2d4..abbb92d62d 100644 --- a/giga/evmonly/rpc/setup_test.go +++ b/giga/evmonly/rpc/setup_test.go @@ -4,6 +4,7 @@ import ( "context" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" ethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/holiman/uint256" @@ -19,6 +20,7 @@ type testBackend struct { transactionCount func(common.Address) uint64 blockNumber func() uint64 chainID func() uint64 + call func(context.Context, *core.Message) (*core.ExecutionResult, error) } func (b *testBackend) BroadcastTx(ctx context.Context, req *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) { @@ -48,3 +50,7 @@ func (b *testBackend) EvmBlockNumber() uint64 { func (b *testBackend) EvmChainID() uint64 { return b.chainID() } + +func (b *testBackend) EvmCall(ctx context.Context, msg *core.Message) (*core.ExecutionResult, error) { + return b.call(ctx, msg) +} diff --git a/integration_test/autobahn/README.md b/integration_test/autobahn/README.md index 71e8cb6bbb..89ed979dea 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -375,7 +375,8 @@ The public EVM JSON-RPC surface intentionally contains only: - `eth_getBalance`, for the current committed EVM balance; - `eth_getTransactionCount`, for the current committed nonce; - `eth_blockNumber`, for the current committed block height; -- `eth_chainId`, for the configured EVM chain ID. +- `eth_chainId`, for the configured EVM chain ID; +- `eth_call`, for a read-only message call against current committed state. All other `eth_*` methods currently return JSON-RPC method-not-found. A lookup for a pending or unknown hash returns `null`. @@ -447,10 +448,39 @@ error: historical state is not available from this RPC. `eth_blockNumber` and `eth_chainId` take no block selector and always return the current height and the network's configured EVM chain ID. +### Make a read-only call with `cast call` + +`cast call` executes a message against current committed state without +sending a transaction, so it works for any `view`/`pure` contract function, +such as an ERC20 `balanceOf`: + +```sh +cast call \ + --rpc-url http://127.0.0.1:8545 \ + 0xYOUR_CONTRACT_ADDRESS \ + "balanceOf(address)(uint256)" \ + 0xYOUR_ADDRESS +``` + +`eth_call` accepts the same `latest`/`safe`/`finalized`/`pending` block tags as +`eth_getBalance` and `eth_getTransactionCount`; an explicit height, an explicit +hash, or `earliest` returns the same historical-state error. A caller-omitted +gas limit defaults to a fixed cap rather than the block gas limit, and an +explicit limit above that cap is silently lowered to it. A reverted call +returns a JSON-RPC error carrying the ABI-decoded revert reason, matching +go-ethereum's own `eth_call` behavior. + +Block context for a call is a mix of real and best-effort values: `Number` and +`GasLimit` are the actual current committed values, but `Coinbase` is always +the zero address (this application never sets one, even for committed +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. + The remaining `cast` gaps are RPC gaps, not receipt-decoding gaps. There is no `eth_getTransactionByHash` or block API to discover a `sei-load` transfer hash, and `sei-load` does not currently print every submitted hash. There are also no -fee-estimation, gas-estimation, call, log, or WebSocket subscription methods. +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 the example above. diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 6f66c99cab..1e20b40909 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -10,6 +10,7 @@ import ( "slices" "github.com/ethereum/go-ethereum/common" + ethcore "github.com/ethereum/go-ethereum/core" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/params" @@ -48,13 +49,18 @@ type evmOnlyState struct { committedHeight int64 appHash common.Hash parentHash common.Hash - pending utils.Option[evmOnlyPending] + // lastBlockTime is the Time of the most recently committed block. EvmCall + // uses it to reproduce that block's execution context for a read-only call + // against current state. + lastBlockTime uint64 + pending utils.Option[evmOnlyPending] } type evmOnlyPending struct { height int64 appHash common.Hash blockHash common.Hash + timestamp uint64 } var _ abci.Application = (*evmOnlyApplication)(nil) @@ -242,6 +248,51 @@ func (a *evmOnlyApplication) EvmChainID() uint64 { return a.chainID.Uint64() } +// evmOnlyPrevRandao derives a block's PrevRandao the same deterministic way +// for every caller that needs one: this application has no real randomness +// beacon, so it stands in one pseudo-randomly from the block timestamp. +func evmOnlyPrevRandao(timestamp uint64) common.Hash { + return crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, timestamp)) +} + +// EvmCall executes msg as a read-only call against the most recently +// committed EVM state and returns the raw execution result. It is not part of +// abci.Application: running a call requires a chain config and an EVM, which +// only this application's executor can supply, so callers reach it through a +// narrower capability check (proxy.Proxy.EvmCall) instead of a method every +// Application implementer would otherwise have to stub. +// +// Coinbase is always the zero address and ParentHash is always the zero hash: +// FinalizeBlock never sets a coinbase, and the block before the current head +// is not tracked outside of the single FinalizeBlock call that used it, so a +// call has no way to answer blockhash(current-1). +func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) (*ethcore.ExecutionResult, error) { + var executor *evmonly.Executor + var blockCtx evmonly.BlockContext + for state := range a.state.Lock() { + exec, ok := state.executor.Get() + if !ok { + return nil, fmt.Errorf("EVM-only call attempted before InitChain") + } + number, ok := utils.SafeCast[uint64](state.committedHeight) + if !ok { + return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committedHeight) + } + executor = exec + blockCtx = evmonly.BlockContext{ + Number: number, + Time: state.lastBlockTime, + GasLimit: state.gasLimit, + ChainID: new(big.Int).Set(a.chainID), + BaseFee: evmOnlyBaseFee(), + BlobBaseFee: new(big.Int), + BlockHash: state.parentHash, + PrevRandao: evmOnlyPrevRandao(state.lastBlockTime), + } + } + return executor.Call(ctx, blockCtx, msg) +} + func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) { height := req.Header.Height if height <= 0 { @@ -277,7 +328,7 @@ func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.Reques BlobBaseFee: new(big.Int), ParentHash: state.parentHash, BlockHash: blockHash, - PrevRandao: crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, timestamp)), + PrevRandao: evmOnlyPrevRandao(timestamp), }, Txs: req.Txs, }) @@ -289,7 +340,7 @@ func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.Reques if err != nil { return nil, err } - state.pending = utils.Some(evmOnlyPending{height: height, appHash: appHash, blockHash: blockHash}) + state.pending = utils.Some(evmOnlyPending{height: height, appHash: appHash, blockHash: blockHash, timestamp: timestamp}) return &abci.ResponseFinalizeBlock{ AppHash: append([]byte(nil), appHash[:]...), TxResults: evmOnlyABCIResults(result), @@ -308,6 +359,7 @@ func (a *evmOnlyApplication) Commit(context.Context) (*abci.ResponseCommit, erro state.nextHeight = pending.height + 1 state.appHash = pending.appHash state.parentHash = pending.blockHash + state.lastBlockTime = pending.timestamp state.pending = utils.None[evmOnlyPending]() return &abci.ResponseCommit{}, nil } diff --git a/sei-tendermint/internal/evmonlyapp/call_test.go b/sei-tendermint/internal/evmonlyapp/call_test.go new file mode 100644 index 0000000000..00c9071419 --- /dev/null +++ b/sei-tendermint/internal/evmonlyapp/call_test.go @@ -0,0 +1,148 @@ +package evmonlyapp + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethcore "github.com/ethereum/go-ethereum/core" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" +) + +// storeCode returns runtime bytecode that unconditionally SSTOREs value at +// key on every invocation. +func storeCode(key, value common.Hash) []byte { + code := append([]byte{0x7f}, value.Bytes()...) // PUSH32 value + code = append(code, 0x7f) // PUSH32 key + code = append(code, key.Bytes()...) + return append(code, 0x55, 0x00) // SSTORE, STOP +} + +// initCode wraps runtime bytecode in the standard CODECOPY+RETURN preamble a +// contract-creation transaction executes to install it. +func initCode(runtime []byte) []byte { + if len(runtime) > 255 { + panic("test runtime too large") + } + runtimeLen := byte(len(runtime)) //nolint:gosec // bounded by the check above. + code := []byte{ + 0x60, runtimeLen, + 0x60, 0x0c, + 0x60, 0x00, + 0x39, + 0x60, runtimeLen, + 0x60, 0x00, + 0xf3, + } + return append(code, runtime...) +} + +func signedEVMOnlyCreateTx(t *testing.T, chainID uint64, data []byte, gas uint64) (raw []byte, sender, contractAddr common.Address) { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender = crypto.PubkeyToAddress(key.PublicKey) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(evmOnlyMinGasPrice), + Gas: gas, + Value: big.NewInt(0), + Data: data, + }) + signed, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(new(big.Int).SetUint64(chainID)), key) + require.NoError(t, err) + raw, err = signed.MarshalBinary() + require.NoError(t, err) + return raw, sender, crypto.CreateAddress(sender, 0) +} + +func callMessage(from common.Address, to *common.Address) *ethcore.Message { + return ðcore.Message{ + From: from, + To: to, + GasLimit: 100_000, + GasPrice: new(big.Int), + GasFeeCap: new(big.Int), + GasTipCap: new(big.Int), + Value: new(big.Int), + SkipNonceChecks: true, + SkipFromEOACheck: true, + } +} + +func TestEVMOnlyApplicationEvmCallReadsCommittedContractCode(t *testing.T) { + app := newInitializedEVMOnlyTestApp(t) + evmApp := app.(*evmOnlyApplication) + slot := common.BytesToHash([]byte{0x11}) + value := common.BytesToHash([]byte{0x22}) + runtime := storeCode(slot, value) + deployRaw, sender, contractAddr := signedEVMOnlyCreateTx(t, evmOnlyTestChainID, initCode(runtime), 300_000) + + _, err := app.FinalizeBlock(t.Context(), &abci.RequestFinalizeBlock{ + Txs: [][]byte{deployRaw}, + Hash: crypto.Keccak256([]byte("block-1")), + Header: &tmproto.Header{ + Height: 1, + Time: time.Unix(1_700_000_001, 0), + }, + }) + require.NoError(t, err) + _, err = app.Commit(t.Context()) + require.NoError(t, err) + + result, err := evmApp.EvmCall(t.Context(), callMessage(sender, &contractAddr)) + + require.NoError(t, err) + require.False(t, result.Failed()) +} + +func TestEVMOnlyApplicationEvmCallDoesNotMutateCommittedState(t *testing.T) { + app := newInitializedEVMOnlyTestApp(t) + evmApp := app.(*evmOnlyApplication) + slot := common.BytesToHash([]byte{0x44}) + writtenValue := common.BytesToHash([]byte{0x55}) + // This contract unconditionally SSTOREs on every invocation; EvmCall must + // never let that write reach committed state. + runtime := storeCode(slot, writtenValue) + deployRaw, sender, contractAddr := signedEVMOnlyCreateTx(t, evmOnlyTestChainID, initCode(runtime), 300_000) + + _, err := app.FinalizeBlock(t.Context(), &abci.RequestFinalizeBlock{ + Txs: [][]byte{deployRaw}, + Hash: crypto.Keccak256([]byte("block-1")), + Header: &tmproto.Header{ + Height: 1, + Time: time.Unix(1_700_000_001, 0), + }, + }) + require.NoError(t, err) + _, err = app.Commit(t.Context()) + require.NoError(t, err) + + before := evmApp.storage.StateDB().OpenView() + beforeValue := before.GetStorage(contractAddr, slot) + before.Close() + require.Equal(t, common.Hash{}, beforeValue) + + result, err := evmApp.EvmCall(t.Context(), callMessage(sender, &contractAddr)) + require.NoError(t, err) + require.False(t, result.Failed()) + + after := evmApp.storage.StateDB().OpenView() + defer after.Close() + require.Equal(t, common.Hash{}, after.GetStorage(contractAddr, slot)) +} + +func TestEVMOnlyApplicationEvmCallRequiresInitChain(t *testing.T) { + app := newEVMOnlyTestApp(t, nil) + evmApp := app.(*evmOnlyApplication) + + _, err := evmApp.EvmCall(t.Context(), callMessage(common.Address{}, nil)) + + require.Error(t, err) +} diff --git a/sei-tendermint/internal/proxy/proxy.go b/sei-tendermint/internal/proxy/proxy.go index 30cd912419..fb434856d5 100644 --- a/sei-tendermint/internal/proxy/proxy.go +++ b/sei-tendermint/internal/proxy/proxy.go @@ -7,6 +7,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" "github.com/holiman/uint256" "github.com/prometheus/client_golang/prometheus" @@ -59,6 +60,27 @@ func (app *Proxy) EvmChainID() uint64 { return app.app.EvmChainID() } +// evmCaller is implemented by applications that can run a read-only EVM call +// against their current state. Only the Autobahn EVM-only application does +// today, so this stays a capability check on the concrete app rather than a +// method on abci.Application, which every other implementer would then have +// to stub. +type evmCaller interface { + EvmCall(context.Context, *core.Message) (*core.ExecutionResult, error) +} + +// EvmCall executes msg as a read-only call against the wrapped application's +// current EVM state. It errors if that application does not support EVM +// calls. +func (app *Proxy) EvmCall(ctx context.Context, msg *core.Message) (*core.ExecutionResult, error) { + defer addTimeSample(Global.MethodTimingAt("evm_call", "sync"))() + caller, ok := app.app.(evmCaller) + if !ok { + return nil, fmt.Errorf("application does not support EVM calls") + } + return caller.EvmCall(ctx, msg) +} + 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 c10e4a8698..1946f300d7 100644 --- a/sei-tendermint/internal/proxy/proxy_test.go +++ b/sei-tendermint/internal/proxy/proxy_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" "github.com/holiman/uint256" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" @@ -73,3 +74,38 @@ func TestCheckTxSafeAllowsValidEVMResponse(t *testing.T) { _, err := proxyApp.CheckTxSafe(t.Context(), &types.RequestCheckTxV2{Tx: []byte("tx")}) require.NoError(t, err) } + +func TestEvmCallErrorsWhenApplicationDoesNotSupportIt(t *testing.T) { + proxyApp := New(testApp{}) + + _, err := proxyApp.EvmCall(t.Context(), &core.Message{}) + + require.Error(t, err) +} + +type testEvmCallerApp struct { + testApp + call func(context.Context, *core.Message) (*core.ExecutionResult, error) +} + +func (app testEvmCallerApp) EvmCall(ctx context.Context, msg *core.Message) (*core.ExecutionResult, error) { + return app.call(ctx, msg) +} + +func TestEvmCallDelegatesToASupportingApplication(t *testing.T) { + want := &core.ExecutionResult{ReturnData: []byte{0x2a}} + var gotMsg *core.Message + proxyApp := New(testEvmCallerApp{ + call: func(_ context.Context, msg *core.Message) (*core.ExecutionResult, error) { + gotMsg = msg + return want, nil + }, + }) + msg := &core.Message{GasLimit: 21_000} + + got, err := proxyApp.EvmCall(t.Context(), msg) + + require.NoError(t, err) + require.Same(t, want, got) + require.Same(t, msg, gotMsg) +} diff --git a/sei-tendermint/internal/rpc/core/mempool.go b/sei-tendermint/internal/rpc/core/mempool.go index 51e7c11d46..0e87d14984 100644 --- a/sei-tendermint/internal/rpc/core/mempool.go +++ b/sei-tendermint/internal/rpc/core/mempool.go @@ -8,6 +8,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + ethcore "github.com/ethereum/go-ethereum/core" ethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/holiman/uint256" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" @@ -317,6 +318,12 @@ func (env *Environment) EvmChainID() uint64 { return env.App.EvmChainID() } +// 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) { + return env.App.EvmCall(ctx, msg) +} + // CheckTx checks the transaction without executing it. The transaction won't // be added to the mempool either. // More: https://docs.tendermint.com/master/rpc/#/Tx/check_tx From 42409abcacefb894f1e04512a22296d391d3ba14 Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 11:30:55 +0200 Subject: [PATCH 2/5] refactor(evmonly): trim eth_call godocs, default call base fee to x/evm's minimum - Tighten the eth_call path's godocs (giga/evmonly, evmonlyapp, proxy) to state what a function does rather than why/how; surviving rationale moves inline at the code that needs it. - eth_call's TransactionArgs base fee now defaults to x/evm's DefaultMinFeePerGas instead of a hardcoded zero, matching the floor a fresh chain executes at. --- giga/evmonly/call.go | 6 ++---- giga/evmonly/call_test.go | 3 +-- giga/evmonly/rpc/call.go | 13 ++++++------ giga/evmonly/rpc/setup_test.go | 26 +++++++++++------------ sei-tendermint/internal/evmonlyapp/app.go | 21 +++++------------- sei-tendermint/internal/proxy/proxy.go | 5 +---- 6 files changed, 28 insertions(+), 46 deletions(-) diff --git a/giga/evmonly/call.go b/giga/evmonly/call.go index 27eb9ac2b9..e940d0bed3 100644 --- a/giga/evmonly/call.go +++ b/giga/evmonly/call.go @@ -9,10 +9,8 @@ import ( ) // Call executes msg as a read-only EVM message call against the current -// committed state and returns the execution result. It builds its own state -// overlay from a fresh store snapshot and discards that overlay when it -// returns, so a call can never persist a state change or become visible to -// another caller. +// committed state and returns the execution result. It persists no state +// change. func (e *Executor) Call(ctx context.Context, blockCtx BlockContext, msg *core.Message) (*core.ExecutionResult, error) { chainConfig := e.chainConfig(blockCtx) if err := validateBlockContext(chainConfig, blockCtx); err != nil { diff --git a/giga/evmonly/call_test.go b/giga/evmonly/call_test.go index 0c9acad20d..c40761f8f5 100644 --- a/giga/evmonly/call_test.go +++ b/giga/evmonly/call_test.go @@ -60,8 +60,7 @@ func abiEncodeString(s string) []byte { } // revertCodeForPayload returns runtime bytecode that copies payload out of its -// own code (via CODECOPY) and REVERTs with it, for constructing EVM code that -// reverts with an arbitrary ABI-encoded reason. +// own code (via CODECOPY) and REVERTs with it. func revertCodeForPayload(payload []byte) []byte { const preambleLen = 14 if len(payload) > 0xffff { diff --git a/giga/evmonly/rpc/call.go b/giga/evmonly/rpc/call.go index e16050492e..5d3d6b5d38 100644 --- a/giga/evmonly/rpc/call.go +++ b/giga/evmonly/rpc/call.go @@ -11,14 +11,13 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/export" ethrpc "github.com/ethereum/go-ethereum/rpc" + + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) -// defaultCallGasCap bounds the gas an eth_call may consume: it is used both to -// fill in a caller-omitted gas limit and to cap one the caller supplied. It -// matches evmrpc's simulation_gas_limit default so the two EVM JSON-RPC -// surfaces this node serves behave the same way for an unbounded caller. It is -// not derived from the block gas limit: a call's gas pool is a standalone -// budget for that one execution, not a share of a block's real capacity. +// defaultCallGasCap bounds the gas an eth_call may consume, filling in an +// omitted gas limit and capping a caller-supplied one. It matches evmrpc's +// simulation_gas_limit default. const defaultCallGasCap = 10_000_000 type callAPI struct { @@ -32,7 +31,7 @@ func (api *callAPI) Call(ctx context.Context, args export.TransactionArgs, block if err := requireCurrentState(block); err != nil { return nil, err } - baseFee := new(big.Int) + baseFee := evmtypes.DefaultMinFeePerGas.TruncateInt().BigInt() chainID := new(big.Int).SetUint64(api.backend.EvmChainID()) if err := args.CallDefaults(defaultCallGasCap, baseFee, chainID); err != nil { return nil, err diff --git a/giga/evmonly/rpc/setup_test.go b/giga/evmonly/rpc/setup_test.go index abbb92d62d..fad2d894e8 100644 --- a/giga/evmonly/rpc/setup_test.go +++ b/giga/evmonly/rpc/setup_test.go @@ -13,14 +13,14 @@ import ( ) type testBackend struct { - broadcast func(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) - block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) balance func(common.Address) uint256.Int - proxy utils.Option[*ethrpc.Client] - transactionCount func(common.Address) uint64 + block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) blockNumber func() uint64 - chainID func() uint64 + broadcast func(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) call func(context.Context, *core.Message) (*core.ExecutionResult, error) + chainID func() uint64 + proxy utils.Option[*ethrpc.Client] + transactionCount func(common.Address) uint64 } func (b *testBackend) BroadcastTx(ctx context.Context, req *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) { @@ -31,26 +31,26 @@ func (b *testBackend) Block(ctx context.Context, req *coretypes.RequestBlockInfo return b.block(ctx, req) } -func (b *testBackend) EvmBalance(address common.Address) uint256.Int { - return b.balance(address) -} - func (b *testBackend) EvmProxy(common.Address) utils.Option[*ethrpc.Client] { return b.proxy } -func (b *testBackend) EvmTransactionCount(address common.Address) uint64 { - return b.transactionCount(address) +func (b *testBackend) EvmBalance(address common.Address) uint256.Int { + return b.balance(address) } func (b *testBackend) EvmBlockNumber() uint64 { return b.blockNumber() } +func (b *testBackend) EvmCall(ctx context.Context, msg *core.Message) (*core.ExecutionResult, error) { + return b.call(ctx, msg) +} + func (b *testBackend) EvmChainID() uint64 { return b.chainID() } -func (b *testBackend) EvmCall(ctx context.Context, msg *core.Message) (*core.ExecutionResult, error) { - return b.call(ctx, msg) +func (b *testBackend) EvmTransactionCount(address common.Address) uint64 { + return b.transactionCount(address) } diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 1e20b40909..112d7a7582 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -49,9 +49,7 @@ type evmOnlyState struct { committedHeight int64 appHash common.Hash parentHash common.Hash - // lastBlockTime is the Time of the most recently committed block. EvmCall - // uses it to reproduce that block's execution context for a read-only call - // against current state. + // lastBlockTime is the Time of the most recently committed block. lastBlockTime uint64 pending utils.Option[evmOnlyPending] } @@ -248,24 +246,13 @@ func (a *evmOnlyApplication) EvmChainID() uint64 { return a.chainID.Uint64() } -// evmOnlyPrevRandao derives a block's PrevRandao the same deterministic way -// for every caller that needs one: this application has no real randomness -// beacon, so it stands in one pseudo-randomly from the block timestamp. +// evmOnlyPrevRandao derives a deterministic PrevRandao from a block timestamp. func evmOnlyPrevRandao(timestamp uint64) common.Hash { return crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, timestamp)) } // EvmCall executes msg as a read-only call against the most recently -// committed EVM state and returns the raw execution result. It is not part of -// abci.Application: running a call requires a chain config and an EVM, which -// only this application's executor can supply, so callers reach it through a -// narrower capability check (proxy.Proxy.EvmCall) instead of a method every -// Application implementer would otherwise have to stub. -// -// Coinbase is always the zero address and ParentHash is always the zero hash: -// FinalizeBlock never sets a coinbase, and the block before the current head -// is not tracked outside of the single FinalizeBlock call that used it, so a -// call has no way to answer blockhash(current-1). +// committed EVM state and returns the execution result. func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) (*ethcore.ExecutionResult, error) { var executor *evmonly.Executor var blockCtx evmonly.BlockContext @@ -279,6 +266,8 @@ func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committedHeight) } executor = exec + // Coinbase and ParentHash are left zero: no coinbase is tracked outside + // FinalizeBlock, and only the current block's hash is tracked at all. blockCtx = evmonly.BlockContext{ Number: number, Time: state.lastBlockTime, diff --git a/sei-tendermint/internal/proxy/proxy.go b/sei-tendermint/internal/proxy/proxy.go index fb434856d5..ff68c27c28 100644 --- a/sei-tendermint/internal/proxy/proxy.go +++ b/sei-tendermint/internal/proxy/proxy.go @@ -61,10 +61,7 @@ func (app *Proxy) EvmChainID() uint64 { } // evmCaller is implemented by applications that can run a read-only EVM call -// against their current state. Only the Autobahn EVM-only application does -// today, so this stays a capability check on the concrete app rather than a -// method on abci.Application, which every other implementer would then have -// to stub. +// against their current state. type evmCaller interface { EvmCall(context.Context, *core.Message) (*core.ExecutionResult, error) } From 44d8d91716a10691a53412ef00e05c6817060649 Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 11:49:38 +0200 Subject: [PATCH 3/5] fix(evmonly): address eth_call review findings on base fee and commit window - Executor.Call now runs with vm.Config.NoBaseFee, matching go-ethereum's own eth_call semantics so a caller leaving fee fields at zero isn't rejected by the fee-cap-vs-basefee check. - rpc/call.go's message-building base fee now matches the zero base fee the call actually executes under, instead of an unrelated x/evm minimum-fee constant. - EvmCall now refuses while a finalized block is pending Commit, since the store already reflects the new block while tracked NUMBER/TIMESTAMP/ PrevRandao still describe the previous one in that window. --- giga/evmonly/call.go | 4 +++- giga/evmonly/rpc/call.go | 8 +++++--- sei-tendermint/internal/evmonlyapp/app.go | 7 +++++++ .../internal/evmonlyapp/call_test.go | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/giga/evmonly/call.go b/giga/evmonly/call.go index e940d0bed3..f291af1aeb 100644 --- a/giga/evmonly/call.go +++ b/giga/evmonly/call.go @@ -32,7 +32,9 @@ func (e *Executor) Call(ctx context.Context, blockCtx BlockContext, msg *core.Me stateDB := e.acquireStateDB(gigaSnapshotStateReader{snapshot: snapshot, missingState: e.missingState}) defer e.releaseStateDB(stateDB) - evm := vm.NewEVM(buildBlockContext(blockCtx), stateDB, chainConfig, vm.Config{}, customPrecompileMap(e.cfg.CustomPrecompiles)) + // NoBaseFee lets a caller who leaves GasFeeCap/GasTipCap at zero skip the + // fee-cap-vs-basefee check, matching go-ethereum's own eth_call behavior. + evm := vm.NewEVM(buildBlockContext(blockCtx), stateDB, chainConfig, vm.Config{NoBaseFee: true}, customPrecompileMap(e.cfg.CustomPrecompiles)) stateDB.SetEVM(evm) evm.SetTxContext(core.NewEVMTxContext(msg)) diff --git a/giga/evmonly/rpc/call.go b/giga/evmonly/rpc/call.go index 5d3d6b5d38..78e3394e24 100644 --- a/giga/evmonly/rpc/call.go +++ b/giga/evmonly/rpc/call.go @@ -11,8 +11,6 @@ import ( "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/export" ethrpc "github.com/ethereum/go-ethereum/rpc" - - evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" ) // defaultCallGasCap bounds the gas an eth_call may consume, filling in an @@ -31,7 +29,11 @@ func (api *callAPI) Call(ctx context.Context, args export.TransactionArgs, block if err := requireCurrentState(block); err != nil { return nil, err } - baseFee := evmtypes.DefaultMinFeePerGas.TruncateInt().BigInt() + // Must match the base fee the call actually executes under + // (evmOnlyBaseFee in sei-tendermint/internal/evmonlyapp/app.go): a + // mismatch here would misprice a caller-supplied fee cap/tip against the + // block context the EVM sees. + baseFee := new(big.Int) chainID := new(big.Int).SetUint64(api.backend.EvmChainID()) if err := args.CallDefaults(defaultCallGasCap, baseFee, chainID); err != nil { return nil, err diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 112d7a7582..8539dd4438 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -261,6 +261,13 @@ func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) if !ok { return nil, fmt.Errorf("EVM-only call attempted before InitChain") } + if state.pending.IsPresent() { + // FinalizeBlock already wrote this block's state to the store, but + // committedHeight/lastBlockTime (below) only advance on Commit, so a + // call in this window would see the new block's storage under the + // previous block's NUMBER/TIMESTAMP/PrevRandao. + return nil, fmt.Errorf("EVM-only call attempted before committing the finalized block") + } number, ok := utils.SafeCast[uint64](state.committedHeight) if !ok { return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committedHeight) diff --git a/sei-tendermint/internal/evmonlyapp/call_test.go b/sei-tendermint/internal/evmonlyapp/call_test.go index 00c9071419..7e0db3bd67 100644 --- a/sei-tendermint/internal/evmonlyapp/call_test.go +++ b/sei-tendermint/internal/evmonlyapp/call_test.go @@ -138,6 +138,24 @@ func TestEVMOnlyApplicationEvmCallDoesNotMutateCommittedState(t *testing.T) { require.Equal(t, common.Hash{}, after.GetStorage(contractAddr, slot)) } +func TestEVMOnlyApplicationEvmCallRefusesDuringPendingCommit(t *testing.T) { + app := newInitializedEVMOnlyTestApp(t) + evmApp := app.(*evmOnlyApplication) + + _, err := app.FinalizeBlock(t.Context(), &abci.RequestFinalizeBlock{ + Hash: crypto.Keccak256([]byte("block-1")), + Header: &tmproto.Header{ + Height: 1, + Time: time.Unix(1_700_000_001, 0), + }, + }) + require.NoError(t, err) + + _, err = evmApp.EvmCall(t.Context(), callMessage(common.Address{}, nil)) + + require.Error(t, err) +} + func TestEVMOnlyApplicationEvmCallRequiresInitChain(t *testing.T) { app := newEVMOnlyTestApp(t, nil) evmApp := app.(*evmOnlyApplication) From 6567d40209ac9fbbdbfd2260cada845f229dbb41 Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 11:51:08 +0200 Subject: [PATCH 4/5] style(evmonly): trim comments added for the review fixes Condense the NoBaseFee, base fee, and pending-commit comments to one line each, stating what/why without restating the surrounding code. --- giga/evmonly/call.go | 3 +-- giga/evmonly/rpc/call.go | 5 +---- sei-tendermint/internal/evmonlyapp/app.go | 5 +---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/giga/evmonly/call.go b/giga/evmonly/call.go index f291af1aeb..2e8d886871 100644 --- a/giga/evmonly/call.go +++ b/giga/evmonly/call.go @@ -32,8 +32,7 @@ func (e *Executor) Call(ctx context.Context, blockCtx BlockContext, msg *core.Me stateDB := e.acquireStateDB(gigaSnapshotStateReader{snapshot: snapshot, missingState: e.missingState}) defer e.releaseStateDB(stateDB) - // NoBaseFee lets a caller who leaves GasFeeCap/GasTipCap at zero skip the - // fee-cap-vs-basefee check, matching go-ethereum's own eth_call behavior. + // NoBaseFee matches go-ethereum's eth_call: zero fee fields skip the fee-cap check. evm := vm.NewEVM(buildBlockContext(blockCtx), stateDB, chainConfig, vm.Config{NoBaseFee: true}, customPrecompileMap(e.cfg.CustomPrecompiles)) stateDB.SetEVM(evm) evm.SetTxContext(core.NewEVMTxContext(msg)) diff --git a/giga/evmonly/rpc/call.go b/giga/evmonly/rpc/call.go index 78e3394e24..53ad6c720d 100644 --- a/giga/evmonly/rpc/call.go +++ b/giga/evmonly/rpc/call.go @@ -29,10 +29,7 @@ func (api *callAPI) Call(ctx context.Context, args export.TransactionArgs, block if err := requireCurrentState(block); err != nil { return nil, err } - // Must match the base fee the call actually executes under - // (evmOnlyBaseFee in sei-tendermint/internal/evmonlyapp/app.go): a - // mismatch here would misprice a caller-supplied fee cap/tip against the - // block context the EVM sees. + // Must match the base fee EvmCall executes under (evmOnlyBaseFee). baseFee := new(big.Int) chainID := new(big.Int).SetUint64(api.backend.EvmChainID()) if err := args.CallDefaults(defaultCallGasCap, baseFee, chainID); err != nil { diff --git a/sei-tendermint/internal/evmonlyapp/app.go b/sei-tendermint/internal/evmonlyapp/app.go index 8539dd4438..9ab4041d31 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -262,10 +262,7 @@ func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) return nil, fmt.Errorf("EVM-only call attempted before InitChain") } if state.pending.IsPresent() { - // FinalizeBlock already wrote this block's state to the store, but - // committedHeight/lastBlockTime (below) only advance on Commit, so a - // call in this window would see the new block's storage under the - // previous block's NUMBER/TIMESTAMP/PrevRandao. + // The store already has this block's writes; NUMBER/TIMESTAMP/PrevRandao advance only on Commit. return nil, fmt.Errorf("EVM-only call attempted before committing the finalized block") } number, ok := utils.SafeCast[uint64](state.committedHeight) From 7620e0296f1237e85893637f0f5ac6669e0dacef Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 16 Sep 2026 14:20:27 +0200 Subject: [PATCH 5/5] fix(evmonly): bound eth_call execution with a wall-clock timeout core.ApplyMessage doesn't respect ctx, so a call priced under the 10M gas cap but CPU-expensive (e.g. modexp with adversarial inputs) could hold a state view open indefinitely. Executor.Call now cancels its EVM after callTimeout (60s, matching evmrpc's simulation_evm_timeout default) via evm.Cancel(), same as go-ethereum's own eth_call. Addresses a non-blocking review finding that was missed on the initial pass. --- giga/evmonly/call.go | 20 ++++++++++++++++++++ giga/evmonly/call_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/giga/evmonly/call.go b/giga/evmonly/call.go index 2e8d886871..7b568fac58 100644 --- a/giga/evmonly/call.go +++ b/giga/evmonly/call.go @@ -3,11 +3,18 @@ package evmonly import ( "context" "errors" + "fmt" + "time" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/vm" ) +// callTimeout bounds how long a Call may run before its EVM is cancelled, +// matching evmrpc's simulation_evm_timeout default. A var, not a const, so +// tests can shrink it rather than run for the full timeout. +var callTimeout = 60 * time.Second + // Call executes msg as a read-only EVM message call against the current // committed state and returns the execution result. It persists no state // change. @@ -37,8 +44,21 @@ func (e *Executor) Call(ctx context.Context, blockCtx BlockContext, msg *core.Me stateDB.SetEVM(evm) evm.SetTxContext(core.NewEVMTxContext(msg)) + // core.ApplyMessage does not itself respect ctx, so bound it with a timer + // that cancels the EVM directly; gas pricing alone cannot cap wall-clock + // cost (e.g. modexp with adversarial inputs). + callCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + go func() { + <-callCtx.Done() + evm.Cancel() + }() + gasPool := new(core.GasPool).AddGas(msg.GasLimit) result, err := core.ApplyMessage(evm, msg, gasPool) + if evm.Cancelled() { + return nil, fmt.Errorf("EVM-only call exceeded %s execution timeout", callTimeout) + } if stateErr := stateDB.Error(); stateErr != nil { return nil, stateErr } diff --git a/giga/evmonly/call_test.go b/giga/evmonly/call_test.go index c40761f8f5..82c3e66656 100644 --- a/giga/evmonly/call_test.go +++ b/giga/evmonly/call_test.go @@ -3,6 +3,7 @@ package evmonly import ( "math/big" "testing" + "time" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" @@ -200,6 +201,43 @@ func TestExecutorCallDoesNotMutateCommittedState(t *testing.T) { "eth_call-style execution must never persist a state change") } +// infiniteLoopCode returns runtime bytecode that loops forever +// (JUMPDEST, PUSH1 0, JUMP), for a call whose gas alone would never stop it. +func infiniteLoopCode() []byte { + return []byte{0x5b, 0x60, 0x00, 0x56} +} + +func TestExecutorCallTimesOutOnUnboundedExecution(t *testing.T) { + original := callTimeout + callTimeout = 20 * time.Millisecond + defer func() { callTimeout = original }() + + chainID := big.NewInt(testChainID) + key, err := crypto.GenerateKey() + require.NoError(t, err) + sender := crypto.PubkeyToAddress(key.PublicKey) + contractAddr := crypto.CreateAddress(sender, 0) + + state := NewMemoryState() + state.SetBalance(sender, big.NewInt(2_000_000_000_000_000)) + store := NewMemoryStore(state) + executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet)) + + deploy := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(infiniteLoopCode()), 300_000) + _, err = executor.ExecuteBlock(t.Context(), BlockRequest{ + Context: blockContext(chainID), + Txs: [][]byte{deploy}, + }) + require.NoError(t, err) + + msg := callMessage(sender, &contractAddr) + msg.GasLimit = 1_000_000_000_000 // far more gas than the shrunk timeout allows spending + + _, err = executor.Call(t.Context(), blockContext(chainID), msg) + + require.ErrorContains(t, err, "timeout") +} + func TestExecutorCallRejectsMissingStateStore(t *testing.T) { executor := NewExecutor(Config{})