-
Notifications
You must be signed in to change notification settings - Fork 886
feat(evmonly): add eth_call RPC to the EVM-only executor #4194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
79e37fc
feat(evmonly): add eth_call RPC to the EVM-only executor
shemnon 42409ab
refactor(evmonly): trim eth_call godocs, default call base fee to x/e…
shemnon 44d8d91
fix(evmonly): address eth_call review findings on base fee and commit…
shemnon 6567d40
style(evmonly): trim comments added for the review fixes
shemnon 80c24fb
Merge remote-tracking branch 'origin/giga-1' into shemnon/giga-evmonl…
shemnon 7620e02
fix(evmonly): bound eth_call execution with a wall-clock timeout
shemnon cda82e7
Merge remote-tracking branch 'origin/giga-1' into shemnon/giga-evmonl…
shemnon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.