diff --git a/core/vm/contracts.libevm.go b/core/vm/contracts.libevm.go index e509f5c1a6cc..254abc1f7835 100644 --- a/core/vm/contracts.libevm.go +++ b/core/vm/contracts.libevm.go @@ -221,6 +221,10 @@ type PrecompileEnvironment interface { // removed and automatically determined according to the type of call that // invoked the precompile. // + // On chains with EIP-150 active, outbound gas follows the same 63/64 rule and + // call-value stipend as the CALL opcode. + // Use [WithLegacyOutboundCallGas] only when reproducing pre-fix historical behaviour. + // // WARNING: using this method makes the precompile susceptible to reentrancy // attacks as with a regular contract. The Checks-Effects-Interactions // pattern, libevm's `reentrancy` package, or some other protection MUST be diff --git a/core/vm/contracts.libevm_test.go b/core/vm/contracts.libevm_test.go index 2b6e021c9a13..40b73a4b6acc 100644 --- a/core/vm/contracts.libevm_test.go +++ b/core/vm/contracts.libevm_test.go @@ -878,3 +878,136 @@ func TestPrecompileCallWithTracer(t *testing.T) { 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") } + +func TestPrecompileOutboundCall_EIP150CallGas(t *testing.T) { + // Calldata bytes interpreted by the SUT precompile below (test harness only). + const ( + opWithLegacyOutboundCallGas = 0xff // enable vm.WithLegacyOutboundCallGas for outbound Call + opNonZeroCallValue = 0xee // attach 1 wei so CALL stipend rules apply (unless legacy) + opFixedCallGas5000 = 0xdd // outbound gas arg 5000 (< 63/64 of gasBudget) + ) + + const gasBudget = uint64(640_000) + wantCapped := gasBudget - gasBudget/64 // 63/64 of available when base cost is zero + + sut := common.HexToAddress("7E570001") + dest := common.HexToAddress("7E570002") + + hooks := &hookstest.Stub{ + PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{ + sut: vm.NewStatefulPrecompile(func(env vm.PrecompileEnvironment, input []byte) (ret []byte, err error) { + var opts []vm.CallOption + val := uint256.NewInt(0) + callGasArg := env.Gas() + for _, b := range input { + switch b { + case opWithLegacyOutboundCallGas: + opts = append(opts, vm.WithLegacyOutboundCallGas()) + case opNonZeroCallValue: + val = uint256.NewInt(1) + case opFixedCallGas5000: + // Fixed outbound gas limit (less than 63/64 of a large budget). + callGasArg = 5000 + } + } + return env.Call(dest, nil, callGasArg, val, opts...) + }), + dest: vm.NewStatefulPrecompile(func(env vm.PrecompileEnvironment, input []byte) (ret []byte, err error) { + var u uint256.Int + u.SetUint64(env.Gas()) + b := u.Bytes32() + return b[:], nil + }), + }, + } + hooks.Register(t) + + blockCtx := vm.BlockContext{ + CanTransfer: core.CanTransfer, + Transfer: core.Transfer, + BlockNumber: big.NewInt(1), + } + state, evm := ethtest.NewZeroEVM(t, + ethtest.WithChainConfig(params.TestChainConfig), + ethtest.WithBlockContext(blockCtx), + ) + // Fund the SUT precompile address so outbound calls with non-zero value succeed. + state.AddBalance(sut, uint256.NewInt(1e18)) + + t.Run("EIP150_63_64", func(t *testing.T) { + ret, _, err := evm.Call(vm.AccountRef(common.Address{1}), sut, nil, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, wantCapped, got.Uint64(), "callee should start with 63/64 of parent's gas (no memory expansion base)") + }) + + t.Run("legacy_full_gas", func(t *testing.T) { + ret, _, err := evm.Call(vm.AccountRef(common.Address{1}), sut, []byte{opWithLegacyOutboundCallGas}, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, gasBudget, got.Uint64(), "WithLegacyOutboundCallGas should pass full requested gas to callee") + }) + + t.Run("EIP150_nonzero_value_adds_call_stipend", func(t *testing.T) { + want := wantCapped + params.CallStipend + ret, _, err := evm.Call(vm.AccountRef(common.Address{1}), sut, []byte{opNonZeroCallValue}, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, want, got.Uint64(), "callee gas should be 63/64-capped gas plus CALL stipend for value transfer") + }) + + t.Run("EIP150_requested_gas_below_cap_unchanged", func(t *testing.T) { + const wantPassThrough = uint64(5000) + ret, _, err := evm.Call(vm.AccountRef(common.Address{1}), sut, []byte{opFixedCallGas5000}, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, wantPassThrough, got.Uint64(), "when the requested limit is below the 63/64 cap, callee receives the full requested amount") + }) + + t.Run("legacy_with_value_no_stipend", func(t *testing.T) { + ret, _, err := evm.Call(vm.AccountRef(common.Address{1}), sut, []byte{opWithLegacyOutboundCallGas, opNonZeroCallValue}, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, gasBudget, got.Uint64(), "legacy outbound gas must not add CALL stipend even when value is non-zero") + }) + + t.Run("EIP150_cap_when_requested_exceeds_available_portion", func(t *testing.T) { + const smallBudget = uint64(100) + wantChild := smallBudget - smallBudget/64 // 99; request would be 100 but cap is lower + + _, evmSmall := ethtest.NewZeroEVM(t, + ethtest.WithChainConfig(params.TestChainConfig), + ethtest.WithBlockContext(blockCtx), + ) + ret, _, err := evmSmall.Call(vm.AccountRef(common.Address{2}), sut, nil, smallBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, wantChild, got.Uint64(), "callee gas is capped to 63/64 of remaining even when the requested limit is higher") + }) + + t.Run("pre_EIP150_rules_skip_outbound_gas_adjustment", func(t *testing.T) { + // Same fork heights as TestChainConfig except EIP-150 activates far in the future, + // so [params.Rules.IsEIP150] is false at blockCtx.BlockNumber. + cfg := *params.TestChainConfig + cfg.EIP150Block = big.NewInt(10_000) + require.False(t, cfg.IsEIP150(blockCtx.BlockNumber), "sanity: test block is before EIP-150 fork") + + _, evmFrontier := ethtest.NewZeroEVM(t, + ethtest.WithChainConfig(&cfg), + ethtest.WithBlockContext(blockCtx), + ) + evmFrontier.StateDB.AddBalance(sut, uint256.NewInt(1e18)) + + ret, _, err := evmFrontier.Call(vm.AccountRef(common.Address{1}), sut, nil, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got := new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, gasBudget, got.Uint64(), + "without EIP-150 rules, outbound Call uses full requested gas (no 63/64) even without WithLegacyOutboundCallGas") + + ret, _, err = evmFrontier.Call(vm.AccountRef(common.Address{1}), sut, []byte{opNonZeroCallValue}, gasBudget, uint256.NewInt(0)) + require.NoError(t, err) + got = new(uint256.Int).SetBytes(common.TrimLeftZeroes(ret)) + require.Equal(t, gasBudget, got.Uint64(), + "without EIP-150 rules, non-zero outbound value must not add CALL stipend to callee gas") + }) +} diff --git a/core/vm/environment.libevm.go b/core/vm/environment.libevm.go index 6551b21d0a76..809e920f8e78 100644 --- a/core/vm/environment.libevm.go +++ b/core/vm/environment.libevm.go @@ -104,8 +104,10 @@ func (e *environment) Call(addr common.Address, input []byte, gas uint64, value } func (e *environment) callContract(typ CallType, addr common.Address, input []byte, gas uint64, value *uint256.Int, opts ...CallOption) (retData []byte, retErr error) { + cfg := options.As[callConfig](opts...) + var caller ContractRef = e.self - if options.As[callConfig](opts...).unsafeCallerAddressProxying { + if cfg.unsafeCallerAddressProxying { // Note that, in addition to being unsafe, this breaks an EVM // assumption that the caller ContractRef is always a *Contract. caller = AccountRef(e.self.CallerAddress) @@ -119,7 +121,25 @@ func (e *environment) callContract(typ CallType, addr common.Address, input []by if e.ReadOnly() && value != nil && !value.IsZero() { return nil, ErrWriteProtection } - if !e.UseGas(gas) { + + gasForCall := gas + charge := gas + if e.evm.chainRules.IsEIP150 && !cfg.legacyOutboundCallGas { + calleeGas, err := callGas(true, e.self.Gas, 0, new(uint256.Int).SetUint64(gas)) + if err != nil { + return nil, err + } + gasForCall = calleeGas + if value != nil && !value.IsZero() { + var overflow bool + gasForCall, overflow = math.SafeAdd(calleeGas, params.CallStipend) + if overflow { + return nil, ErrGasUintOverflow + } + } + charge = calleeGas + } + if !e.UseGas(charge) { return nil, ErrOutOfGas } @@ -128,9 +148,9 @@ func (e *environment) callContract(typ CallType, addr common.Address, input []by if value != nil { bigVal = value.ToBig() } - t.CaptureEnter(typ.OpCode(), caller.Address(), addr, input, gas, bigVal) + t.CaptureEnter(typ.OpCode(), caller.Address(), addr, input, gasForCall, bigVal) - startGas := gas + startGas := gasForCall defer func() { t.CaptureEnd(retData, startGas-e.Gas(), retErr) }() @@ -138,7 +158,7 @@ func (e *environment) callContract(typ CallType, addr common.Address, input []by switch typ { case Call: - ret, returnGas, callErr := e.evm.Call(caller, addr, input, gas, value) + ret, returnGas, callErr := e.evm.Call(caller, addr, input, gasForCall, value) if err := e.refundGas(returnGas); err != nil { return nil, err } diff --git a/core/vm/options.libevm.go b/core/vm/options.libevm.go index d6cd7423e863..2b9b11e80186 100644 --- a/core/vm/options.libevm.go +++ b/core/vm/options.libevm.go @@ -20,6 +20,10 @@ import "github.com/ava-labs/libevm/libevm/options" type callConfig struct { unsafeCallerAddressProxying bool + // legacyOutboundCallGas, if true, skips EIP-150 call gas (63/64) and + // CallStipend handling so the callee receives the full `gas` argument and + // the parent is charged exactly that amount — matching pre-fix behaviour. + legacyOutboundCallGas bool } // A CallOption modifies the default behaviour of a contract call. @@ -37,3 +41,16 @@ func WithUNSAFECallerAddressProxying() CallOption { c.unsafeCallerAddressProxying = true }) } + +// WithLegacyOutboundCallGas disables EIP-150 outbound call gas rules for this +// call: the parent is charged the full requested gas and the callee receives +// that full amount (no 63/64 cap, no call-value stipend). +// +// Deprecated: only for backwards compatibility with historical chain behaviour +// (e.g. legacy native-asset precompile semantics). New precompiles MUST NOT use +// this option. +func WithLegacyOutboundCallGas() CallOption { + return options.Func[callConfig](func(c *callConfig) { + c.legacyOutboundCallGas = true + }) +}