Skip to content

Commit 58f3883

Browse files
authored
feat: read-only stateful precompiles (#20)
* feat: read-only argument to stateful precompiles * refactor: group `PrecompiledStatefulRun()` args except input * test: read-only status of all `EVM.*Call*()` methods * feat: `PrecompileEnvironment.ReadOnlyStateDB()` * doc: warning about allowing recursive calling * test: precompile call from within read-only env * refactor: introduce `{inherit,force}ReadOnly` consts * fix: `nolint` verbose if statement
1 parent df32256 commit 58f3883

File tree

3 files changed

+275
-23
lines changed

3 files changed

+275
-23
lines changed

core/vm/contracts.libevm.go

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,60 @@ import (
66
"github.com/holiman/uint256"
77

88
"github.com/ethereum/go-ethereum/common"
9+
"github.com/ethereum/go-ethereum/libevm"
910
"github.com/ethereum/go-ethereum/params"
1011
)
1112

1213
// evmCallArgs mirrors the parameters of the [EVM] methods Call(), CallCode(),
1314
// DelegateCall() and StaticCall(). Its fields are identical to those of the
14-
// parameters, prepended with the receiver name. As {Delegate,Static}Call don't
15-
// accept a value, they MUST set the respective field to nil.
15+
// parameters, prepended with the receiver name and appended with additional
16+
// values. As {Delegate,Static}Call don't accept a value, they MUST set the
17+
// respective field to nil.
1618
//
1719
// Instantiation can be achieved by merely copying the parameter names, in
1820
// order, which is trivially achieved with AST manipulation:
1921
//
2022
// func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *uint256.Int) ... {
21-
// ...
22-
// args := &evmCallArgs{evm, caller, addr, input, gas, value}
23+
// ...
24+
// args := &evmCallArgs{evm, caller, addr, input, gas, value, false}
2325
type evmCallArgs struct {
24-
evm *EVM
26+
evm *EVM
27+
// args:start
2528
caller ContractRef
2629
addr common.Address
2730
input []byte
2831
gas uint64
2932
value *uint256.Int
33+
// args:end
34+
35+
// evm.interpreter.readOnly is only set to true via a call to
36+
// EVMInterpreter.Run() so, if a precompile is called directly with
37+
// StaticCall(), then readOnly might not be set yet. StaticCall() MUST set
38+
// this to forceReadOnly and all other methods MUST set it to
39+
// inheritReadOnly; i.e. equivalent to the boolean they each pass to
40+
// EVMInterpreter.Run().
41+
readWrite rwInheritance
3042
}
3143

44+
type rwInheritance uint8
45+
46+
const (
47+
inheritReadOnly rwInheritance = iota + 1
48+
forceReadOnly
49+
)
50+
3251
// run runs the [PrecompiledContract], differentiating between stateful and
3352
// regular types.
3453
func (args *evmCallArgs) run(p PrecompiledContract, input []byte) (ret []byte, err error) {
3554
if p, ok := p.(statefulPrecompile); ok {
36-
return p.run(args.evm.StateDB, &args.evm.chainRules, args.caller.Address(), args.addr, input)
55+
return p.run(args, input)
3756
}
3857
return p.Run(input)
3958
}
4059

4160
// PrecompiledStatefulRun is the stateful equivalent of the Run() method of a
4261
// [PrecompiledContract].
43-
type PrecompiledStatefulRun func(_ StateDB, _ *params.Rules, caller, self common.Address, input []byte) ([]byte, error)
62+
type PrecompiledStatefulRun func(env PrecompileEnvironment, input []byte) ([]byte, error)
4463

4564
// NewStatefulPrecompile constructs a new PrecompiledContract that can be used
4665
// via an [EVM] instance but MUST NOT be called directly; a direct call to Run()
@@ -69,6 +88,69 @@ func (p statefulPrecompile) Run([]byte) ([]byte, error) {
6988
panic(fmt.Sprintf("BUG: call to %T.Run(); MUST call %T", p, p.run))
7089
}
7190

91+
// A PrecompileEnvironment provides information about the context in which a
92+
// precompiled contract is being run.
93+
type PrecompileEnvironment interface {
94+
Rules() params.Rules
95+
ReadOnly() bool
96+
// StateDB will be non-nil i.f.f !ReadOnly().
97+
StateDB() StateDB
98+
// ReadOnlyState will always be non-nil.
99+
ReadOnlyState() libevm.StateReader
100+
Addresses() *libevm.AddressContext
101+
}
102+
103+
//
104+
// ****** SECURITY ******
105+
//
106+
// If you are updating PrecompileEnvironment to provide the ability to call back
107+
// into another contract, you MUST revisit the evmCallArgs.forceReadOnly flag.
108+
//
109+
// It is possible that forceReadOnly is true but evm.interpreter.readOnly is
110+
// false. This is safe for now, but may not be if recursive calling *from* a
111+
// precompile is enabled.
112+
//
113+
// ****** SECURITY ******
114+
115+
var _ PrecompileEnvironment = (*evmCallArgs)(nil)
116+
117+
func (args *evmCallArgs) Rules() params.Rules { return args.evm.chainRules }
118+
119+
func (args *evmCallArgs) ReadOnly() bool {
120+
if args.readWrite == inheritReadOnly {
121+
if args.evm.interpreter.readOnly { //nolint:gosimple // Clearer code coverage for difficult-to-test branch
122+
return true
123+
}
124+
return false
125+
}
126+
// Even though args.readWrite may be some value other than forceReadOnly,
127+
// that would be an invalid use of the API so we default to read-only as the
128+
// safest failure mode.
129+
return true
130+
}
131+
132+
func (args *evmCallArgs) StateDB() StateDB {
133+
if args.ReadOnly() {
134+
return nil
135+
}
136+
return args.evm.StateDB
137+
}
138+
139+
func (args *evmCallArgs) ReadOnlyState() libevm.StateReader {
140+
// Even though we're actually returning a full state database, the user
141+
// would have to actively circumvent the returned interface to use it. At
142+
// that point they're off-piste and it's not our problem.
143+
return args.evm.StateDB
144+
}
145+
146+
func (args *evmCallArgs) Addresses() *libevm.AddressContext {
147+
return &libevm.AddressContext{
148+
Origin: args.evm.TxContext.Origin,
149+
Caller: args.caller.Address(),
150+
Self: args.addr,
151+
}
152+
}
153+
72154
var (
73155
// These lock in the assumptions made when implementing [evmCallArgs]. If
74156
// these break then the struct fields SHOULD be changed to match these

core/vm/contracts.libevm_test.go

Lines changed: 182 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package vm_test
22

33
import (
44
"fmt"
5+
"math/big"
56
"testing"
67

78
"github.com/holiman/uint256"
@@ -86,18 +87,25 @@ func TestNewStatefulPrecompile(t *testing.T) {
8687
const gasLimit = 1e6
8788
gasCost := rng.Uint64n(gasLimit)
8889

89-
makeOutput := func(caller, self common.Address, input []byte, stateVal common.Hash) []byte {
90+
makeOutput := func(caller, self common.Address, input []byte, stateVal common.Hash, readOnly bool) []byte {
9091
return []byte(fmt.Sprintf(
91-
"Caller: %v Precompile: %v State: %v Input: %#x",
92-
caller, self, stateVal, input,
92+
"Caller: %v Precompile: %v State: %v Read-only: %t, Input: %#x",
93+
caller, self, stateVal, readOnly, input,
9394
))
9495
}
96+
run := func(env vm.PrecompileEnvironment, input []byte) ([]byte, error) {
97+
if got, want := env.StateDB() != nil, !env.ReadOnly(); got != want {
98+
return nil, fmt.Errorf("PrecompileEnvironment().StateDB() must be non-nil i.f.f. not read-only; got non-nil? %t; want %t", got, want)
99+
}
100+
101+
addrs := env.Addresses()
102+
val := env.ReadOnlyState().GetState(precompile, slot)
103+
return makeOutput(addrs.Caller, addrs.Self, input, val, env.ReadOnly()), nil
104+
}
95105
hooks := &hookstest.Stub{
96106
PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{
97107
precompile: vm.NewStatefulPrecompile(
98-
func(state vm.StateDB, _ *params.Rules, caller, self common.Address, input []byte) ([]byte, error) {
99-
return makeOutput(caller, self, input, state.GetState(precompile, slot)), nil
100-
},
108+
run,
101109
func(b []byte) uint64 {
102110
return gasCost
103111
},
@@ -112,13 +120,175 @@ func TestNewStatefulPrecompile(t *testing.T) {
112120

113121
state, evm := ethtest.NewZeroEVM(t)
114122
state.SetState(precompile, slot, value)
115-
wantReturnData := makeOutput(caller, precompile, input, value)
116-
wantGasLeft := gasLimit - gasCost
117123

118-
gotReturnData, gotGasLeft, err := evm.Call(vm.AccountRef(caller), precompile, input, gasLimit, uint256.NewInt(0))
119-
require.NoError(t, err)
120-
assert.Equal(t, wantReturnData, gotReturnData)
121-
assert.Equal(t, wantGasLeft, gotGasLeft)
124+
tests := []struct {
125+
name string
126+
call func() ([]byte, uint64, error)
127+
// Note that this only covers evm.readWrite being set to forceReadOnly,
128+
// via StaticCall(). See TestInheritReadOnly for alternate case.
129+
wantReadOnly bool
130+
}{
131+
{
132+
name: "EVM.Call()",
133+
call: func() ([]byte, uint64, error) {
134+
return evm.Call(vm.AccountRef(caller), precompile, input, gasLimit, uint256.NewInt(0))
135+
},
136+
wantReadOnly: false,
137+
},
138+
{
139+
name: "EVM.CallCode()",
140+
call: func() ([]byte, uint64, error) {
141+
return evm.CallCode(vm.AccountRef(caller), precompile, input, gasLimit, uint256.NewInt(0))
142+
},
143+
wantReadOnly: false,
144+
},
145+
{
146+
name: "EVM.DelegateCall()",
147+
call: func() ([]byte, uint64, error) {
148+
return evm.DelegateCall(vm.AccountRef(caller), precompile, input, gasLimit)
149+
},
150+
wantReadOnly: false,
151+
},
152+
{
153+
name: "EVM.StaticCall()",
154+
call: func() ([]byte, uint64, error) {
155+
return evm.StaticCall(vm.AccountRef(caller), precompile, input, gasLimit)
156+
},
157+
wantReadOnly: true,
158+
},
159+
}
160+
161+
for _, tt := range tests {
162+
t.Run(tt.name, func(t *testing.T) {
163+
wantReturnData := makeOutput(caller, precompile, input, value, tt.wantReadOnly)
164+
wantGasLeft := gasLimit - gasCost
165+
166+
gotReturnData, gotGasLeft, err := tt.call()
167+
require.NoError(t, err)
168+
assert.Equal(t, string(wantReturnData), string(gotReturnData))
169+
assert.Equal(t, wantGasLeft, gotGasLeft)
170+
})
171+
}
172+
}
173+
174+
func TestInheritReadOnly(t *testing.T) {
175+
// The regular test of stateful precompiles only checks the read-only state
176+
// when called directly via vm.EVM.*Call*() methods. That approach will not
177+
// result in a read-only state via inheritance, which occurs when already in
178+
// a read-only environment there is a non-static call to a precompile.
179+
//
180+
// Test strategy:
181+
//
182+
// 1. Create a precompile that echoes its read-only status in the return
183+
// data. We MUST NOT assert inside the precompile as we need proof that
184+
// the precompile was actually called.
185+
//
186+
// 2. Create a bytecode contract that calls the precompile with CALL and
187+
// propagates the return data. Using CALL (i.e. not STATICCALL) means
188+
// that we know for certain that [forceReadOnly] isn't being used and,
189+
// instead, the read-only state is being read from
190+
// evm.interpreter.readOnly.
191+
//
192+
// 3. Assert that the returned input is as expected for the read-only state.
193+
194+
// (1)
195+
196+
var precompile common.Address
197+
const precompileAddr = 255
198+
precompile[common.AddressLength-1] = precompileAddr
199+
200+
const (
201+
ifReadOnly = iota + 1 // see contract bytecode for rationale
202+
ifNotReadOnly
203+
)
204+
hooks := &hookstest.Stub{
205+
PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{
206+
precompile: vm.NewStatefulPrecompile(
207+
func(env vm.PrecompileEnvironment, input []byte) ([]byte, error) {
208+
if env.ReadOnly() {
209+
return []byte{ifReadOnly}, nil
210+
}
211+
return []byte{ifNotReadOnly}, nil
212+
},
213+
func([]byte) uint64 { return 0 },
214+
),
215+
},
216+
}
217+
hookstest.Register(t, params.Extras[*hookstest.Stub, *hookstest.Stub]{
218+
NewRules: func(_ *params.ChainConfig, r *params.Rules, _ *hookstest.Stub, blockNum *big.Int, isMerge bool, timestamp uint64) *hookstest.Stub {
219+
r.IsCancun = true // enable PUSH0
220+
return hooks
221+
},
222+
})
223+
224+
// (2)
225+
226+
// See CALL signature: https://www.evm.codes/#f1?fork=cancun
227+
const p0 = vm.PUSH0
228+
contract := []vm.OpCode{
229+
vm.PUSH1, 1, // retSize (bytes)
230+
p0, // retOffset
231+
p0, // argSize
232+
p0, // argOffset
233+
p0, // value
234+
vm.PUSH1, precompileAddr,
235+
p0, // gas
236+
vm.CALL,
237+
// It's ok to ignore the return status. If the CALL failed then we'll
238+
// return []byte{0} next, and both non-failure return buffers are
239+
// non-zero because of the `iota + 1`.
240+
vm.PUSH1, 1, // size (byte)
241+
p0,
242+
vm.RETURN,
243+
}
244+
245+
state, evm := ethtest.NewZeroEVM(t)
246+
rng := ethtest.NewPseudoRand(42)
247+
contractAddr := rng.Address()
248+
state.CreateAccount(contractAddr)
249+
state.SetCode(contractAddr, contractCode(contract))
250+
251+
// (3)
252+
253+
caller := vm.AccountRef(rng.Address())
254+
tests := []struct {
255+
name string
256+
call func() ([]byte, uint64, error)
257+
want byte
258+
}{
259+
{
260+
name: "EVM.Call()",
261+
call: func() ([]byte, uint64, error) {
262+
return evm.Call(caller, contractAddr, []byte{}, 1e6, uint256.NewInt(0))
263+
},
264+
want: ifNotReadOnly,
265+
},
266+
{
267+
name: "EVM.StaticCall()",
268+
call: func() ([]byte, uint64, error) {
269+
return evm.StaticCall(vm.AccountRef(rng.Address()), contractAddr, []byte{}, 1e6)
270+
},
271+
want: ifReadOnly,
272+
},
273+
}
274+
275+
for _, tt := range tests {
276+
t.Run(tt.name, func(t *testing.T) {
277+
got, _, err := tt.call()
278+
require.NoError(t, err)
279+
require.Equalf(t, []byte{tt.want}, got, "want %d if read-only, otherwise %d", ifReadOnly, ifNotReadOnly)
280+
})
281+
}
282+
}
283+
284+
// contractCode converts a slice of op codes into a byte buffer for storage as
285+
// contract code.
286+
func contractCode(ops []vm.OpCode) []byte {
287+
ret := make([]byte, len(ops))
288+
for i, o := range ops {
289+
ret[i] = byte(o)
290+
}
291+
return ret
122292
}
123293

124294
func TestCanCreateContract(t *testing.T) {

core/vm/evm.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas
228228
}
229229

230230
if isPrecompile {
231-
args := &evmCallArgs{evm, caller, addr, input, gas, value}
231+
args := &evmCallArgs{evm, caller, addr, input, gas, value, inheritReadOnly}
232232
ret, gas, err = args.RunPrecompiledContract(p, input, gas)
233233
} else {
234234
// Initialise a new contract and set the code that is to be used by the EVM.
@@ -292,7 +292,7 @@ func (evm *EVM) CallCode(caller ContractRef, addr common.Address, input []byte,
292292

293293
// It is allowed to call precompiles, even via delegatecall
294294
if p, isPrecompile := evm.precompile(addr); isPrecompile {
295-
args := &evmCallArgs{evm, caller, addr, input, gas, value}
295+
args := &evmCallArgs{evm, caller, addr, input, gas, value, inheritReadOnly}
296296
ret, gas, err = args.RunPrecompiledContract(p, input, gas)
297297
} else {
298298
addrCopy := addr
@@ -338,7 +338,7 @@ func (evm *EVM) DelegateCall(caller ContractRef, addr common.Address, input []by
338338

339339
// It is allowed to call precompiles, even via delegatecall
340340
if p, isPrecompile := evm.precompile(addr); isPrecompile {
341-
args := &evmCallArgs{evm, caller, addr, input, gas, nil}
341+
args := &evmCallArgs{evm, caller, addr, input, gas, nil, inheritReadOnly}
342342
ret, gas, err = args.RunPrecompiledContract(p, input, gas)
343343
} else {
344344
addrCopy := addr
@@ -388,7 +388,7 @@ func (evm *EVM) StaticCall(caller ContractRef, addr common.Address, input []byte
388388
}
389389

390390
if p, isPrecompile := evm.precompile(addr); isPrecompile {
391-
args := &evmCallArgs{evm, caller, addr, input, gas, nil}
391+
args := &evmCallArgs{evm, caller, addr, input, gas, nil, forceReadOnly}
392392
ret, gas, err = args.RunPrecompiledContract(p, input, gas)
393393
} else {
394394
// At this point, we use a copy of address. If we don't, the go compiler will

0 commit comments

Comments
 (0)