diff --git a/giga/evmonly/call.go b/giga/evmonly/call.go new file mode 100644 index 0000000000..7b568fac58 --- /dev/null +++ b/giga/evmonly/call.go @@ -0,0 +1,66 @@ +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. +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) + + // 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)) + + // 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 + } + return result, err +} diff --git a/giga/evmonly/call_test.go b/giga/evmonly/call_test.go new file mode 100644 index 0000000000..82c3e66656 --- /dev/null +++ b/giga/evmonly/call_test.go @@ -0,0 +1,247 @@ +package evmonly + +import ( + "math/big" + "testing" + "time" + + "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. +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") +} + +// 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{}) + + _, 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..53ad6c720d --- /dev/null +++ b/giga/evmonly/rpc/call.go @@ -0,0 +1,80 @@ +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, 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 { + 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 + } + // 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 { + 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 1050b9f94e..15bf8d3b8c 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 7f623fa21a..1a39dbcd4e 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" @@ -35,6 +36,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] EvmProxyEnabled() bool @@ -86,6 +88,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 7d984a6a5d..63bfdb5a09 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" @@ -12,14 +13,15 @@ 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 + block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + blockNumber 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] proxyCalls int transactionCount func(common.Address) uint64 - blockNumber func() uint64 - chainID func() uint64 } func (b *testBackend) BroadcastTx(ctx context.Context, req *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) { @@ -30,27 +32,31 @@ 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] { b.proxyCalls++ return b.proxy } -func (b *testBackend) EvmProxyEnabled() bool { - return b.proxy.IsPresent() +func (b *testBackend) EvmBalance(address common.Address) uint256.Int { + return b.balance(address) } -func (b *testBackend) EvmTransactionCount(address common.Address) uint64 { - return b.transactionCount(address) +func (b *testBackend) EvmProxyEnabled() bool { + return b.proxy.IsPresent() } 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) EvmTransactionCount(address common.Address) uint64 { + return b.transactionCount(address) +} 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 4dba48e2d8..c61297def1 100644 --- a/sei-tendermint/internal/evmonlyapp/app.go +++ b/sei-tendermint/internal/evmonlyapp/app.go @@ -11,6 +11,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" @@ -61,6 +62,12 @@ type evmOnlyApplication struct { type evmOnlyCursorState struct { committed evmOnlyCursor pending utils.Option[evmOnlyCursor] + // lastBlockTime is the Time of the most recently committed block, used by + // EvmCall to reproduce that block's execution context for a read-only call. + lastBlockTime uint64 + // pendingBlockTime is the Time of the block staged in pending; Commit + // promotes it to lastBlockTime. + pendingBlockTime uint64 } var _ abci.Application = (*evmOnlyApplication)(nil) @@ -139,6 +146,7 @@ func (a *evmOnlyApplication) encodeCursorChangeSet(block evmonly.BlockContext, r gasLimit: block.GasLimit, } state.pending = utils.Some(next) + state.pendingBlockTime = block.Time return []*proto.NamedChangeSet{next.changeSet()}, nil } panic("unreachable") @@ -330,6 +338,48 @@ func (a *evmOnlyApplication) EvmChainID() uint64 { return a.chainID.Uint64() } +// 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 execution result. +func (a *evmOnlyApplication) EvmCall(ctx context.Context, msg *ethcore.Message) (*ethcore.ExecutionResult, error) { + var executor *evmonly.Executor + for exec := range a.executor.Lock() { + got, ok := exec.Get() + if !ok { + return nil, fmt.Errorf("EVM-only call attempted before InitChain") + } + executor = got + } + var blockCtx evmonly.BlockContext + for state := range a.cursor.Lock() { + if state.pending.IsPresent() { + // 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.committed.height) + if !ok { + return nil, fmt.Errorf("EVM-only committed height exceeds uint64: %d", state.committed.height) + } + // 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, + GasLimit: state.committed.gasLimit, + ChainID: new(big.Int).Set(a.chainID), + BaseFee: evmOnlyBaseFee(), + BlobBaseFee: new(big.Int), + BlockHash: state.committed.blockHash, + 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 { @@ -363,7 +413,7 @@ func (a *evmOnlyApplication) FinalizeBlock(ctx context.Context, req *abci.Reques BlobBaseFee: new(big.Int), ParentHash: parent.blockHash, BlockHash: blockHash, - PrevRandao: crypto.Keccak256Hash(binary.BigEndian.AppendUint64(nil, timestamp)), + PrevRandao: evmOnlyPrevRandao(timestamp), }, Txs: req.Txs, Senders: a.takeSenders(req.Txs), @@ -434,6 +484,7 @@ func (a *evmOnlyApplication) Commit(context.Context) (*abci.ResponseCommit, erro return nil, fmt.Errorf("EVM-only Commit called without a finalized block") } state.committed = pending + state.lastBlockTime = state.pendingBlockTime state.pending = utils.None[evmOnlyCursor]() 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..7e0db3bd67 --- /dev/null +++ b/sei-tendermint/internal/evmonlyapp/call_test.go @@ -0,0 +1,166 @@ +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 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) + + _, 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..ff68c27c28 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,24 @@ 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. +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 b15c3adc34..ce61ec2702 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" @@ -326,6 +327,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