diff --git a/core/eip8037_test.go b/core/eip8037_test.go new file mode 100644 index 0000000000..c7e94ec629 --- /dev/null +++ b/core/eip8037_test.go @@ -0,0 +1,614 @@ +// 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 . + +// Transaction- and block-level tests for EIP-8037 (multidimensional state-gas +// metering). They apply whole transactions and inspect the 2D block gas pool +// (cumulativeRegular / cumulativeState) and the receipt/peak figures. + +package core + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/beacon" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +var ( + cfg8037 = balChainConfig() + signer8037 = types.LatestSigner(cfg8037) + rules8037 = cfg8037.Rules(big.NewInt(0), true, 0) + senderKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + senderAddr = crypto.PubkeyToAddress(senderKey.PublicKey) + + // state-gas charges in units (CPSB applied). + newAccountState = uint64(params.AccountCreationSize * params.CostPerStateByte) // 183,600 + newSlotState = uint64(params.StorageCreationSize * params.CostPerStateByte) // 97,920 + authBaseState = uint64(params.AuthorizationCreationSize * params.CostPerStateByte) // 35,190 + authWorstState = newAccountState + authBaseState // 218,790 +) + +// mkState builds an in-memory StateDB from a genesis allocation. +func mkState(alloc types.GenesisAlloc) *state.StateDB { + sdb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + for addr, acc := range alloc { + sdb.CreateAccount(addr) + if acc.Balance != nil { + sdb.AddBalance(addr, uint256.MustFromBig(acc.Balance), tracing.BalanceChangeUnspecified) + } + if acc.Nonce != 0 { + sdb.SetNonce(addr, acc.Nonce, tracing.NonceChangeGenesis) + } + if len(acc.Code) != 0 { + sdb.SetCode(addr, acc.Code, tracing.CodeChangeUnspecified) + } + for k, v := range acc.Storage { + sdb.SetState(addr, k, v) + } + } + sdb.Finalise(true) + return sdb +} + +// amsterdamCoreEVM builds an Amsterdam EVM over statedb with fees disabled. +func amsterdamCoreEVM(sdb *state.StateDB) *vm.EVM { + ctx := vm.BlockContext{ + CanTransfer: CanTransfer, + Transfer: Transfer, + GetHash: func(uint64) common.Hash { return common.Hash{} }, + BlockNumber: big.NewInt(0), + Random: &common.Hash{}, + Difficulty: big.NewInt(0), + BaseFee: big.NewInt(0), + BlobBaseFee: big.NewInt(0), + GasLimit: 60_000_000, + CostPerStateByte: params.CostPerStateByte, + } + return vm.NewEVM(ctx, sdb, cfg8037, vm.Config{NoBaseFee: true}) +} + +// applyMsg applies one transaction with a fresh block gas pool and returns the +// execution result, the gas pool (for the 2D split) and any consensus error. +func applyMsg(t *testing.T, sdb *state.StateDB, tx *types.Transaction) (*ExecutionResult, *GasPool, error) { + t.Helper() + evm := amsterdamCoreEVM(sdb) + msg, err := TransactionToMessage(tx, signer8037, evm.Context.BaseFee) + if err != nil { + t.Fatalf("to message: %v", err) + } + gp := NewGasPool(evm.Context.GasLimit) + // Drive the stateTransition directly (as ApplyMessage does) so the test can + // inspect the final tx-level GasBudget vector via st.gasRemaining. + evm.SetTxContext(NewEVMTxContext(msg)) + st := newStateTransition(evm, msg, gp) + res, err := st.execute() + if err == nil && res != nil { + assertPoolSane(t, res, gp) + limit := min(msg.GasLimit, params.MaxTxGas) + assertBudgetSane(t, vm.NewGasBudget(limit, msg.GasLimit-limit), st.gasRemaining) + } + return res, gp, err +} + +// assertBudgetSane validates the final tx-level GasBudget vector: +// +// regular: RegularGas + UsedRegularGas + Spilled == initial.RegularGas +// state: StateGas + UsedStateGas == initial.StateGas + Spilled +// scalar: Used(initial) == UsedRegularGas + UsedStateGas +func assertBudgetSane(t *testing.T, initial, got vm.GasBudget) { + t.Helper() + if got.RegularGas+got.UsedRegularGas+got.Spilled != initial.RegularGas { + t.Fatalf("regular not conserved: R=%d usedR=%d spilled=%d, want sum %d", + got.RegularGas, got.UsedRegularGas, got.Spilled, initial.RegularGas) + } + if int64(got.StateGas)+got.UsedStateGas != int64(initial.StateGas)+int64(got.Spilled) { + t.Fatalf("state not conserved: S=%d usedS=%d spilled=%d, want %d+spilled", + got.StateGas, got.UsedStateGas, got.Spilled, initial.StateGas) + } + if int64(got.Used(initial)) != int64(got.UsedRegularGas)+got.UsedStateGas { + t.Fatalf("scalar mismatch: used=%d, usedR=%d usedS=%d", + got.Used(initial), got.UsedRegularGas, got.UsedStateGas) + } +} + +// assertPoolSane validates the whole 2D block-gas-pool vector after a single tx. +// +// receipt: cumulativeUsed == res.UsedGas <= res.MaxUsedGas +// pre-refund: cumulativeRegular + cumulativeState <= res.MaxUsedGas (peak) +// bottleneck: Used() == max(cumulativeRegular, cumulativeState) <= initial +func assertPoolSane(t *testing.T, res *ExecutionResult, gp *GasPool) { + t.Helper() + if gp.cumulativeUsed != res.UsedGas { + t.Fatalf("receipt scalar = %d, want UsedGas %d", gp.cumulativeUsed, res.UsedGas) + } + if res.UsedGas > res.MaxUsedGas { + t.Fatalf("post-refund gas %d exceeds peak %d", res.UsedGas, res.MaxUsedGas) + } + if sum := gp.cumulativeRegular + gp.cumulativeState; sum > res.MaxUsedGas { + t.Fatalf("regular+state %d exceeds peak %d", sum, res.MaxUsedGas) + } + if gp.Used() != max(gp.cumulativeRegular, gp.cumulativeState) { + t.Fatalf("block used %d != max(%d,%d)", gp.Used(), gp.cumulativeRegular, gp.cumulativeState) + } + if gp.Used() > gp.initial { + t.Fatalf("block used %d exceeds limit %d", gp.Used(), gp.initial) + } +} + +// senderAlloc funds the sender with the given extra accounts merged in. +func senderAlloc(extra types.GenesisAlloc) types.GenesisAlloc { + alloc := types.GenesisAlloc{senderAddr: {Balance: big.NewInt(1e18)}} + for a, acc := range extra { + alloc[a] = acc + } + return alloc +} + +// callTx builds a signed dynamic-fee call to `to` with zero fees. +func callTx(nonce uint64, to common.Address, value int64, gas uint64, data []byte) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: nonce, To: &to, Value: big.NewInt(value), + Gas: gas, GasFeeCap: big.NewInt(0), GasTipCap: big.NewInt(0), Data: data, + }) +} + +// createTx builds a signed contract-creation transaction. +func createTx(nonce, gas uint64, initCode []byte) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: nonce, To: nil, Value: big.NewInt(0), + Gas: gas, GasFeeCap: big.NewInt(0), GasTipCap: big.NewInt(0), Data: initCode, + }) +} + +var ( + deploy3 = []byte{0x60, 0x03, 0x60, 0x00, 0xf3} // init: return 3 bytes of code + revertI = []byte{0x60, 0x00, 0x60, 0x00, 0xfd} // init: REVERT +) + +// ===================== Top-level create transaction ====================== + +// A creation tx's intrinsic gas pre-charges one account creation as state gas. +func TestCreateTxIntrinsicChargesAccountUnconditionally(t *testing.T) { + cost, err := IntrinsicGas(nil, nil, nil, true, rules8037, params.CostPerStateByte) + if err != nil { + t.Fatal(err) + } + if cost.StateGas != newAccountState { + t.Fatalf("intrinsic state gas = %d, want %d", cost.StateGas, newAccountState) + } +} + +// Creating onto a pre-existing (balance-only) address refills the account +// portion; only the code deposit is charged as state gas. +func TestCreateTxPreexistingDestRefill(t *testing.T) { + derived := crypto.CreateAddress(senderAddr, 0) + sdb := mkState(senderAlloc(types.GenesisAlloc{derived: {Balance: big.NewInt(1)}})) + _, gp, err := applyMsg(t, sdb, createTx(0, 1_000_000, deploy3)) + if err != nil { + t.Fatal(err) + } + if want := uint64(3 * params.CostPerStateByte); gp.cumulativeState != want { + t.Fatalf("state gas = %d, want %d", gp.cumulativeState, want) + } +} + +// A creation tx that reverts refills the account-creation charge. +func TestCreateTxRevertRefill(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + res, gp, err := applyMsg(t, sdb, createTx(0, 1_000_000, revertI)) + if err != nil { + t.Fatal(err) + } + if !res.Failed() { + t.Fatal("expected failed creation") + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", gp.cumulativeState) + } +} + +// An address collision burns gas_left while refilling the account charge. +func TestCreateTxCollisionConsumesGasLeft(t *testing.T) { + const gas = 1_000_000 + derived := crypto.CreateAddress(senderAddr, 0) + sdb := mkState(senderAlloc(types.GenesisAlloc{derived: {Nonce: 1}})) + res, gp, err := applyMsg(t, sdb, createTx(0, gas, deploy3)) + if err != nil { + t.Fatal(err) + } + if !res.Failed() { + t.Fatal("expected collision failure") + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", gp.cumulativeState) + } + // All forwarded gas_left is burned; only the refilled account charge (which + // had spilled into regular) returns to gas_left. So regular gas consumed is + // exactly tx.gas - newAccountState, with no other refund. + if want := uint64(gas) - newAccountState; gp.cumulativeRegular != want { + t.Fatalf("regular gas = %d, want %d", gp.cumulativeRegular, want) + } +} + +// ======================== Transaction validation ========================= + +// The regular dimension must have room for min(tx.gas, MaxTxGas). +func TestValidationRegularGasAvailable(t *testing.T) { + gp := NewGasPool(30_000_000) + gp.cumulativeRegular = 29_000_000 + if gp.CheckGasAmsterdam(2_000_000, 0) == nil { + t.Fatal("expected regular dimension full") + } + if err := gp.CheckGasAmsterdam(1_000_000, 0); err != nil { + t.Fatalf("regular fits but rejected: %v", err) + } +} + +// The state dimension must have room for the whole tx.gas. +func TestValidationStateGasAvailable(t *testing.T) { + gp := NewGasPool(30_000_000) + gp.cumulativeState = 29_000_000 + if gp.CheckGasAmsterdam(0, 2_000_000) == nil { + t.Fatal("expected state dimension full") + } + if err := gp.CheckGasAmsterdam(0, 1_000_000); err != nil { + t.Fatalf("state fits but rejected: %v", err) + } +} + +// tx.gas may exceed MaxTxGas: regular is capped at MaxTxGas while the state +// dimension reserves the full tx.gas (the excess lands in the reservoir). +func TestValidationStateGasOverflowAllowed(t *testing.T) { + gas := uint64(params.MaxTxGas) + 5_000_000 + gp := NewGasPool(40_000_000) + if err := gp.CheckGasAmsterdam(min(gas, params.MaxTxGas), gas); err != nil { + t.Fatalf("overflow tx rejected at pool: %v", err) + } + // A real transfer with gas above MaxTxGas is accepted under Amsterdam. + sdb := mkState(senderAlloc(nil)) + to := common.HexToAddress("0xc0ffee") + if _, _, err := applyMsg(t, sdb, callTx(0, to, 1, gas, nil)); err != nil { + t.Fatalf("tx with gas > MaxTxGas rejected: %v", err) + } +} + +// Intrinsic regular gas above MaxTxGas (EIP-7825 cap) is rejected. +func TestValidationIntrinsicRegularCap(t *testing.T) { + al := make(types.AccessList, 8000) // ~19.2M regular, over the 16.77M cap + for i := range al { + al[i].Address = common.BigToAddress(big.NewInt(int64(i + 1))) + } + tx := types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: 0, To: &senderAddr, Value: big.NewInt(0), + Gas: 25_000_000, GasFeeCap: big.NewInt(0), GasTipCap: big.NewInt(0), AccessList: al, + }) + if _, _, err := applyMsg(t, mkState(senderAlloc(nil)), tx); err == nil { + t.Fatal("expected rejection for intrinsic regular over MaxTxGas") + } +} + +// ========================= Refund and gas used =========================== + +// clearSlots deploys a contract that zeroes slots 1..n, each preset to 1. +func clearSlots(addr common.Address, n int) (types.GenesisAlloc, []byte) { + var code []byte + storage := make(map[common.Hash]common.Hash, n) + for s := 1; s <= n; s++ { + code = append(code, 0x60, 0x00, 0x60, byte(s), 0x55) // PUSH1 0; PUSH1 s; SSTORE + storage[common.BytesToHash([]byte{byte(s)})] = common.BytesToHash([]byte{1}) + } + return types.GenesisAlloc{addr: {Code: append(code, 0x00), Storage: storage}}, nil +} + +// tx_gas_used_before_refund (peak) exceeds the post-refund gas used. +func TestGasUsedBeforeRefund(t *testing.T) { + c := common.HexToAddress("0xc1ea0") + alloc, _ := clearSlots(c, 1) + res, _, err := applyMsg(t, mkState(senderAlloc(alloc)), callTx(0, c, 0, 1_000_000, nil)) + if err != nil { + t.Fatal(err) + } + if res.MaxUsedGas <= res.UsedGas { + t.Fatalf("peak %d must exceed post-refund %d", res.MaxUsedGas, res.UsedGas) + } +} + +// The refund is capped at 20% of gas used before refund. +func TestRefundCappedAt20Percent(t *testing.T) { + c := common.HexToAddress("0xc1ea3") + alloc, _ := clearSlots(c, 3) // refund (3x4800) exceeds the 20% cap + res, _, err := applyMsg(t, mkState(senderAlloc(alloc)), callTx(0, c, 0, 1_000_000, nil)) + if err != nil { + t.Fatal(err) + } + if want := res.MaxUsedGas - res.MaxUsedGas/5; res.UsedGas != want { + t.Fatalf("gas used = %d, want capped %d", res.UsedGas, want) + } +} + +// The EIP-7623 calldata floor is applied after the refund. +func TestRefundCalldataFloorAfterRefund(t *testing.T) { + data := make([]byte, 1000) // all-zero calldata: floor dominates a bare call + floor, _ := FloorDataGas(rules8037, data, nil) + to := common.HexToAddress("0xeeee") + res, _, err := applyMsg(t, mkState(senderAlloc(nil)), callTx(0, to, 0, 1_000_000, data)) + if err != nil { + t.Fatal(err) + } + if res.UsedGas != floor { + t.Fatalf("gas used = %d, want floor %d", res.UsedGas, floor) + } +} + +// When the floor exceeds the post-refund gas, it negates part of the refund. +func TestRefundFloorNegatesRefund(t *testing.T) { + c := common.HexToAddress("0xc1ea1") + alloc, _ := clearSlots(c, 1) + data := make([]byte, 1000) + floor, _ := FloorDataGas(rules8037, data, nil) + res, _, err := applyMsg(t, mkState(senderAlloc(alloc)), callTx(0, c, 0, 1_000_000, data)) + if err != nil { + t.Fatal(err) + } + if res.UsedGas != floor { + t.Fatalf("gas used = %d, want floor %d (refund negated)", res.UsedGas, floor) + } +} + +// ========================= Block-level accounting ======================== + +// The pool tracks regular and state cumulatively in separate counters. +func TestBlockTracksTwoCounters(t *testing.T) { + gp := NewGasPool(60_000_000) + if err := gp.ChargeGasAmsterdam(100, 200, 300); err != nil { + t.Fatal(err) + } + if gp.cumulativeRegular != 100 || gp.cumulativeState != 200 { + t.Fatalf("counters = (%d,%d), want (100,200)", gp.cumulativeRegular, gp.cumulativeState) + } +} + +// Block gas used is the max of the two dimensions. +func TestBlockGasUsedIsMax(t *testing.T) { + gp := NewGasPool(60_000_000) + gp.ChargeGasAmsterdam(100, 200, 300) + if gp.Used() != 200 { + t.Fatalf("block used = %d, want 200", gp.Used()) + } +} + +// Block validity is checked against the max dimension, not the sum. +func TestBlockValidityAgainstMax(t *testing.T) { + gp := NewGasPool(150) + // regular 100 + state 120: sum 220 > 150 but max 120 <= 150 is valid. + if err := gp.ChargeGasAmsterdam(100, 120, 0); err != nil { + t.Fatalf("max within limit but rejected: %v", err) + } + // state 200 alone exceeds the limit. + if err := gp.ChargeGasAmsterdam(0, 200, 0); err == nil { + t.Fatal("expected block overflow on state dimension") + } +} + +// The block header gas_used reflects the bottleneck dimension (here, state), +// which the base-fee update then equilibrates against. +func TestBlockBaseFeeUsesMax(t *testing.T) { + c := common.HexToAddress("0x5707e5") + var code []byte + for s := 1; s <= 5; s++ { + code = append(code, 0x60, byte(s), 0x60, byte(s), 0x55) // SSTORE new slot s + } + env := newBALTestEnv(types.GenesisAlloc{c: {Code: append(code, 0x00)}}) + engine := beacon.New(ethash.NewFaker()) + _, blocks, _ := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, b *BlockGen) { + b.AddTx(env.tx(0, &c, big.NewInt(0), 1_000_000, 0, nil)) + }) + if want := uint64(5 * newSlotState); blocks[0].GasUsed() != want { + t.Fatalf("block gas used = %d, want %d (state bottleneck)", blocks[0].GasUsed(), want) + } +} + +// Receipt cumulative_gas_used is the running sum of per-tx gas (post-refund, +// post-floor), so consecutive receipts differ by exactly that tx's gas. +func TestReceiptCumulativeGasUsed(t *testing.T) { + env := newBALTestEnv(nil) + a, b := common.HexToAddress("0xaaaa"), common.HexToAddress("0xbbbb") + engine := beacon.New(ethash.NewFaker()) + _, _, receipts := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, g *BlockGen) { + g.AddTx(env.tx(0, &a, big.NewInt(1), txGasNewAccount, 0, nil)) + g.AddTx(env.tx(1, &b, big.NewInt(1), txGasNewAccount, 0, nil)) + }) + r := receipts[0] + if got := r[1].CumulativeGasUsed - r[0].CumulativeGasUsed; got != r[1].GasUsed { + t.Fatalf("cumulative delta = %d, want tx gas %d", got, r[1].GasUsed) + } +} + +// ======================= EIP-7702 authorizations ========================= + +// signAuth signs an authorization from authKey for the given delegate and nonce. +func signAuth(t *testing.T, authKey string, delegate common.Address, nonce uint64) (types.SetCodeAuthorization, common.Address) { + t.Helper() + k, _ := crypto.HexToECDSA(authKey) + auth, err := types.SignSetCode(k, types.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(cfg8037.ChainID), Address: delegate, Nonce: nonce, + }) + if err != nil { + t.Fatalf("sign auth: %v", err) + } + return auth, crypto.PubkeyToAddress(k.PublicKey) +} + +func setCodeTx(nonce uint64, to common.Address, auths []types.SetCodeAuthorization) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.SetCodeTx{ + ChainID: uint256.MustFromBig(cfg8037.ChainID), Nonce: nonce, To: to, Value: new(uint256.Int), + Gas: 1_000_000, GasFeeCap: new(uint256.Int), GasTipCap: new(uint256.Int), AuthList: auths, + }) +} + +const authKeyA = "0202020202020202020202020202020202020202020202020202002020202020" + +var delegate8037 = common.HexToAddress("0xde1e8a7e") + +// Intrinsic gas pre-charges the worst-case (account + indicator) per auth. +func TestAuthIntrinsicWorstCase(t *testing.T) { + cost, err := IntrinsicGas(nil, nil, []types.SetCodeAuthorization{{}}, false, rules8037, params.CostPerStateByte) + if err != nil { + t.Fatal(err) + } + if cost.StateGas != authWorstState { + t.Fatalf("intrinsic state gas = %d, want %d", cost.StateGas, authWorstState) + } +} + +// An invalid authorization refills its entire intrinsic state-gas charge. +func TestAuthInvalidRefillFull(t *testing.T) { + k, _ := crypto.HexToECDSA(authKeyA) + bad, _ := types.SignSetCode(k, types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(999), Address: delegate8037, Nonce: 0, // wrong chain id + }) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{bad})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (fully refilled)", gp.cumulativeState) + } +} + +// A pre-existing authority refills the account portion (indicator stands). +func TestAuthAccountExistsRefill(t *testing.T) { + auth, authority := signAuth(t, authKeyA, delegate8037, 0) + sdb := mkState(senderAlloc(types.GenesisAlloc{authority: {Balance: big.NewInt(1)}})) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != authBaseState { + t.Fatalf("state gas = %d, want %d (account refilled)", gp.cumulativeState, authBaseState) + } +} + +// Setting a delegation on an already-delegated authority refills the indicator +// portion (and the account portion, since the authority already exists). +func TestAuthSetOnDelegatedRefillBase(t *testing.T) { + auth, authority := signAuth(t, authKeyA, delegate8037, 0) + pre := types.AddressToDelegation(common.HexToAddress("0xabcd")) + sdb := mkState(senderAlloc(types.GenesisAlloc{authority: {Code: pre}})) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != 0 { + t.Fatalf("state gas = %d, want 0 (account+indicator refilled)", gp.cumulativeState) + } +} + +// A net-new delegation on a fresh authority keeps the full worst-case charge. +func TestAuthSetNetNewNoRefill(t *testing.T) { + auth, _ := signAuth(t, authKeyA, delegate8037, 0) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != authWorstState { + t.Fatalf("state gas = %d, want %d (no refill)", gp.cumulativeState, authWorstState) + } +} + +// Clearing a delegation writes no indicator, so the indicator portion refills. +func TestAuthClearRefillBase(t *testing.T) { + auth, _ := signAuth(t, authKeyA, common.Address{}, 0) // clear (address ZERO) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{auth})) + if err != nil { + t.Fatal(err) + } + if want := uint64(newAccountState); gp.cumulativeState != want { + t.Fatalf("state gas = %d, want %d (indicator refilled)", gp.cumulativeState, want) + } +} + +// 0->a->0 in one tx: the indicator created by an earlier auth and cleared by a +// later one writes zero net bytes, so both indicator charges refill. +func TestAuthClearSameTxDoubleRefill(t *testing.T) { + set, authority := signAuth(t, authKeyA, delegate8037, 0) + clr, _ := signAuth(t, authKeyA, common.Address{}, 1) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{set, clr})) + if err != nil { + t.Fatal(err) + } + _ = authority + if want := uint64(newAccountState); gp.cumulativeState != want { + t.Fatalf("state gas = %d, want %d (net-zero delegation)", gp.cumulativeState, want) + } +} + +// The same authority across two auths is charged for its account only once. +func TestAuthDuplicateAuthorityOnce(t *testing.T) { + a0, _ := signAuth(t, authKeyA, delegate8037, 0) + a1, _ := signAuth(t, authKeyA, delegate8037, 1) + sdb := mkState(senderAlloc(nil)) + _, gp, err := applyMsg(t, sdb, setCodeTx(0, senderAddr, []types.SetCodeAuthorization{a0, a1})) + if err != nil { + t.Fatal(err) + } + if gp.cumulativeState != authWorstState { + t.Fatalf("state gas = %d, want %d (leaf+indicator once)", gp.cumulativeState, authWorstState) + } +} + +// ===================== System contracts / system calls =================== + +// System call gas limit keeps 30M regular plus a state reservoir for new slots. +func TestSystemCallGasLimit(t *testing.T) { + limit, budget := systemCallGasBudget(amsterdamCoreEVM(mkState(nil))) + if limit != 30_000_000 || budget.RegularGas != 30_000_000 { + t.Fatalf("limit/regular = %d/%d, want 30M/30M", limit, budget.RegularGas) + } +} + +// The extra system budget is placed in the state reservoir (16 new slots). +func TestSystemCallExtraInReservoir(t *testing.T) { + _, budget := systemCallGasBudget(amsterdamCoreEVM(mkState(nil))) + want := uint64(params.SystemMaxSStoresPerCall * params.CostPerStateByte * params.StorageCreationSize) + if budget.StateGas != want { + t.Fatalf("reservoir = %d, want %d", budget.StateGas, want) + } +} + +// System calls do not contribute to either block dimension: an empty block +// (whose system calls still write state) reports zero gas used. +func TestSystemCallNotCountedInBlock(t *testing.T) { + env := newBALTestEnv(nil) + engine := beacon.New(ethash.NewFaker()) + _, blocks, _ := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, b *BlockGen) {}) + if blocks[0].GasUsed() != 0 { + t.Fatalf("block gas used = %d, want 0 (system calls excluded)", blocks[0].GasUsed()) + } +} diff --git a/core/state_transition.go b/core/state_transition.go index dac8123530..bbeb163b16 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -674,11 +674,6 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { ret []byte vmerr error // vm errors do not effect consensus and are therefore not assigned to err result vm.GasBudget - - // Capture the forwarded regular-gas amount BEFORE ForwardAll consumes - // it, so Absorb can back out state-gas spillover from UsedRegularGas - // per EIP-8037. - forwarded = st.gasRemaining.RegularGas ) if contractCreation { // Check whether the init code size has been exceeded. @@ -686,12 +681,13 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { return nil, err } // Execute the transaction's creation. - ret, _, result, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) - st.gasRemaining.Absorb(result, forwarded) + var creation bool + ret, _, result, creation, vmerr = st.evm.Create(msg.From, msg.Data, st.gasRemaining.ForwardAll(), value) + st.gasRemaining.Absorb(result) - // If the contract creation failed, refund the account-creation state - // gas pre-charged in IntrinsicGas. - if rules.IsAmsterdam && vmerr != nil { + // If the contract creation failed, or the destination was pre-existing, + // refund the account-creation state gas pre-charged in IntrinsicGas. + if rules.IsAmsterdam && !creation { st.gasRemaining.RefundState(params.AccountCreationSize * st.evm.Context.CostPerStateByte) } } else { @@ -711,7 +707,7 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } // Execute the transaction's call. ret, result, vmerr = st.evm.Call(msg.From, st.to(), msg.Data, st.gasRemaining.ForwardAll(), value) - st.gasRemaining.Absorb(result, forwarded) + st.gasRemaining.Absorb(result) } // Settle down the gas usage and refund the ETH back if any remaining diff --git a/core/tracing/gen_gas_change_reason_stringer.go b/core/tracing/gen_gas_change_reason_stringer.go index 3d3aa96fad..e40f490051 100644 --- a/core/tracing/gen_gas_change_reason_stringer.go +++ b/core/tracing/gen_gas_change_reason_stringer.go @@ -28,17 +28,17 @@ func _() { _ = x[GasChangeWitnessCodeChunk-17] _ = x[GasChangeWitnessContractCollisionCheck-18] _ = x[GasChangeTxDataFloor-19] - _ = x[GasChangeAccountCreation-20] + _ = x[GasChangeRefundAccountCreation-20] _ = x[GasChangeIgnored-255] } const ( - _GasChangeReason_name_0 = "UnspecifiedTxInitialBalanceTxIntrinsicGasTxRefundsTxLeftOverReturnedCallInitialBalanceCallLeftOverReturnedCallLeftOverRefundedCallContractCreationCallContractCreation2CallCodeStorageCallOpCodeCallPrecompiledContractCallStorageColdAccessCallFailedExecutionWitnessContractInitWitnessContractCreationWitnessCodeChunkWitnessContractCollisionCheckTxDataFloorAccountCreation" + _GasChangeReason_name_0 = "UnspecifiedTxInitialBalanceTxIntrinsicGasTxRefundsTxLeftOverReturnedCallInitialBalanceCallLeftOverReturnedCallLeftOverRefundedCallContractCreationCallContractCreation2CallCodeStorageCallOpCodeCallPrecompiledContractCallStorageColdAccessCallFailedExecutionWitnessContractInitWitnessContractCreationWitnessCodeChunkWitnessContractCollisionCheckTxDataFloorRefundAccountCreation" _GasChangeReason_name_1 = "Ignored" ) var ( - _GasChangeReason_index_0 = [...]uint16{0, 11, 27, 41, 50, 68, 86, 106, 126, 146, 167, 182, 192, 215, 236, 255, 274, 297, 313, 342, 353, 368} + _GasChangeReason_index_0 = [...]uint16{0, 11, 27, 41, 50, 68, 86, 106, 126, 146, 167, 182, 192, 215, 236, 255, 274, 297, 313, 342, 353, 374} ) func (i GasChangeReason) String() string { diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go index 78b85fafea..667c6341b4 100644 --- a/core/tracing/hooks.go +++ b/core/tracing/hooks.go @@ -472,9 +472,9 @@ const ( // transaction data. This change will always be a negative change. GasChangeTxDataFloor GasChangeReason = 19 - // GasChangeAccountCreation represents the state gas charging for account - // creation inside the call/create frame. - GasChangeAccountCreation GasChangeReason = 20 + // GasChangeRefundAccountCreation represents the cancellation of a + // pre-charged account-creation cost when no account is created. + GasChangeRefundAccountCreation GasChangeReason = 20 // GasChangeIgnored is a special value that can be used to indicate that the gas change should be ignored as // it will be "manually" tracked by a direct emit of the gas change event. diff --git a/core/vm/contract.go b/core/vm/contract.go index 9155e9f84a..45dad42be0 100644 --- a/core/vm/contract.go +++ b/core/vm/contract.go @@ -152,10 +152,20 @@ func (c *Contract) chargeState(s uint64, logger *tracing.Hooks, reason tracing.G return true } -// refundGas absorbs a sub-call's leftover GasBudget into this contract's gas state. -func (c *Contract) refundGas(child GasBudget, forwarded uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) { +// refundState refunds the pre-charged state gas back to state reservoir. +func (c *Contract) refundState(s uint64, logger *tracing.Hooks, reason tracing.GasChangeReason) { prior := c.Gas - c.Gas.Absorb(child, forwarded) + c.Gas.RefundState(s) + + if s != 0 && logger.HasGasHook() && reason != tracing.GasChangeIgnored { + logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) + } +} + +// refundGas absorbs a sub-call's leftover GasBudget into this contract's gas state. +func (c *Contract) refundGas(child GasBudget, logger *tracing.Hooks, reason tracing.GasChangeReason) { + prior := c.Gas + c.Gas.Absorb(child) if logger.HasGasHook() && reason != tracing.GasChangeIgnored { logger.EmitGasChange(prior.AsTracing(), c.Gas.AsTracing(), reason) } diff --git a/core/vm/eip8037_test.go b/core/vm/eip8037_test.go new file mode 100644 index 0000000000..658096d228 --- /dev/null +++ b/core/vm/eip8037_test.go @@ -0,0 +1,739 @@ +// 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 . + +// Opcode-level tests for EIP-8037 (multidimensional state-gas metering). +// They drive a single frame via evm.Call and assert the state-gas accounting +// exposed by the returned GasBudget (UsedStateGas / StateGas / Spilled). + +package vm + +import ( + "math" + "math/big" + "math/rand" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/tracing" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +// state-gas charges in units (CPSB applied). +var ( + stateGasNewAccount = int64(params.AccountCreationSize * params.CostPerStateByte) // 183,600 + stateGasNewSlot = int64(params.StorageCreationSize * params.CostPerStateByte) // 97,920 +) + +// amsterdam8037Config clones MergedTestChainConfig with Amsterdam (EIP-8037) live. +func amsterdam8037Config() *params.ChainConfig { + cfg := *params.MergedTestChainConfig + cfg.AmsterdamTime = new(uint64) + blob := *cfg.BlobScheduleConfig + blob.Amsterdam = blob.Osaka + cfg.BlobScheduleConfig = &blob + return &cfg +} + +// amsterdam8037EVM builds an EVM with real value transfers and CPSB wired in. +func amsterdam8037EVM(statedb StateDB) *EVM { + ctx := BlockContext{ + CanTransfer: func(db StateDB, addr common.Address, amount *uint256.Int) bool { + return db.GetBalance(addr).Cmp(amount) >= 0 + }, + Transfer: func(db StateDB, sender, recipient common.Address, amount *uint256.Int, _ *params.Rules) { + db.SubBalance(sender, amount, tracing.BalanceChangeTransfer) + db.AddBalance(recipient, amount, tracing.BalanceChangeTransfer) + }, + BlockNumber: big.NewInt(0), + Random: &common.Hash{}, + CostPerStateByte: params.CostPerStateByte, + } + return NewEVM(ctx, statedb, amsterdam8037Config(), Config{}) +} + +// run8037 executes code at a contract address and returns the call's return +// data and the resulting budget. setup mutates the pre-state (before Finalise) +// and may fund the contract. +func run8037(t *testing.T, code []byte, gas GasBudget, value *uint256.Int, setup func(db *state.StateDB, self common.Address)) ([]byte, GasBudget, error) { + t.Helper() + self := common.BytesToAddress([]byte("self")) + statedb, _ := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + statedb.CreateAccount(self) + statedb.SetCode(self, code, tracing.CodeChangeUnspecified) + if setup != nil { + setup(statedb, self) + } + statedb.Finalise(true) + ret, result, err := amsterdam8037EVM(statedb).Call(common.Address{}, self, nil, gas, value) + assertBudgetSane(t, gas, result) + return ret, result, err +} + +// assertBudgetSane verifies the GasBudget conservation identities that must hold +// for any frame exit (success, revert or halt), validating the whole vector. +// +// regular: RegularGas + UsedRegularGas + Spilled == initial.RegularGas +// state: StateGas + UsedStateGas == initial.StateGas + Spilled +// scalar: Used(initial) == UsedRegularGas + UsedStateGas +func assertBudgetSane(t *testing.T, initial, got GasBudget) { + t.Helper() + if got.RegularGas+got.UsedRegularGas+got.Spilled != initial.RegularGas { + t.Fatalf("regular not conserved: R=%d usedR=%d spilled=%d, want sum %d", + got.RegularGas, got.UsedRegularGas, got.Spilled, initial.RegularGas) + } + if int64(got.StateGas)+got.UsedStateGas != int64(initial.StateGas)+int64(got.Spilled) { + t.Fatalf("state not conserved: S=%d usedS=%d spilled=%d, want %d+spilled", + got.StateGas, got.UsedStateGas, got.Spilled, initial.StateGas) + } + if int64(got.Used(initial)) != int64(got.UsedRegularGas)+got.UsedStateGas { + t.Fatalf("scalar mismatch: used=%d, usedR=%d usedS=%d", + got.Used(initial), got.UsedRegularGas, got.UsedStateGas) + } +} + +// hugeBudget is a budget that never runs out, with a separate state reservoir. +func hugeBudget() GasBudget { return NewGasBudget(math.MaxUint64/2, math.MaxUint64/2) } + +// sstore returns "PUSH val; PUSH slot; SSTORE" bytecode. +func sstore(slot, val byte) []byte { return []byte{0x60, val, 0x60, slot, 0x55} } + +// setSlot commits an original (pre-tx) value into a storage slot. +func setSlot(slot, val byte) func(*state.StateDB, common.Address) { + return func(db *state.StateDB, self common.Address) { + db.SetState(self, common.BytesToHash([]byte{slot}), common.BytesToHash([]byte{val})) + } +} + +// ============================ SSTORE state-gas ============================= + +// 0 -> 0 -> x: brand-new slot is charged one storage-creation. +func TestSStoreNewSlot(t *testing.T) { + _, res, err := run8037(t, sstore(0, 1), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewSlot { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateGasNewSlot) + } +} + +// 0 -> x -> 0: slot created then cleared in-tx, net charge refilled to zero. +func TestSStoreClearZeroAtStart(t *testing.T) { + code := append(sstore(0, 1), sstore(0, 0)...) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// x -> x -> 0: clearing a slot non-zero at tx start makes no state adjustment. +func TestSStoreClearOriginalNonzero(t *testing.T) { + _, res, err := run8037(t, sstore(0, 0), hugeBudget(), new(uint256.Int), setSlot(0, 1)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// x -> 0 -> x: clearing then restoring the original value makes no adjustment. +func TestSStoreRestoreOriginal(t *testing.T) { + code := append(sstore(0, 0), sstore(0, 1)...) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), setSlot(0, 1)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// x -> y: overwriting an existing slot with another value makes no adjustment. +func TestSStoreOtherWrite(t *testing.T) { + _, res, err := run8037(t, sstore(0, 2), hugeBudget(), new(uint256.Int), setSlot(0, 1)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// New-slot charge is metered at the opcode: with a reservoir smaller than the +// charge it spills into regular gas exactly at the SSTORE. +func TestSStoreChargedAtOpcodeEnd(t *testing.T) { + _, res, err := run8037(t, sstore(0, 1), NewGasBudget(1_000_000, 100), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := uint64(stateGasNewSlot) - 100; res.Spilled != want { + t.Fatalf("spilled = %d, want %d", res.Spilled, want) + } +} + +// The SSTORE reentrancy sentry checks gas_left only; the reservoir is excluded. +// Uses a noop write (1->1->1) so the sentry is the sole gate. +func TestSStoreStipendExcludesReservoir(t *testing.T) { + // regular at the sentry, huge reservoir: must still fail. + if _, _, err := run8037(t, sstore(0, 1), NewGasBudget(2306, math.MaxUint64/2), new(uint256.Int), setSlot(0, 1)); err == nil { + t.Fatal("expected sentry failure with regular gas at the limit") + } + // one more regular gas clears the sentry. + if _, _, err := run8037(t, sstore(0, 1), NewGasBudget(2307, math.MaxUint64/2), new(uint256.Int), setSlot(0, 1)); err != nil { + t.Fatalf("unexpected failure above sentry: %v", err) + } +} + +// ---- CALL / CREATE bytecode helpers ---- + +var ( + freshAddr = common.BytesToAddress([]byte("fresh-target")) + existAddr = common.BytesToAddress([]byte("exist-target")) + balanceAddr = common.BytesToAddress([]byte("balance-only")) + childAddr = common.BytesToAddress([]byte("child-frame")) + revertInit = []byte{0x60, 0x00, 0x60, 0x00, 0xfd} // PUSH1 0; PUSH1 0; REVERT + invalidInit = []byte{0xfe} // INVALID + deploy3Init = []byte{0x60, 0x03, 0x60, 0x00, 0xf3} // return 3 bytes of code + deploy0Init = []byte{0x60, 0x00, 0x60, 0x00, 0xf3} // return 0 bytes of code + stop = []byte{0x00} + revertTail = []byte{0x60, 0x00, 0x60, 0x00, 0xfd} + invalidTail = []byte{0xfe} + stateDeposit = int64(3 * params.CostPerStateByte) // 3-byte code deposit (4,590) +) + +// callCode builds bytecode that CALLs `to` forwarding `value` wei and all gas, +// followed by `tail`. +func callCode(to common.Address, value byte, tail []byte) []byte { + b := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, value, 0x73} + b = append(b, to.Bytes()...) + b = append(b, 0x5a, 0xf1) // GAS; CALL + return append(b, tail...) +} + +// deployCode builds bytecode that MSTOREs init and runs CREATE/CREATE2 with value. +func deployCode(init []byte, create2 bool, value byte) []byte { + word := make([]byte, 32) + copy(word[32-len(init):], init) + off, sz := byte(32-len(init)), byte(len(init)) + b := append([]byte{0x7f}, word...) // PUSH32 init-word + b = append(b, 0x60, 0x00, 0x52) // PUSH1 0; MSTORE + if create2 { + b = append(b, 0x60, 0x00, 0x60, sz, 0x60, off, 0x60, value, 0xf5) // salt,size,off,value; CREATE2 + } else { + b = append(b, 0x60, sz, 0x60, off, 0x60, value, 0xf0) // size,off,value; CREATE + } + return append(b, 0x00) // STOP +} + +func fund(addr common.Address, wei int64) func(*state.StateDB, common.Address) { + return func(db *state.StateDB, _ common.Address) { + db.AddBalance(addr, uint256.NewInt(uint64(wei)), tracing.BalanceChangeUnspecified) + } +} + +// ====================== CALL* new-account state-gas ======================= + +// CALL with value to a non-existent account charges one account creation. +func TestCallValueToNewAccount(t *testing.T) { + _, res, err := run8037(t, callCode(freshAddr, 1, stop), hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewAccount { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateGasNewAccount) + } +} + +// CALL with value to an existing (code-bearing) account is not charged. +func TestCallValueToExistingAccount(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.CreateAccount(existAddr) + db.SetCode(existAddr, stop, tracing.CodeChangeUnspecified) + db.AddBalance(self, uint256.NewInt(10), tracing.BalanceChangeUnspecified) + } + _, res, err := run8037(t, callCode(existAddr, 1, stop), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// CALL with zero value creates no account, so nothing is charged. +func TestCallZeroValueToNewAccount(t *testing.T) { + _, res, err := run8037(t, callCode(freshAddr, 0, stop), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// CALL that fails before the child frame (insufficient balance) refills the charge. +func TestCallInsufficientBalanceRefill(t *testing.T) { + // self has no balance, so the value transfer fails the CanTransfer check. + _, res, err := run8037(t, callCode(freshAddr, 1, stop), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// A new-account charge is refilled when its frame reverts. +func TestCallChildRevertRefill(t *testing.T) { + code := callCode(freshAddr, 1, revertTail) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err != ErrExecutionReverted { + t.Fatalf("err = %v, want revert", err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// A new-account charge is refilled when its frame halts exceptionally. +func TestCallChildExceptionalHaltRefill(t *testing.T) { + code := callCode(freshAddr, 1, invalidTail) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err == nil || err == ErrExecutionReverted { + t.Fatalf("err = %v, want exceptional halt", err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// An account with balance but no code/nonce is existent: no account charge. +func TestCallBalanceOnlyAccountIsExistent(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.AddBalance(balanceAddr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + db.AddBalance(self, uint256.NewInt(10), tracing.BalanceChangeUnspecified) + } + _, res, err := run8037(t, callCode(balanceAddr, 1, stop), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// ===================== CREATE / CREATE2 state-gas ========================= + +// CREATE to a fresh address charges account creation plus code deposit. +func TestCreateNewAccount(t *testing.T) { + _, res, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := stateGasNewAccount + stateDeposit; res.UsedStateGas != want { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, want) + } +} + +// CREATE onto a pre-existing (balance-only) leaf refills the account portion; +// only the code deposit is charged. +func TestCreatePreexistingTarget(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + derived := crypto.CreateAddress(self, db.GetNonce(self)) + db.AddBalance(derived, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + } + _, res, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateDeposit { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateDeposit) + } +} + +// CREATE whose init code reverts refills the account charge and deposits nothing. +func TestCreateInitRevertRefill(t *testing.T) { + _, res, err := run8037(t, deployCode(revertInit, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE whose init code halts exceptionally refills the account charge. +func TestCreateInitOOGRefill(t *testing.T) { + _, res, err := run8037(t, deployCode(invalidInit, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE onto an address collision (existing nonce) refills the account charge. +func TestCreateAddressCollisionRefill(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + derived := crypto.CreateAddress(self, db.GetNonce(self)) + db.SetNonce(derived, 1, tracing.NonceChangeUnspecified) + } + _, res, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE with value exceeding balance fails before the frame and is refilled. +func TestCreateInsufficientBalanceRefill(t *testing.T) { + // self has no balance; CREATE forwards value 1. + _, res, err := run8037(t, deployCode(deploy3Init, false, 1), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled)", res.UsedStateGas) + } +} + +// CREATE2 charges account creation plus code deposit identically to CREATE. +func TestCreate2SameSemantics(t *testing.T) { + _, res, err := run8037(t, deployCode(deploy3Init, true, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := stateGasNewAccount + stateDeposit; res.UsedStateGas != want { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, want) + } +} + +// The code-deposit portion is charged per byte independently of the account +// charge: the delta between a 3-byte and 0-byte deploy is exactly 3 x CPSB. +func TestCreateCodeDepositChargedSeparately(t *testing.T) { + _, big3, err := run8037(t, deployCode(deploy3Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + _, big0, err := run8037(t, deployCode(deploy0Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if got := big3.UsedStateGas - big0.UsedStateGas; got != stateDeposit { + t.Fatalf("deposit delta = %d, want %d", got, stateDeposit) + } +} + +// ========================= SELFDESTRUCT state-gas ========================= + +// selfdestruct sending balance to a non-existent beneficiary creates it. +func TestSelfdestructCreatesNewAccount(t *testing.T) { + code := append([]byte{0x73}, freshAddr.Bytes()...) // PUSH20 beneficiary + code = append(code, 0xff) // SELFDESTRUCT + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), fund(common.BytesToAddress([]byte("self")), 10)) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewAccount { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, stateGasNewAccount) + } +} + +// selfdestruct to an existing beneficiary creates no account. +func TestSelfdestructToExistingAccount(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.AddBalance(existAddr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + db.AddBalance(self, uint256.NewInt(10), tracing.BalanceChangeUnspecified) + } + code := append([]byte{0x73}, existAddr.Bytes()...) + code = append(code, 0xff) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// A contract created and self-destructed in the same tx gets no refill: the +// account-creation charge stands. +func TestSelfdestructSameTxAccountNoRefill(t *testing.T) { + // init code selfdestructs to self (existing), so only the create charges. + self := common.BytesToAddress([]byte("self")) + init := append([]byte{0x73}, self.Bytes()...) + init = append(init, 0xff) + _, res, err := run8037(t, deployCode(init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != stateGasNewAccount { + t.Fatalf("state gas = %d, want %d (no refill)", res.UsedStateGas, stateGasNewAccount) + } +} + +// selfdestruct of a pre-existing account refills nothing (EIP-6780: not removed). +func TestSelfdestructPreexistingNoRefill(t *testing.T) { + setup := func(db *state.StateDB, self common.Address) { + db.AddBalance(existAddr, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + } + code := append([]byte{0x73}, existAddr.Bytes()...) + code = append(code, 0xff) + _, res, err := run8037(t, code, hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0", res.UsedStateGas) + } +} + +// ===================== Reservoir / gas_left mechanics ===================== + +// State-gas is drawn from the reservoir first: a charge within reservoir size +// does not spill into regular gas. +func TestReservoirDrawnFirst(t *testing.T) { + _, res, err := run8037(t, sstore(0, 1), NewGasBudget(1_000_000, 200_000), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.Spilled != 0 { + t.Fatalf("spilled = %d, want 0", res.Spilled) + } + if want := uint64(200_000 - stateGasNewSlot); res.StateGas != want { + t.Fatalf("reservoir left = %d, want %d", res.StateGas, want) + } +} + +// The GAS opcode returns gas_left only, excluding the reservoir. +func TestGasOpcodeExcludesReservoir(t *testing.T) { + code := []byte{0x5a, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3} // GAS; MSTORE; RETURN(32) + ret, _, err := run8037(t, code, NewGasBudget(1_000_000, 500_000), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if got := new(uint256.Int).SetBytes(ret).Uint64(); got != 1_000_000-GasQuickStep { + t.Fatalf("GAS = %d, want %d (reservoir excluded)", got, 1_000_000-GasQuickStep) + } +} + +// Refills are LIFO: borrowed regular gas is repaid before the reservoir. With a +// zero reservoir, a 0->x->0 SSTORE repays the spill and leaves the reservoir at 0. +func TestLIFORefillOrder(t *testing.T) { + code := append(sstore(0, 1), sstore(0, 0)...) + _, res, err := run8037(t, code, NewGasBudget(1_000_000, 0), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if res.Spilled != 0 || res.StateGas != 0 || res.UsedStateGas != 0 { + t.Fatalf("after LIFO refill: spilled=%d reservoir=%d used=%d, want 0/0/0", res.Spilled, res.StateGas, res.UsedStateGas) + } +} + +// State-gas charged inside a child frame is refilled at the frame boundary when +// the child reverts or halts. +func TestStateGasMeteredAtFrameBoundary(t *testing.T) { + for _, tt := range []struct { + name string + tail []byte + }{ + {"revert", revertTail}, + {"halt", invalidTail}, + } { + t.Run(tt.name, func(t *testing.T) { + childCode := append(sstore(0, 1), tt.tail...) + setup := func(db *state.StateDB, self common.Address) { + db.CreateAccount(childAddr) + db.SetCode(childAddr, childCode, tracing.CodeChangeUnspecified) + } + _, res, err := run8037(t, callCode(childAddr, 0, stop), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedStateGas != 0 { + t.Fatalf("state gas = %d, want 0 (refilled at boundary)", res.UsedStateGas) + } + }) + } +} + +// ===================== LIFO refill vector invariant ========================= + +// Charge A then B (both spilling into regular because the reservoir is too +// small), then refill only A. The refill must repay the borrowed regular gas +// first (Spilled -> 0) before crediting the reservoir, leaving B outstanding. +func TestLIFORefillRepaysRegularBeforeReservoir(t *testing.T) { + initial := NewGasBudget(1000, 100) // reservoir covers only 100 of state gas + b := initial + + b.ChargeState(150) // A: 100 from reservoir, 50 spills into regular + b.ChargeState(30) // B: reservoir empty, all 30 spills + if b.Spilled != 80 || b.StateGas != 0 { + t.Fatalf("after A+B: spilled=%d reservoir=%d, want 80/0", b.Spilled, b.StateGas) + } + + b.RefundState(150) // refill A: repay 80 to regular first, 70 tops reservoir + if b.Spilled != 0 { + t.Fatalf("spilled=%d, want 0 (regular repaid before reservoir)", b.Spilled) + } + if b.StateGas != 70 { + t.Fatalf("reservoir=%d, want 70 (remainder after repaying regular)", b.StateGas) + } + assertBudgetSane(t, initial, b) +} + +// Fuzz arbitrary sequences of state/regular charges and LIFO refills around the +// reservoir/spill boundary: the GasBudget vector must stay self-consistent after +// every op and across all three frame-exit forms, and refilling every charge +// must restore the state side exactly (reservoir to initial, nothing borrowed). +func TestLIFOVectorInvariantUnderRandomOps(t *testing.T) { + rng := rand.New(rand.NewSource(8037)) + for trial := 0; trial < 2000; trial++ { + initial := NewGasBudget(1_000_000, uint64(rng.Intn(1000))) + b := initial + outstanding := int64(0) // state-gas charged but not yet refilled + for step := 0; step < 40; step++ { + switch rng.Intn(3) { + case 0: // state charge (may spill into regular) + if s := uint64(rng.Intn(400)); b.CanAfford(GasCosts{StateGas: s}) { + b.ChargeState(s) + outstanding += int64(s) + } + case 1: // regular charge + if r := uint64(rng.Intn(400)); b.CanAfford(GasCosts{RegularGas: r}) { + b.ChargeRegular(r) + } + case 2: // LIFO refill of part of the outstanding state gas + if outstanding > 0 { + s := uint64(rng.Int63n(outstanding) + 1) + b.RefundState(s) + outstanding -= int64(s) + } + } + assertBudgetSane(t, initial, b) + assertBudgetSane(t, initial, b.ExitSuccess()) + assertBudgetSane(t, initial, b.ExitRevert()) + assertBudgetSane(t, initial, b.ExitHalt()) + } + if outstanding > 0 { + b.RefundState(uint64(outstanding)) + } + if b.Spilled != 0 || b.StateGas != initial.StateGas || b.UsedStateGas != 0 { + t.Fatalf("trial %d: after full refill spilled=%d reservoir=%d used=%d, want 0/%d/0", + trial, b.Spilled, b.StateGas, b.UsedStateGas, initial.StateGas) + } + } +} + +// ================== Halting frame terminal state (nested) =================== + +func concat(parts ...[]byte) []byte { + var b []byte + for _, p := range parts { + b = append(b, p...) + } + return b +} + +// assertHalted checks the predictable terminal budget of an exceptionally +// halted frame: regular gas fully consumed, state restored to the frame's +// initial reservoir, and no net state-gas used. +func assertHalted(t *testing.T, initial, got GasBudget) { + t.Helper() + if got.RegularGas != 0 { + t.Fatalf("RegularGas = %d, want 0 (gas_left consumed on halt)", got.RegularGas) + } + if got.StateGas != initial.StateGas { + t.Fatalf("StateGas = %d, want %d (reservoir restored)", got.StateGas, initial.StateGas) + } + if got.UsedStateGas != 0 { + t.Fatalf("UsedStateGas = %d, want 0 (all refilled)", got.UsedStateGas) + } +} + +var ( + haltGrandchild = common.BytesToAddress([]byte("grandchild")) + haltOKChild = common.BytesToAddress([]byte("child-ok")) // succeeds, calls grandchild + haltBadChild = common.BytesToAddress([]byte("child-halt")) // SSTOREs then INVALID +) + +// haltFrameChildren is a run8037 setup that funds self and deploys a 3-level +// child set: a success child that itself calls a grandchild, and a halting child. +func haltFrameChildren(db *state.StateDB, self common.Address) { + db.AddBalance(self, uint256.NewInt(1000), tracing.BalanceChangeUnspecified) + db.CreateAccount(haltGrandchild) + db.SetCode(haltGrandchild, concat(sstore(5, 5), []byte{0x00}), tracing.CodeChangeUnspecified) // new slot; STOP + db.CreateAccount(haltOKChild) + db.SetCode(haltOKChild, concat(sstore(1, 1), callCode(haltGrandchild, 0, nil), []byte{0x00}), tracing.CodeChangeUnspecified) + db.CreateAccount(haltBadChild) + db.SetCode(haltBadChild, concat(sstore(3, 3), []byte{0xfe}), tracing.CodeChangeUnspecified) // new slot; INVALID +} + +// A frame that charges state, drives a successful child (with a grandchild), a +// halting child and a new-account call, then halts, returns the predictable +// terminal budget regardless of all the descendant activity. +func TestHaltFrameTerminalState(t *testing.T) { + top := concat( + sstore(0, 1), // self: new slot + callCode(haltOKChild, 0, nil), // child + grandchild succeed + callCode(haltBadChild, 0, nil), // descendant halts (contained) + callCode(freshAddr, 1, nil), // new-account charge + []byte{0xfe}, // this frame halts + ) + initial := NewGasBudget(2_000_000, 300_000) + _, res, err := run8037(t, top, initial, new(uint256.Int), haltFrameChildren) + if err == nil || err == ErrExecutionReverted { + t.Fatalf("err = %v, want exceptional halt", err) + } + assertHalted(t, initial, res) +} + +// Fuzz: arbitrary sequences of state writes, child calls (success / halting) and +// new-account calls, always terminated by INVALID. However the descendants +// behave, a halted frame's terminal budget is always (0, initial reservoir, 0). +func TestHaltFrameTerminalStateFuzz(t *testing.T) { + rng := rand.New(rand.NewSource(80371)) + for trial := 0; trial < 400; trial++ { + steps := [][]byte{ + sstore(byte(1+rng.Intn(20)), 1), + callCode(haltOKChild, 0, nil), + callCode(haltBadChild, 0, nil), + callCode(freshAddr, 1, nil), + } + var code []byte + for n := 1 + rng.Intn(8); n > 0; n-- { + code = append(code, steps[rng.Intn(len(steps))]...) + } + code = append(code, 0xfe) // halt + initial := NewGasBudget(3_000_000, uint64(rng.Intn(400_000))) + _, res, err := run8037(t, code, initial, new(uint256.Int), haltFrameChildren) + if err == nil || err == ErrExecutionReverted { + t.Fatalf("trial %d: err = %v, want halt", trial, err) + } + assertHalted(t, initial, res) + } +} diff --git a/core/vm/eips.go b/core/vm/eips.go index f8473e65e8..814e4be788 100644 --- a/core/vm/eips.go +++ b/core/vm/eips.go @@ -587,7 +587,10 @@ func enable7843(jt *JumpTable) { // enable8037 enables the multidimensional-metering as specified in EIP-8037. func enable8037(jt *JumpTable) { jt[CREATE].constantGas = params.CreateGasAmsterdam + jt[CREATE].dynamicGas = gasCreateEip8037 jt[CREATE2].constantGas = params.CreateGasAmsterdam + jt[CREATE2].dynamicGas = gasCreate2Eip8037 + jt[CALL].dynamicGas = gasCallEIP8037 jt[SELFDESTRUCT].dynamicGas = gasSelfdestruct8037 jt[SSTORE].dynamicGas = gasSStore8037 } diff --git a/core/vm/evm.go b/core/vm/evm.go index 50d9e8ab0c..15609a0205 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -265,7 +265,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g if !syscall && !value.IsZero() && !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() p, isPrecompile := evm.precompile(addr) if !evm.StateDB.Exist(addr) { if !isPrecompile && evm.chainRules.IsEIP4762 && !isSystemCall(caller) { @@ -279,7 +279,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g wgas := evm.AccessEvents.CodeHashGas(addr, true, gas.RegularGas, false) if _, ok := gas.ChargeRegular(wgas); !ok { evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, gas.ExitHalt(), ErrOutOfGas } } @@ -289,16 +289,6 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } evm.StateDB.CreateAccount(addr) } - if evm.chainRules.IsAmsterdam && !value.IsZero() && evm.StateDB.Empty(addr) { - prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) - if !ok { - evm.StateDB.RevertToSnapshot(snapshot) - return nil, gas.ExitHalt(reservoir), ErrOutOfGas - } - if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) - } - } // Perform the value transfer only in non-syscall mode. // Calling this is required even for zero-value transfers, // to ensure the state clearing mechanism is applied. @@ -324,7 +314,7 @@ func (evm *EVM) Call(caller common.Address, addr common.Address, input []byte, g } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -360,7 +350,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt if !evm.Context.CanTransfer(evm.StateDB, caller, value) { return nil, gas, ErrInsufficientBalance } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -375,7 +365,7 @@ func (evm *EVM) CallCode(caller common.Address, addr common.Address, input []byt } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -406,7 +396,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, if evm.depth > int(params.CallCreateDepth) { return nil, gas, ErrDepth } - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // It is allowed to call precompiles, even via delegatecall if p, isPrecompile := evm.precompile(addr); isPrecompile { @@ -419,7 +409,7 @@ func (evm *EVM) DelegateCall(originCaller common.Address, caller common.Address, } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) @@ -453,7 +443,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b // after all empty accounts were deleted, so this is not required. However, if we omit this, // then certain tests start failing; stRevertTest/RevertPrecompiledTouchExactOOG.json. // We could change this, but for now it's left for legacy reasons - snapshot, reservoir := evm.StateDB.Snapshot(), gas.StateGas + snapshot := evm.StateDB.Snapshot() // We do an AddBalance of zero here, just in order to trigger a touch. // This doesn't matter on Mainnet, where all empties are gone at the time of Byzantium, @@ -471,7 +461,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b } // Calculate the remaining gas at the end of frame - exitGas := gas.Exit(err, reservoir) + exitGas := gas.Exit(err) if err != nil { evm.StateDB.RevertToSnapshot(snapshot) if err != ErrExecutionReverted { @@ -484,7 +474,7 @@ func (evm *EVM) StaticCall(caller common.Address, addr common.Address, input []b } // create creates a new contract using code as deployment code. -func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, result GasBudget, err error) { +func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int, address common.Address, typ OpCode) (ret []byte, createAddress common.Address, result GasBudget, creation bool, err error) { // Depth check execution. Fail if we're trying to execute above the // limit. var nonce uint64 @@ -505,18 +495,17 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value }(gas) } if err != nil { - return nil, common.Address{}, gas, err + return nil, common.Address{}, gas, false, err } // Increment the caller's nonce after passing all validations evm.StateDB.SetNonce(caller, nonce+1, tracing.NonceChangeContractCreator) - reservoir := gas.StateGas // Charge the contract creation init gas in verkle mode if evm.chainRules.IsEIP4762 { statelessGas := evm.AccessEvents.ContractCreatePreCheckGas(address, gas.RegularGas) prior, ok := gas.Charge(GasCosts{RegularGas: statelessGas}) if !ok { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), false, ErrOutOfGas } if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(prior.AsTracing(), gas.AsTracing(), tracing.GasChangeWitnessContractCollisionCheck) @@ -537,13 +526,13 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != types.EmptyCodeHash) || // non-empty code isEIP7610RejectedAccount(evm.ChainConfig().ChainID, address, evm.chainRules.IsEIP158) { - halt := gas.ExitHalt(reservoir) + halt := gas.ExitHalt() if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(gas.AsTracing(), halt.AsTracing(), tracing.GasChangeCallFailedExecution) } // EIP-8037 collision rule: the state reservoir is fully preserved on // address collision while regular gas is burnt. - return nil, common.Address{}, halt, ErrContractAddressCollision + return nil, common.Address{}, halt, false, ErrContractAddressCollision } // Create a new account on the state only if the object was not present. // It might be possible the contract code is deployed to a pre-existent @@ -551,18 +540,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value snapshot := evm.StateDB.Snapshot() if !evm.StateDB.Exist(address) { evm.StateDB.CreateAccount(address) - - if evm.chainRules.IsAmsterdam && evm.depth > 0 { - // Only charge state gas if we are not doing a create transaction. - // Prevents double charging with IntrinsicGas. - prev, ok := gas.ChargeState(params.AccountCreationSize * evm.Context.CostPerStateByte) - if !ok { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas - } - if evm.Config.Tracer.HasGasHook() { - evm.Config.Tracer.EmitGasChange(prev.AsTracing(), gas.AsTracing(), tracing.GasChangeAccountCreation) - } - } + creation = true } // CreateContract means that regardless of whether the account previously existed // in the state trie or not, it _now_ becomes created as a _contract_ account. @@ -577,7 +555,7 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if evm.chainRules.IsEIP4762 { consumed, wanted := evm.AccessEvents.ContractCreateInitGas(address, gas.RegularGas) if consumed < wanted { - return nil, common.Address{}, gas.ExitHalt(reservoir), ErrOutOfGas + return nil, common.Address{}, gas.ExitHalt(), false, ErrOutOfGas } prior, _ := gas.Charge(GasCosts{RegularGas: consumed}) if evm.Config.Tracer.HasGasHook() { @@ -602,17 +580,17 @@ func (evm *EVM) create(caller common.Address, code []byte, gas GasBudget, value if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) { evm.StateDB.RevertToSnapshot(snapshot) - exit := contract.Gas.Exit(err, reservoir) + exit := contract.Gas.Exit(err) if err != ErrExecutionReverted { if evm.Config.Tracer.HasGasHook() { evm.Config.Tracer.EmitGasChange(contract.Gas.AsTracing(), exit.AsTracing(), tracing.GasChangeCallFailedExecution) } } - return ret, address, exit, err + return ret, address, exit, false, err } // Either success, or pre-Homestead ErrCodeStoreOutOfGas (gas preserved). // Both packaged as a success-form GasBudget. - return ret, address, contract.Gas.ExitSuccess(), err + return ret, address, contract.Gas.ExitSuccess(), creation, err } // initNewContract runs a new contract's creation code, performs checks on the @@ -668,7 +646,7 @@ func (evm *EVM) initNewContract(contract *Contract, address common.Address) ([]b } // Create creates a new contract using code as deployment code. -func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, err error) { +func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, creation bool, err error) { contractAddr = crypto.CreateAddress(caller, evm.StateDB.GetNonce(caller)) return evm.create(caller, code, gas, value, contractAddr, CREATE) } @@ -677,7 +655,7 @@ func (evm *EVM) Create(caller common.Address, code []byte, gas GasBudget, value // // The different between Create2 with Create is Create2 uses keccak256(0xff ++ msg.sender ++ salt ++ keccak256(init_code))[12:] // instead of the usual sender-and-nonce-hash as the address where the contract is initialized at. -func (evm *EVM) Create2(caller common.Address, code []byte, gas GasBudget, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, err error) { +func (evm *EVM) Create2(caller common.Address, code []byte, gas GasBudget, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, result GasBudget, creation bool, err error) { inithash := crypto.Keccak256Hash(code) contractAddr = crypto.CreateAddress2(caller, salt.Bytes32(), inithash[:]) return evm.create(caller, code, gas, endowment, contractAddr, CREATE2) diff --git a/core/vm/gas_table.go b/core/vm/gas_table.go index 550375c9c0..4bd971e711 100644 --- a/core/vm/gas_table.go +++ b/core/vm/gas_table.go @@ -519,6 +519,117 @@ func gasSelfdestruct(evm *EVM, contract *Contract, stack *Stack, mem *Memory, me return GasCosts{RegularGas: gas}, nil } +func gasCreateEip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + gas, err := memoryGasCost(mem, memorySize) + if err != nil { + return GasCosts{}, err + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow + words := (size + 31) / 32 + wordGas := params.InitCodeWordGas * words + + // Unconditionally pre-charge the account creation and refunds if the creation + // doesn't happen after the create-frame. + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: params.AccountCreationSize * evm.Context.CostPerStateByte, + }, nil +} + +func gasCreate2Eip8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + if evm.readOnly { + return GasCosts{}, ErrWriteProtection + } + gas, err := memoryGasCost(mem, memorySize) + if err != nil { + return GasCosts{}, err + } + size, overflow := stack.back(2).Uint64WithOverflow() + if overflow { + return GasCosts{}, ErrGasUintOverflow + } + if err := CheckMaxInitCodeSize(&evm.chainRules, size); err != nil { + return GasCosts{}, err + } + // Since size <= MaxInitCodeSizeAmsterdam, these multiplications cannot overflow + words := (size + 31) / 32 + + // CREATE2 charges both InitCodeWordGas (EIP-3860) and Keccak256WordGas + // (for address hashing). + wordGas := (params.InitCodeWordGas + params.Keccak256WordGas) * words + + // Unconditionally pre-charge the account creation and refunds if the creation + // doesn't happen after the create-frame. + return GasCosts{ + RegularGas: gas + wordGas, + StateGas: params.AccountCreationSize * evm.Context.CostPerStateByte, + }, nil +} + +// regularGasCall8037 is the intrinsic gas calculator for CALL in Amsterdam. +// It computes memory expansion + value transfer gas but excludes new account +// creation, which is handled as state gas by the wrapper. +func regularGasCall8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (uint64, error) { + var ( + gas uint64 + transfersValue = !stack.back(2).IsZero() + ) + if evm.readOnly && transfersValue { + return 0, ErrWriteProtection + } + memoryGas, err := memoryGasCost(mem, memorySize) + if err != nil { + return 0, err + } + var transferGas uint64 + if transfersValue && !evm.chainRules.IsEIP4762 { + transferGas = params.CallValueTransferGas + } + var overflow bool + if gas, overflow = math.SafeAdd(memoryGas, transferGas); overflow { + return 0, ErrGasUintOverflow + } + return gas, nil +} + +// stateGasCall8037 is the stateful gas calculator for CALL in Amsterdam (EIP-8037). +// It only returns the state-dependent gas (account creation as state gas). +// Memory gas, transfer gas, and callGas are handled by gasCallStateless and +// makeCallVariantGasCall. +func stateGasCall8037(evm *EVM, contract *Contract, stack *Stack) (uint64, error) { + var ( + gas uint64 + transfersValue = !stack.back(2).IsZero() + address = common.Address(stack.back(1).Bytes20()) + ) + // TODO(rjl, marius), can EIP8037 implicitly means the EIP158 is also activated? + // It's technically possible to skip the EIP158 but very unlikely in practice. + if evm.chainRules.IsEIP158 { + // Important: use StateDB.Empty instead of !StateDB.Exist. An account may exist + // in the current state yet still be considered non-existent by EIP-161 if its + // nonce, balance, and code are all zero. Such accounts can appear temporarily + // during execution (e.g. via SELFDESTRUCT) and are removed at tx end. + // + // Funding such an account makes it permanent state growth and must be charged. + if transfersValue && evm.StateDB.Empty(address) { + gas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + } else if !evm.StateDB.Exist(address) { + gas += params.AccountCreationSize * evm.Context.CostPerStateByte + } + return gas, nil +} + func gasSelfdestruct8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { if evm.readOnly { return GasCosts{}, ErrWriteProtection diff --git a/core/vm/gascosts.go b/core/vm/gascosts.go index b1756ab5fe..1a8efe3aac 100644 --- a/core/vm/gascosts.go +++ b/core/vm/gascosts.go @@ -65,6 +65,11 @@ type GasBudget struct { StateGas uint64 // remaining state-gas reservoir (or leftover for caller to absorb) UsedRegularGas uint64 // gross regular gas consumed in this frame UsedStateGas int64 // signed net state-gas consumed in this frame + + // Spilled tracks how much of this frame's regular gas (gas_left) + // has been borrowed to cover state-gas charges that exceeded the + // reservoir. + Spilled uint64 } // NewGasBudget initializes a fresh GasBudget for execution / forwarding, @@ -82,7 +87,7 @@ func (g GasBudget) Used(initial GasBudget) uint64 { // String returns a visual representation of the budget. func (g GasBudget) String() string { - return fmt.Sprintf("<%v,%v,used=<%v,%v>>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas) + return fmt.Sprintf("<%v,%v,used=<%v,%v>,borrowed=%v>", g.RegularGas, g.StateGas, g.UsedRegularGas, g.UsedStateGas, g.Spilled) } // CanAfford reports whether the running balance can cover the given cost. @@ -117,6 +122,10 @@ func (g *GasBudget) Charge(cost GasCosts) (GasBudget, bool) { spillover := cost.StateGas - g.StateGas g.StateGas = 0 g.RegularGas -= spillover + + // Record the regular gas borrowed to cover the overflowing state gas + // so a later inline refund can repay it before topping up the reservoir. + g.Spilled += spillover } else { g.StateGas -= cost.StateGas } @@ -146,14 +155,27 @@ func (g *GasBudget) IsZero() bool { } // RefundState applies an inline state-gas refund (e.g., SSTORE 0->A->0). -// The reservoir is credited and the signed usage counter is decremented -// in lockstep, preserving the per-frame invariant: // -// StateGas + UsedStateGas == initialStateGas + spillover_so_far +// Per EIP-8037, the refund repays the regular gas previously borrowed for +// state-gas spillover (tracked by Spilled) before crediting the +// reservoir: it is returned to RegularGas up to the outstanding borrowed +// amount, and only the remainder tops up StateGas. This keeps the regular and +// state pools from drifting into one another. // -// which the revert path relies on for the correct gross refund. +// The signed usage counter is decremented by the full refund regardless of the +// split, preserving the per-frame invariant: +// +// StateGas + UsedStateGas == initialStateGas + Spilled +// +// which the revert and halt paths rely on for the correct gross refund. func (g *GasBudget) RefundState(s uint64) { - g.StateGas += s + // Repay the borrowed regular gas first, capped at the outstanding amount. + repay := min(s, g.Spilled) + g.RegularGas += repay + g.Spilled -= repay + + // Whatever is left tops up the reservoir. + g.StateGas += s - repay g.UsedStateGas -= int64(s) } @@ -201,53 +223,46 @@ func (g GasBudget) ExitSuccess() GasBudget { return g } -// ExitRevert produces the leftover for a REVERT exit. Per EIP-8037, all state -// gas charged by the reverted frame is refunded to the caller's reservoir: -// -// leftover.StateGas = StateGas + UsedStateGas -// -// UsedStateGas is reset since the frame's state changes are discarded. +// ExitRevert produces the leftover for a REVERT exit. The frame's state +// changes are discarded, so all state gas it charged is refilled to its origin +// (EIP-8037): up to Spilled is returned to RegularGas (the regular +// gas it borrowed), and the remainder restores the reservoir. Because the +// borrowed regular gas is repaid first, the reservoir is made whole back to its +// start-of-frame value: func (g GasBudget) ExitRevert() GasBudget { - reservoir := int64(g.StateGas) + g.UsedStateGas + reservoir := int64(g.StateGas) + g.UsedStateGas - int64(g.Spilled) if reservoir < 0 { // Reservoir should never be negative. By construction it equals - // the initial state-gas allocation plus any spillover to regular - // gas. + // the initial state-gas allocation. reservoir = 0 - log.Warn("Negative reservoir at revert", "remaining", g.StateGas, "used", g.UsedStateGas) + log.Warn("Negative reservoir at revert", "remaining", g.StateGas, "used", g.UsedStateGas, "borrowed", g.Spilled) } return GasBudget{ - RegularGas: g.RegularGas, + RegularGas: g.RegularGas + g.Spilled, StateGas: uint64(reservoir), UsedRegularGas: g.UsedRegularGas, UsedStateGas: 0, } } -// ExitHalt produces the leftover for an exceptional halt. -// -// - state_gas_reservoir is reset back to its value at the start of the child frame -// - the gas_left initially given to the child is consumed (set to zero) -func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { - reservoir := int64(g.StateGas) + g.UsedStateGas +// ExitHalt produces the leftover for an exceptional halt. As with a revert, the +// frame's state changes are rolled back and its state gas is refilled to origin +// (EIP-8037); the difference is that the frame's gas_left is consumed rather +// than returned. The portion refilled to RegularGas is therefore burned along +// with the rest of gas_left, leaving only the reservoir portion to survive, +// which equals the reservoir's value at the start of the frame. +func (g GasBudget) ExitHalt() GasBudget { + reservoir := int64(g.StateGas) + g.UsedStateGas - int64(g.Spilled) if reservoir < 0 { // Reservoir should never be negative. By construction it equals - // the initial state-gas allocation plus any spillover to regular - // gas. + // the initial state-gas allocation. reservoir = 0 - log.Warn("Negative reservoir at halt", "remaining", g.StateGas, "used", g.UsedStateGas) - } - // The portion of state gas charged from regular gas is also burned - // together with the regular gas, rather than being returned to the - // parent's state-gas reservoir. - var spilled uint64 - if uint64(reservoir) > initStateReservoir { - spilled = uint64(reservoir) - initStateReservoir + log.Warn("Negative reservoir at halt", "remaining", g.StateGas, "used", g.UsedStateGas, "borrowed", g.Spilled) } return GasBudget{ RegularGas: 0, - StateGas: initStateReservoir, - UsedRegularGas: g.UsedRegularGas + g.RegularGas + spilled, + StateGas: uint64(reservoir), + UsedRegularGas: g.UsedRegularGas + g.RegularGas + g.Spilled, UsedStateGas: 0, } } @@ -258,17 +273,14 @@ func (g GasBudget) ExitHalt(initStateReservoir uint64) GasBudget { // - err == nil → ExitSuccess // - err == ErrExecutionReverted → ExitRevert // - any other err → ExitHalt -// -// Soft validation failures (occurring BEFORE evm.Run) should call Preserved -// directly instead of going through this dispatcher. -func (g GasBudget) Exit(err error, initStateReservoir uint64) GasBudget { +func (g GasBudget) Exit(err error) GasBudget { switch { case err == nil: return g.ExitSuccess() case err == ErrExecutionReverted: return g.ExitRevert() default: - return g.ExitHalt(initStateReservoir) + return g.ExitHalt() } } @@ -276,18 +288,12 @@ func (g GasBudget) Exit(err error, initStateReservoir uint64) GasBudget { // budget. Additionally, it does an EIP-8037 spillover correction: // state-gas that spilled into the regular pool inside the child frame is // excluded from the UsedRegularGas. -// -// spillover = forwarded - child.RegularGas - child.UsedRegularGas -// -// forwarded is the regular-gas amount that was passed to the child at call -// entry (i.e., the regular initial of the child's GasBudget). -func (g *GasBudget) Absorb(child GasBudget, forwarded uint64) { - spillover := forwarded - child.RegularGas - child.UsedRegularGas - +func (g *GasBudget) Absorb(child GasBudget) { g.UsedRegularGas -= child.RegularGas g.RegularGas += child.RegularGas g.StateGas = child.StateGas g.UsedStateGas += child.UsedStateGas - g.UsedRegularGas -= spillover + g.UsedRegularGas -= child.Spilled + g.Spilled += child.Spilled } diff --git a/core/vm/instructions.go b/core/vm/instructions.go index 209457f670..b20cce4c0d 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -646,7 +646,7 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { stackvalue := size child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation) - res, addr, result, suberr := evm.Create(scope.Contract.Address(), input, child, &value) + res, addr, result, creation, suberr := evm.Create(scope.Contract.Address(), input, child, &value) // Push item on the stack based on the returned error. If the ruleset is // homestead we must check for CodeStoreOutOfGasError (homestead only // rule) and treat as an error, if the ruleset is frontier we must @@ -661,8 +661,12 @@ func opCreate(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(&stackvalue) // Refund the leftover gas back to current frame - scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // Refund the state gas of account-creation if creation doesn't happen + if evm.GetRules().IsAmsterdam && !creation { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeRefundAccountCreation) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer return res, nil @@ -685,7 +689,7 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { // reuse size int for stackvalue stackvalue := size child := scope.Contract.forwardGas(forward, evm.Config.Tracer, tracing.GasChangeCallContractCreation2) - res, addr, result, suberr := evm.Create2(scope.Contract.Address(), input, child, &endowment, &salt) + res, addr, result, creation, suberr := evm.Create2(scope.Contract.Address(), input, child, &endowment, &salt) // Push item on the stack based on the returned error. if suberr != nil { stackvalue.Clear() @@ -695,8 +699,12 @@ func opCreate2(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Stack.push(&stackvalue) // Refund the leftover gas back to current frame - scope.Contract.refundGas(result, forward, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // Refund the state gas of account-creation if creation doesn't happen + if evm.GetRules().IsAmsterdam && !creation { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeRefundAccountCreation) + } if suberr == ErrExecutionReverted { evm.returnData = res // set REVERT data to return data buffer return res, nil @@ -740,8 +748,13 @@ func opCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + // If the call frame reverts or halts exceptionally, the charged state-gas + // is refilled back to the state reservoir in Amsterdam. + if evm.chainRules.IsAmsterdam && err != nil && !value.IsZero() && evm.StateDB.Empty(toAddr) { + scope.Contract.refundState(params.AccountCreationSize*evm.Context.CostPerStateByte, evm.Config.Tracer, tracing.GasChangeRefundAccountCreation) + } evm.returnData = ret return ret, nil } @@ -776,7 +789,7 @@ func opCallCode(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -808,7 +821,7 @@ func opDelegateCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { if err == nil || err == ErrExecutionReverted { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil @@ -841,7 +854,7 @@ func opStaticCall(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) { scope.Memory.Set(retOffset.Uint64(), retSize.Uint64(), ret) } - scope.Contract.refundGas(result, gas, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) + scope.Contract.refundGas(result, evm.Config.Tracer, tracing.GasChangeCallLeftOverRefunded) evm.returnData = ret return ret, nil diff --git a/core/vm/jump_table.go b/core/vm/jump_table.go index 9ea8349e3a..5cc5e34ced 100644 --- a/core/vm/jump_table.go +++ b/core/vm/jump_table.go @@ -28,6 +28,9 @@ type ( intrinsicGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) // last parameter is the requested memory size as a uint64 // memorySizeFunc returns the required size, and whether the operation overflowed a uint64 memorySizeFunc func(*Stack) (size uint64, overflow bool) + + regularGasFunc func(*EVM, *Contract, *Stack, *Memory, uint64) (uint64, error) + stateGasFunc func(*EVM, *Contract, *Stack) (uint64, error) ) type operation struct { diff --git a/core/vm/operations_acl.go b/core/vm/operations_acl.go index 2206fb95fa..8b2a627fef 100644 --- a/core/vm/operations_acl.go +++ b/core/vm/operations_acl.go @@ -262,6 +262,7 @@ var ( gasDelegateCallEIP7702 = makeCallVariantGasCallEIP7702(gasDelegateCallIntrinsic) gasStaticCallEIP7702 = makeCallVariantGasCallEIP7702(gasStaticCallIntrinsic) gasCallCodeEIP7702 = makeCallVariantGasCallEIP7702(gasCallCodeIntrinsic) + innerGasCallEIP8037 = makeCallVariantGasCallEIP8037(regularGasCall8037, stateGasCall8037) ) func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { @@ -276,6 +277,14 @@ func gasCallEIP7702(evm *EVM, contract *Contract, stack *Stack, mem *Memory, mem return innerGasCallEIP7702(evm, contract, stack, mem, memorySize) } +func gasCallEIP8037(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + transfersValue := !stack.back(2).IsZero() + if evm.readOnly && transfersValue { + return GasCosts{}, ErrWriteProtection + } + return innerGasCallEIP8037(evm, contract, stack, mem, memorySize) +} + func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { var ( @@ -362,3 +371,89 @@ func makeCallVariantGasCallEIP7702(intrinsicFunc intrinsicGasFunc) gasFunc { return GasCosts{RegularGas: totalCost}, nil } } + +// makeCallVariantGasCallEIP8037 creates a call gas function for Amsterdam (EIP-8037). +// It extends the EIP-7702 pattern with state gas handling and GasUsed tracking. +// intrinsicFunc computes the regular gas (memory + transfer, no new account creation). +// stateGasFunc computes the state gas (new account creation as state gas). +func makeCallVariantGasCallEIP8037(regularFunc regularGasFunc, stateGasFunc stateGasFunc) gasFunc { + return func(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64) (GasCosts, error) { + var ( + eip2929Cost uint64 + eip7702Cost uint64 + addr = common.Address(stack.back(1).Bytes20()) + ) + // EIP-2929 cold access check. + if !evm.StateDB.AddressInAccessList(addr) { + evm.StateDB.AddAddressToAccessList(addr) + eip2929Cost = params.ColdAccountAccessCostEIP2929 - params.WarmStorageReadCostEIP2929 + if !contract.chargeRegular(eip2929Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + return GasCosts{}, ErrOutOfGas + } + } + + // Compute regular cost (memory + transfer, no new account creation). + regularCost, err := regularFunc(evm, contract, stack, mem, memorySize) + if err != nil { + return GasCosts{}, err + } + + // Charge intrinsic cost directly (regular gas). This must happen + // BEFORE state gas to prevent reservoir inflation, and also serves + // as the OOG guard before stateful operations. + if !contract.chargeRegular(regularCost, evm.Config.Tracer, tracing.GasChangeCallOpCode) { + return GasCosts{}, ErrOutOfGas + } + + // EIP-7702 delegation check. + if target, ok := types.ParseDelegation(evm.StateDB.GetCode(addr)); ok { + if evm.StateDB.AddressInAccessList(target) { + eip7702Cost = params.WarmStorageReadCostEIP2929 + } else { + evm.StateDB.AddAddressToAccessList(target) + eip7702Cost = params.ColdAccountAccessCostEIP2929 + } + if !contract.chargeRegular(eip7702Cost, evm.Config.Tracer, tracing.GasChangeCallStorageColdAccess) { + return GasCosts{}, ErrOutOfGas + } + } + + // Compute and charge state gas (new account creation) AFTER regular gas. + stateGas, err := stateGasFunc(evm, contract, stack) + if err != nil { + return GasCosts{}, err + } + if stateGas > 0 { + if _, ok := contract.Gas.ChargeState(stateGas); !ok { + return GasCosts{}, ErrOutOfGas + } + } + + // Calculate the gas budget for the nested call (63/64 rule). + evm.callGasTemp, err = callGas(evm.chainRules.IsEIP150, contract.Gas.RegularGas, 0, stack.back(0)) + if err != nil { + return GasCosts{}, err + } + + // Temporarily undo direct regular charges for tracer reporting. + // The interpreter will charge the returned totalCost. + contract.Gas.RegularGas += eip2929Cost + eip7702Cost + regularCost + contract.Gas.UsedRegularGas -= eip2929Cost + eip7702Cost + regularCost + + // Aggregate total cost. + var ( + overflow bool + totalCost uint64 + ) + if totalCost, overflow = math.SafeAdd(eip2929Cost, eip7702Cost); overflow { + return GasCosts{}, ErrGasUintOverflow + } + if totalCost, overflow = math.SafeAdd(totalCost, regularCost); overflow { + return GasCosts{}, ErrGasUintOverflow + } + if totalCost, overflow = math.SafeAdd(totalCost, evm.callGasTemp); overflow { + return GasCosts{}, ErrGasUintOverflow + } + return GasCosts{RegularGas: totalCost}, nil + } +} diff --git a/core/vm/runtime/runtime.go b/core/vm/runtime/runtime.go index 34dec1bd4a..9a15f7ac96 100644 --- a/core/vm/runtime/runtime.go +++ b/core/vm/runtime/runtime.go @@ -179,7 +179,7 @@ func Create(input []byte, cfg *Config) ([]byte, common.Address, uint64, error) { // - reset transient storage(eip 1153) cfg.State.Prepare(rules, cfg.Origin, cfg.Coinbase, nil, vm.ActivePrecompiles(rules), nil) // Call the code with the given configuration. - code, address, result, err := vmenv.Create( + code, address, result, _, err := vmenv.Create( cfg.Origin, input, vm.NewGasBudget(cfg.GasLimit, 0),