From 19dfe36291124499471b38e07efec82466402d82 Mon Sep 17 00:00:00 2001 From: Gary Rong Date: Wed, 17 Jun 2026 16:10:43 +0800 Subject: [PATCH] core: add tests --- core/eip8037_test.go | 614 +++++++++++++++++++++++++++++++++ core/vm/eip8037_test.go | 739 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1353 insertions(+) create mode 100644 core/eip8037_test.go create mode 100644 core/vm/eip8037_test.go 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/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) + } +}