diff --git a/core/state_transition.go b/core/state_transition.go index 2564e30ae..ce5c0464b 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -289,7 +289,7 @@ func (st *stateTransition) buyGas() error { if overflow { return fmt.Errorf("%w: address %v required balance exceeds 256 bits", ErrInsufficientFunds, st.msg.From.Hex()) } - if have, want := st.state.GetBalance(st.msg.From), balanceCheckU256; have.Cmp(want) < 0 { + if have, want := st.state.GetBalance(st.msg.From), balanceCheckU256; !st.evm.Config.DisableBalanceCheck && have.Cmp(want) < 0 { return fmt.Errorf("%w: address %v have %v want %v", ErrInsufficientFunds, st.msg.From.Hex(), have, want) } if err := st.gp.SubGas(st.msg.GasLimit); err != nil { @@ -303,7 +303,22 @@ func (st *stateTransition) buyGas() error { st.initialGas = st.msg.GasLimit mgvalU256, _ := uint256.FromBig(mgval) + if st.evm.Config.DisableBalanceCheck { + // Simulations charge the effective gas cost with saturating subtraction. + // Normal unused-gas refunds still apply, even when this debit is capped. + if balance := st.state.GetBalance(st.msg.From); balance.Cmp(mgvalU256) < 0 { + mgvalU256.Set(balance) + } + } st.state.SubBalance(st.msg.From, mgvalU256, tracing.BalanceDecreaseGasBuy) + if st.evm.Config.DisableBalanceCheck { + // Fund only the top-level value transfer. Nested calls retain their + // ordinary balance checks, and a revert keeps this transient funding. + value := uint256.MustFromBig(st.msg.Value) + if balance := st.state.GetBalance(st.msg.From); balance.Cmp(value) < 0 { + st.state.AddBalance(st.msg.From, value.Sub(value, balance), tracing.BalanceChangeUnspecified) + } + } return nil } @@ -351,13 +366,13 @@ func (st *stateTransition) preCheck() error { return fmt.Errorf("%w: address %v, maxPriorityFeePerGas bit length: %d", ErrTipVeryHigh, msg.From.Hex(), l) } - if msg.GasFeeCap.Cmp(msg.GasTipCap) < 0 { + if !st.evm.Config.DisablePriorityFeeCheck && msg.GasFeeCap.Cmp(msg.GasTipCap) < 0 { return fmt.Errorf("%w: address %v, maxPriorityFeePerGas: %s, maxFeePerGas: %s", ErrTipAboveFeeCap, msg.From.Hex(), msg.GasTipCap, msg.GasFeeCap) } // This will panic if baseFee is nil, but basefee presence is verified // as part of header validation. - if msg.GasFeeCap.Cmp(st.evm.Context.BaseFee) < 0 { + if !st.evm.Config.DisableBaseFeeCheck && msg.GasFeeCap.Cmp(st.evm.Context.BaseFee) < 0 { return fmt.Errorf("%w: address %v, maxFeePerGas: %s, baseFee: %s", ErrFeeCapTooLow, msg.From.Hex(), msg.GasFeeCap, st.evm.Context.BaseFee) } @@ -548,6 +563,12 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { effectiveTip := msg.GasPrice if rules.IsLondon { effectiveTip = new(big.Int).Sub(msg.GasPrice, st.evm.Context.BaseFee) + if st.evm.Config.DisableBaseFeeCheck && effectiveTip.Sign() < 0 { + // A relaxed base-fee check may admit a price below the base fee. + // Such a transaction pays no tip; converting a negative tip to + // uint256 would otherwise wrap the beneficiary's balance. + effectiveTip.SetUint64(0) + } } effectiveTipU256, _ := uint256.FromBig(effectiveTip) diff --git a/core/state_transition_policy_test.go b/core/state_transition_policy_test.go new file mode 100644 index 000000000..7a7a2467a --- /dev/null +++ b/core/state_transition_policy_test.go @@ -0,0 +1,274 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it 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 go-ethereum library is distributed in the hope that it 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 . + +package core + +import ( + "errors" + "math/big" + "testing" + + "github.com/holiman/uint256" + "github.com/tenderly/polygon-bor/common" + "github.com/tenderly/polygon-bor/core/state" + "github.com/tenderly/polygon-bor/core/tracing" + "github.com/tenderly/polygon-bor/core/types" + "github.com/tenderly/polygon-bor/core/vm" + "github.com/tenderly/polygon-bor/params" +) + +type executionCheckFixture struct { + state *state.StateDB + evm *vm.EVM + msg *Message + pool *GasPool +} + +func newExecutionCheckFixture(t *testing.T, config vm.Config) *executionCheckFixture { + t.Helper() + statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + if err != nil { + t.Fatal(err) + } + from, to, coinbase := common.HexToAddress("0x100"), common.HexToAddress("0x200"), common.HexToAddress("0x300") + statedb.AddBalance(from, uint256.NewInt(1_000_000), tracing.BalanceChangeUnspecified) + context := vm.BlockContext{ + CanTransfer: CanTransfer, + Transfer: Transfer, + GetHash: func(uint64) common.Hash { return common.Hash{} }, + Coinbase: coinbase, + BlockNumber: big.NewInt(1), + Time: 1, + Difficulty: new(big.Int), + GasLimit: 1_000_000, + BaseFee: big.NewInt(1), + } + return &executionCheckFixture{ + state: statedb, + evm: vm.NewEVM(context, statedb, params.AllEthashProtocolChanges, config), + msg: &Message{ + From: from, To: &to, Value: new(big.Int), GasLimit: 100_000, + GasPrice: big.NewInt(2), GasFeeCap: big.NewInt(2), GasTipCap: big.NewInt(1), + }, + pool: new(GasPool).AddGas(context.GasLimit), + } +} + +func (f *executionCheckFixture) fees(baseFee, feeCap, tipCap int64) { + f.evm.Context.BaseFee.SetInt64(baseFee) + f.msg.GasFeeCap.SetInt64(feeCap) + f.msg.GasTipCap.SetInt64(tipCap) + f.msg.GasPrice.SetInt64(min(feeCap, baseFee+tipCap)) +} + +func TestExecutionCheckFeePolicy(t *testing.T) { + for _, test := range []struct { + name string + config vm.Config + base, fee, tip int64 + wantError error + wantBeneficiaryTip uint64 + }{ + {name: "default valid fees", base: 1, fee: 2, tip: 1, wantBeneficiaryTip: 1}, + {name: "default rejects below base fee", base: 10, fee: 2, tip: 1, wantError: ErrFeeCapTooLow}, + {name: "legacy NoBaseFee still rejects nonzero low fee", config: vm.Config{NoBaseFee: true}, base: 10, fee: 2, tip: 1, wantError: ErrFeeCapTooLow}, + {name: "legacy NoBaseFee accepts zero fees", config: vm.Config{NoBaseFee: true}, base: 10}, + {name: "base fee bypass accepts nonzero low fee", config: vm.Config{DisableBaseFeeCheck: true}, base: 10, fee: 2, tip: 1}, + {name: "base fee bypass accepts zero fees", config: vm.Config{DisableBaseFeeCheck: true}, base: 10}, + {name: "base fee bypass retains priority check", config: vm.Config{DisableBaseFeeCheck: true}, base: 1, fee: 2, tip: 3, wantError: ErrTipAboveFeeCap}, + {name: "priority bypass accepts excess tip", config: vm.Config{DisablePriorityFeeCheck: true}, base: 1, fee: 2, tip: 3, wantBeneficiaryTip: 1}, + {name: "priority bypass retains base fee check", config: vm.Config{DisablePriorityFeeCheck: true}, base: 10, fee: 2, tip: 3, wantError: ErrFeeCapTooLow}, + {name: "both fee bypasses", config: vm.Config{DisableBaseFeeCheck: true, DisablePriorityFeeCheck: true}, base: 10, fee: 2, tip: 3}, + } { + t.Run(test.name, func(t *testing.T) { + f := newExecutionCheckFixture(t, test.config) + f.fees(test.base, test.fee, test.tip) + result, err := ApplyMessage(f.evm, f.msg, f.pool) + if !errors.Is(err, test.wantError) { + t.Fatalf("execution error = %v, want %v", err, test.wantError) + } + if err != nil { + if result != nil || f.state.GetNonce(f.msg.From) != 0 { + t.Fatal("rejected transaction executed") + } + return + } + if result.Err != nil { + t.Fatal(result.Err) + } + wantBalance := uint256.NewInt(1_000_000 - result.UsedGas*uint64(f.msg.GasPrice.Int64())) + if have := f.state.GetBalance(f.msg.From); !have.Eq(wantBalance) { + t.Fatalf("sender balance = %v, want %v", have, wantBalance) + } + wantTip := uint256.NewInt(result.UsedGas * test.wantBeneficiaryTip) + if have := f.state.GetBalance(f.evm.Context.Coinbase); !have.Eq(wantTip) { + t.Fatalf("beneficiary balance = %v, want %v", have, wantTip) + } + }) + } +} + +func TestExecutionCheckFeeOpcodes(t *testing.T) { + for _, test := range []struct { + name string + base, fee, tip int64 + }{ + {name: "nonzero below base", base: 10, fee: 2, tip: 1}, + {name: "zero fee", base: 10}, + {name: "priority above cap", base: 1, fee: 2, tip: 3}, + } { + t.Run(test.name, func(t *testing.T) { + f := newExecutionCheckFixture(t, vm.Config{DisableBaseFeeCheck: true, DisablePriorityFeeCheck: true}) + f.fees(test.base, test.fee, test.tip) + f.state.SetCode(*f.msg.To, []byte{ + byte(vm.BASEFEE), byte(vm.PUSH1), 0, byte(vm.MSTORE), + byte(vm.GASPRICE), byte(vm.PUSH1), 32, byte(vm.MSTORE), + byte(vm.PUSH1), 64, byte(vm.PUSH1), 0, byte(vm.RETURN), + }, tracing.CodeChangeUnspecified) + result, err := ApplyMessage(f.evm, f.msg, f.pool) + if err != nil { + t.Fatal(err) + } + if result.Err != nil || len(result.ReturnData) != 64 { + t.Fatalf("execution result = %+v", result) + } + if have := new(big.Int).SetBytes(result.ReturnData[:32]); have.Int64() != test.base { + t.Fatalf("BASEFEE = %v, want %d", have, test.base) + } + if have := new(big.Int).SetBytes(result.ReturnData[32:]); have.Cmp(f.msg.GasPrice) != 0 { + t.Fatalf("GASPRICE = %v, want %v", have, f.msg.GasPrice) + } + }) + } +} + +func TestExecutionCheckBalancePolicy(t *testing.T) { + for _, test := range []struct { + name string + balance uint64 + disabled bool + feeCap int64 + wantError error + wantSender uint64 + }{ + {name: "default rejects unfunded transfer", wantError: ErrInsufficientFunds}, + {name: "unfunded transfer", disabled: true, wantSender: 79_000}, + {name: "gas deficit", balance: 50_000, disabled: true, wantSender: 79_000}, + {name: "value deficit after gas debit", balance: 100_005, disabled: true, wantSender: 79_000}, + {name: "funded transfer", balance: 200_000, disabled: true, wantSender: 178_993}, + {name: "default checks maximum fee", balance: 200_000, feeCap: 100, wantError: ErrInsufficientFunds}, + {name: "bypass charges effective fee", balance: 200_000, feeCap: 100, disabled: true, wantSender: 178_993}, + } { + t.Run(test.name, func(t *testing.T) { + f := newExecutionCheckFixture(t, vm.Config{DisableBalanceCheck: test.disabled}) + f.fees(0, 1, 1) + if test.feeCap != 0 { + f.msg.GasFeeCap.SetInt64(test.feeCap) + } + f.msg.Value.SetInt64(7) + f.state.SetBalance(f.msg.From, uint256.NewInt(test.balance), tracing.BalanceChangeUnspecified) + result, err := ApplyMessage(f.evm, f.msg, f.pool) + if !errors.Is(err, test.wantError) { + t.Fatalf("execution error = %v, want %v", err, test.wantError) + } + if err != nil { + return + } + if result.Err != nil || result.UsedGas != 21_000 { + t.Fatalf("execution result = %+v", result) + } + if have := f.state.GetBalance(f.msg.From); !have.Eq(uint256.NewInt(test.wantSender)) { + t.Fatalf("sender balance = %v, want %d", have, test.wantSender) + } + if have := f.state.GetBalance(*f.msg.To); !have.Eq(uint256.NewInt(7)) { + t.Fatalf("recipient balance = %v, want 7", have) + } + if have := f.state.GetBalance(f.evm.Context.Coinbase); !have.Eq(uint256.NewInt(21_000)) { + t.Fatalf("beneficiary balance = %v, want 21000", have) + } + if nonce := f.state.GetNonce(f.msg.From); nonce != 1 { + t.Fatalf("sender nonce = %d, want 1", nonce) + } + }) + } +} + +func TestExecutionCheckBalanceRevertAndRefund(t *testing.T) { + for _, test := range []struct { + name string + code []byte + wantRevert bool + wantRefund bool + }{ + {name: "revert", code: []byte{byte(vm.PUSH1), 0, byte(vm.PUSH1), 0, byte(vm.REVERT)}, wantRevert: true}, + {name: "storage refund", code: []byte{byte(vm.PUSH1), 1, byte(vm.PUSH1), 0, byte(vm.SSTORE), byte(vm.PUSH1), 0, byte(vm.PUSH1), 0, byte(vm.SSTORE)}, wantRefund: true}, + } { + t.Run(test.name, func(t *testing.T) { + f := newExecutionCheckFixture(t, vm.Config{DisableBalanceCheck: true}) + f.fees(0, 1, 1) + f.msg.Value.SetInt64(7) + f.state.SetBalance(f.msg.From, new(uint256.Int), tracing.BalanceChangeUnspecified) + f.state.SetCode(*f.msg.To, test.code, tracing.CodeChangeUnspecified) + result, err := ApplyMessage(f.evm, f.msg, f.pool) + if err != nil { + t.Fatal(err) + } + if gotRevert := errors.Is(result.Err, vm.ErrExecutionReverted); gotRevert != test.wantRevert || result.Err != nil && !gotRevert { + t.Fatalf("execution error = %v, want revert %v", result.Err, test.wantRevert) + } + if test.wantRefund && result.MaxUsedGas <= result.UsedGas { + t.Fatal("expected a storage gas refund") + } + wantSender, wantRecipient := f.msg.GasLimit-result.UsedGas, uint64(7) + if test.wantRevert { + wantSender += 7 + wantRecipient = 0 + } + if have := f.state.GetBalance(f.msg.From); !have.Eq(uint256.NewInt(wantSender)) { + t.Fatalf("sender balance = %v, want %d", have, wantSender) + } + if have := f.state.GetBalance(*f.msg.To); !have.Eq(uint256.NewInt(wantRecipient)) { + t.Fatalf("recipient balance = %v, want %d", have, wantRecipient) + } + }) + } +} + +func TestExecutionCheckBypassesRetainValidation(t *testing.T) { + for _, test := range []struct { + name string + change func(*executionCheckFixture) + wantError error + }{ + {name: "fee width", change: func(f *executionCheckFixture) { f.msg.GasFeeCap.Lsh(big.NewInt(1), 256) }, wantError: ErrFeeCapVeryHigh}, + {name: "tip width", change: func(f *executionCheckFixture) { f.msg.GasTipCap.Lsh(big.NewInt(1), 256) }, wantError: ErrTipVeryHigh}, + {name: "value width", change: func(f *executionCheckFixture) { f.msg.Value.Lsh(big.NewInt(1), 256) }, wantError: ErrInsufficientFunds}, + {name: "intrinsic gas", change: func(f *executionCheckFixture) { f.msg.GasLimit = 20_999 }, wantError: ErrIntrinsicGas}, + {name: "block gas budget", change: func(f *executionCheckFixture) { f.pool = new(GasPool).AddGas(f.msg.GasLimit - 1) }, wantError: ErrGasLimitReached}, + {name: "nonce", change: func(f *executionCheckFixture) { f.msg.Nonce = 1 }, wantError: ErrNonceTooHigh}, + {name: "sender code", change: func(f *executionCheckFixture) { + f.state.SetCode(f.msg.From, []byte{byte(vm.STOP)}, tracing.CodeChangeUnspecified) + }, wantError: ErrSenderNoEOA}, + } { + t.Run(test.name, func(t *testing.T) { + f := newExecutionCheckFixture(t, vm.Config{DisableBaseFeeCheck: true, DisablePriorityFeeCheck: true, DisableBalanceCheck: true}) + test.change(f) + if _, err := ApplyMessage(f.evm, f.msg, f.pool); !errors.Is(err, test.wantError) { + t.Fatalf("execution error = %v, want %v", err, test.wantError) + } + }) + } +} diff --git a/core/vm/interpreter.go b/core/vm/interpreter.go index 8beb8ef21..43561661e 100644 --- a/core/vm/interpreter.go +++ b/core/vm/interpreter.go @@ -19,16 +19,19 @@ package vm import ( "fmt" + "github.com/holiman/uint256" "github.com/tenderly/polygon-bor/common" "github.com/tenderly/polygon-bor/common/math" "github.com/tenderly/polygon-bor/core/tracing" - "github.com/holiman/uint256" ) // Config are the configuration options for the Interpreter type Config struct { Tracer *tracing.Hooks NoBaseFee bool // Forces the EIP-1559 baseFee to 0 (needed for 0 price calls) + DisableBaseFeeCheck bool // Allows gas fees below the block base fee without changing fee values. + DisablePriorityFeeCheck bool // Allows a priority fee above the fee cap. + DisableBalanceCheck bool // Funds the top-level caller when gas or value exceeds its balance. EnablePreimageRecording bool // Enables recording of SHA3/keccak preimages ExtraEips []int // Additional EIPS that are to be enabled