fix: pre-state tracer logging storage after call from precompile (#64)

## Why this should be merged

Fixes tracing when a stateful precompile calls another contract that
itself accesses storage.

## How this works

The pre-state tracer from `eth/tracers/native` doesn't implement
`CaptureEnter()` (entry of a new context), instead relying on
`CaptureState()` (per-opcode tracing) to detect that a new contract has
been entered. In doing so, it [maintains an
invariant](cb7eb89341/eth/tracers/native/prestate.go (L160))
that is expected when `CaptureState(vm.SLOAD, ...)` is called—breaking
the invariant results in a panic due to a nil map.

The fix involves (a) maintaining the invariant as part of
`CaptureEnter()` (previously a no-op); and (b) calling said method
inside `vm.PrecompileEnvironment.Call()`. The latter has the added
benefit of properly handling all tracing involving an outbound call from
precompiles.

## How this was tested

New integration test demonstrates that the tracer can log the retrieved
storage value.

---------

Co-authored-by: Darioush Jalali <darioush.jalali@avalabs.org>
This commit is contained in:
Arran Schlosberg 2024-10-29 06:26:29 +00:00 committed by GitHub
parent cb7eb89341
commit 12b8aa5c2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 23 deletions

View file

@ -54,31 +54,40 @@ type evmCallArgs struct {
}
// A CallType refers to a *CALL* [OpCode] / respective method on [EVM].
type CallType uint8
type CallType OpCode
const (
UnknownCallType CallType = iota
Call
CallCode
DelegateCall
StaticCall
Call = CallType(CALL)
CallCode = CallType(CALLCODE)
DelegateCall = CallType(DELEGATECALL)
StaticCall = CallType(STATICCALL)
)
func (t CallType) isValid() bool {
switch t {
case Call, CallCode, DelegateCall, StaticCall:
return true
default:
return false
}
}
// String returns a human-readable representation of the CallType.
func (t CallType) String() string {
switch t {
case Call:
return "Call"
case CallCode:
return "CallCode"
case DelegateCall:
return "DelegateCall"
case StaticCall:
return "StaticCall"
if t.isValid() {
return t.OpCode().String()
}
return fmt.Sprintf("Unknown %T(%d)", t, t)
}
// OpCode returns t's equivalent OpCode.
func (t CallType) OpCode() OpCode {
if t.isValid() {
return OpCode(t)
}
return INVALID
}
// run runs the [PrecompiledContract], differentiating between stateful and
// regular types.
func (args *evmCallArgs) run(p PrecompiledContract, input []byte, suppliedGas uint64) (ret []byte, remainingGas uint64, err error) {

View file

@ -17,6 +17,7 @@ package vm_test
import (
"bytes"
"encoding/json"
"fmt"
"math/big"
"reflect"
@ -33,6 +34,8 @@ import (
"github.com/ava-labs/libevm/core/types"
"github.com/ava-labs/libevm/core/vm"
"github.com/ava-labs/libevm/crypto"
"github.com/ava-labs/libevm/eth/tracers"
_ "github.com/ava-labs/libevm/eth/tracers/native"
"github.com/ava-labs/libevm/libevm"
"github.com/ava-labs/libevm/libevm/ethtest"
"github.com/ava-labs/libevm/libevm/hookstest"
@ -338,7 +341,7 @@ func TestInheritReadOnly(t *testing.T) {
rng := ethtest.NewPseudoRand(42)
contractAddr := rng.Address()
state.CreateAccount(contractAddr)
state.SetCode(contractAddr, convertBytes[vm.OpCode, byte](contract))
state.SetCode(contractAddr, convertBytes[vm.OpCode, byte](contract...))
// (3)
@ -404,7 +407,7 @@ func makeReturnProxy(t *testing.T, dest common.Address, call vm.OpCode) []vm.OpC
}
contract = append(contract, vm.PUSH20)
contract = append(contract, convertBytes[byte, vm.OpCode](dest[:])...)
contract = append(contract, convertBytes[byte, vm.OpCode](dest[:]...)...)
contract = append(contract,
p0, // gas
@ -417,7 +420,7 @@ func makeReturnProxy(t *testing.T, dest common.Address, call vm.OpCode) []vm.OpC
return contract
}
func convertBytes[From ~byte, To ~byte](buf []From) []To {
func convertBytes[From ~byte, To ~byte](buf ...From) []To {
out := make([]To, len(buf))
for i, b := range buf {
out[i] = To(b)
@ -672,7 +675,7 @@ func TestPrecompileMakeCall(t *testing.T) {
evm.Origin = eoa
state.CreateAccount(caller)
proxy := makeReturnProxy(t, sut, tt.incomingCallType)
state.SetCode(caller, convertBytes[vm.OpCode, byte](proxy))
state.SetCode(caller, convertBytes[vm.OpCode, byte](proxy...))
got, _, err := evm.Call(vm.AccountRef(eoa), caller, tt.eoaTxCallData, 1e6, uint256.NewInt(0))
require.NoError(t, err)
@ -681,6 +684,49 @@ func TestPrecompileMakeCall(t *testing.T) {
}
}
func TestPrecompileCallWithTracer(t *testing.T) {
// The native pre-state tracer, when logging storage, assumes an invariant
// that is broken by a precompile calling another contract. This is a test
// of the fix, ensuring that an SLOADed value is properly handled by the
// tracer.
rng := ethtest.NewPseudoRand(42 * 142857)
precompile := rng.Address()
contract := rng.Address()
hooks := &hookstest.Stub{
PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{
precompile: vm.NewStatefulPrecompile(func(env vm.PrecompileEnvironment, input []byte, suppliedGas uint64) (ret []byte, remainingGas uint64, err error) {
return env.Call(contract, nil, suppliedGas, uint256.NewInt(0))
}),
},
}
hooks.Register(t)
state, evm := ethtest.NewZeroEVM(t)
evm.GasPrice = big.NewInt(1)
state.CreateAccount(contract)
var zeroHash common.Hash
value := rng.Hash()
state.SetState(contract, zeroHash, value)
state.SetCode(contract, convertBytes[vm.OpCode, byte](vm.PC, vm.SLOAD))
const tracerName = "prestateTracer"
tracer, err := tracers.DefaultDirectory.New(tracerName, nil, nil)
require.NoErrorf(t, err, "tracers.DefaultDirectory.New(%q)", tracerName)
evm.Config.Tracer = tracer
_, _, err = evm.Call(vm.AccountRef(rng.Address()), precompile, []byte{}, 1e6, uint256.NewInt(0))
require.NoError(t, err, "evm.Call([precompile that calls regular contract])")
gotJSON, err := tracer.GetResult()
require.NoErrorf(t, err, "%T.GetResult()", tracer)
var got map[common.Address]struct{ Storage map[common.Hash]common.Hash }
require.NoErrorf(t, json.Unmarshal(gotJSON, &got), "json.Unmarshal(%T.GetResult(), %T)", tracer, &got)
require.Equal(t, value, got[contract].Storage[zeroHash], "value loaded with SLOAD")
}
//nolint:testableexamples // Including output would only make the example more complicated and hide the true intent
func ExamplePrecompileEnvironment() {
// To determine the actual caller of a precompile, as against the effective

View file

@ -90,7 +90,7 @@ func (e *environment) Call(addr common.Address, input []byte, gas uint64, value
return e.callContract(Call, addr, input, gas, value, opts...)
}
func (e *environment) callContract(typ CallType, addr common.Address, input []byte, gas uint64, value *uint256.Int, opts ...CallOption) ([]byte, uint64, error) {
func (e *environment) callContract(typ CallType, addr common.Address, input []byte, gas uint64, value *uint256.Int, opts ...CallOption) (retData []byte, retGas uint64, retErr error) {
// Depth and read-only setting are handled by [EVMInterpreter.Run], which
// isn't used for precompiles, so we need to do it ourselves to maintain the
// expected invariants.
@ -122,11 +122,24 @@ func (e *environment) callContract(typ CallType, addr common.Address, input []by
}
}
if in.readOnly && value != nil && !value.IsZero() {
return nil, gas, ErrWriteProtection
}
if t := e.evm.Config.Tracer; t != nil {
var bigVal *big.Int
if value != nil {
bigVal = value.ToBig()
}
t.CaptureEnter(typ.OpCode(), caller.Address(), addr, input, gas, bigVal)
startGas := gas
defer func() {
t.CaptureEnd(retData, startGas-retGas, retErr)
}()
}
switch typ {
case Call:
if in.readOnly && !value.IsZero() {
return nil, gas, ErrWriteProtection
}
return e.evm.Call(caller, addr, input, gas, value)
case CallCode, DelegateCall, StaticCall:
// TODO(arr4n): these cases should be very similar to CALL, hence the

View file

@ -0,0 +1,37 @@
// Copyright 2024 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.
package native
import (
"math/big"
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core/vm"
)
// CaptureEnter implements the [vm.EVMLogger] hook for entering a new scope (via
// CALL*, CREATE or SELFDESTRUCT).
func (t *prestateTracer) CaptureEnter(typ vm.OpCode, from common.Address, to common.Address, input []byte, gas uint64, value *big.Int) {
// Although [prestateTracer.lookupStorage] expects
// [prestateTracer.lookupAccount] to have been called, the invariant is
// maintained by [prestateTracer.CaptureState] when it encounters an OpCode
// corresponding to scope entry. This, however, doesn't work when using a
// call method exposed by [vm.PrecompileEnvironment], and is restored by a
// call to this CaptureEnter implementation. Note that lookupAccount(x) is
// idempotent.
t.lookupAccount(to)
}