Skip to content
Merged
66 changes: 66 additions & 0 deletions giga/evmonly/call.go
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
}
247 changes: 247 additions & 0 deletions giga/evmonly/call_test.go
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)
}
80 changes: 80 additions & 0 deletions giga/evmonly/rpc/call.go
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)
Comment thread
cursor[bot] marked this conversation as resolved.

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
}
Loading
Loading