diff --git a/core/eip2780_test.go b/core/eip2780_test.go index cee5b3f685..7b421b1fb4 100644 --- a/core/eip2780_test.go +++ b/core/eip2780_test.go @@ -17,10 +17,12 @@ package core import ( + "errors" "math/big" "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/crypto" @@ -109,6 +111,44 @@ func TestEIP2780Intrinsic(t *testing.T) { } } +// TestEIP2780Boundary distinguishes the state-independent intrinsic validity +// threshold from a valid transaction which later runs out of runtime gas. +func TestEIP2780Boundary(t *testing.T) { + auth, _ := signAuth(t, authKeyA, delegate8037, 0) + to := common.HexToAddress("0xe0a0000000000000000000000000000000000008") + cases := []struct { + name string + tx func(uint64) *types.Transaction + }{ + {"call", func(gas uint64) *types.Transaction { return callTx(0, to, 0, gas, nil) }}, + {"value", func(gas uint64) *types.Transaction { return callTx(0, to, 1, gas, nil) }}, + {"create", func(gas uint64) *types.Transaction { return createTx(0, gas, nil) }}, + {"auth", func(gas uint64) *types.Transaction { + return setCodeTxGas(0, to, 0, gas, []types.SetCodeAuthorization{auth}) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg, err := TransactionToMessage(tc.tx(1_000_000), signer8037, big.NewInt(0)) + if err != nil { + t.Fatal(err) + } + intrinsic, err := IntrinsicGas(msg.Data, msg.AccessList, msg.SetCodeAuthorizations, msg.From, msg.To, msg.Value, rules8037) + if err != nil { + t.Fatal(err) + } + res, _, err := applyMsg(t, mkState(senderAlloc(nil)), tc.tx(intrinsic-1)) + if res != nil || !errors.Is(err, ErrIntrinsicGas) { + t.Fatalf("below intrinsic: result=%v err=%v, want ErrIntrinsicGas", res, err) + } + res, _, err = applyMsg(t, mkState(senderAlloc(nil)), tc.tx(intrinsic)) + if err != nil || res == nil { + t.Fatalf("at intrinsic must be included: result=%v err=%v", res, err) + } + }) + } +} + // TestEIP2780Gas checks every "Transaction reference case" in // the EIP-2780 specification end-to-end, asserting the two-dimensional charge // (intrinsic + top-level + execution) recorded in the block gas pool. @@ -194,6 +234,32 @@ func callTxAL(nonce uint64, to common.Address, value int64, gas uint64, al types }) } +func setCodeTxGas(nonce uint64, to common.Address, value, gas uint64, auths []types.SetCodeAuthorization) *types.Transaction { + return setCodeTxGasAL(nonce, to, value, gas, nil, auths) +} + +func setCodeTxGasAL(nonce uint64, to common.Address, value, gas uint64, al types.AccessList, auths []types.SetCodeAuthorization) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.SetCodeTx{ + ChainID: uint256.MustFromBig(cfg8037.ChainID), Nonce: nonce, To: to, + Value: uint256.NewInt(value), Gas: gas, AccessList: al, + GasFeeCap: new(uint256.Int), GasTipCap: new(uint256.Int), AuthList: auths, + }) +} + +func applyMsgCoinbase(t *testing.T, sdb *state.StateDB, tx *types.Transaction, coinbase common.Address) (*ExecutionResult, *GasPool, error) { + t.Helper() + evm := amsterdamCoreEVM(sdb) + evm.Context.Coinbase = coinbase + msg, err := TransactionToMessage(tx, signer8037, evm.Context.BaseFee) + if err != nil { + t.Fatalf("to message: %v", err) + } + gp := NewGasPool(evm.Context.GasLimit) + evm.SetTxContext(NewEVMTxContext(msg)) + res, err := newStateTransition(evm, msg, gp).execute() + return res, gp, err +} + // accessListEntryCost is the total intrinsic cost of one address-only access // list entry: the EIP-8038 per-address charge plus the EIP-7981 data charge. const accessListEntryCost = params.TxAccessListAddressGasAmsterdam + @@ -332,6 +398,71 @@ func TestEIP2780RuntimeOOGRevertsDelegations(t *testing.T) { } } +// TestEIP2780RecipientOOG verifies that an OOG recipient charge rolls back a +// delegation which was successfully installed earlier in the same transaction. +func TestEIP2780RecipientOOG(t *testing.T) { + auth, authority := signAuth(t, authKeyA, delegate8037, 0) + recipient := common.HexToAddress("0xbeef000000000000000000000000000000000004") + intrinsic := params.TxBaseCost2780 + params.ColdAccountAccessAmsterdam + + params.TxValueCost2780 + params.TransferLogCost2780 + params.RegularPerAuthBaseCost + // The reservoir case needs a near-cap intrinsic cost. This leaves just + // enough total budget for the authorization but not for the recipient leaf. + const ( + regularLeft = 100_000 + reservoir = 200_000 + ) + al := types.AccessList{{Address: common.HexToAddress("0xa1")}} + baseIntrinsic, err := IntrinsicGas(nil, al, []types.SetCodeAuthorization{auth}, senderAddr, &recipient, uint256.NewInt(1), rules8037) + if err != nil { + t.Fatal(err) + } + perKey := params.TxAccessListStorageKeyGasAmsterdam + uint64(common.HashLength)*params.TxCostFloorPerToken7976*params.TxTokenPerNonZeroByte + al[0].StorageKeys = make([]common.Hash, (params.MaxTxGas-regularLeft-baseIntrinsic)/perKey) + alIntrinsic, err := IntrinsicGas(nil, al, []types.SetCodeAuthorization{auth}, senderAddr, &recipient, uint256.NewInt(1), rules8037) + if err != nil { + t.Fatal(err) + } + if available := params.MaxTxGas - alIntrinsic + reservoir; available < params.AccountWriteAmsterdam+authWorstState || available >= params.AccountWriteAmsterdam+authWorstState+newAccountState { + t.Fatalf("setup: available runtime gas %d does not isolate recipient charge", available) + } + cases := []struct { + name string + tx *types.Transaction + want uint64 + }{ + // This exactly pays the first authorization, leaving no gas for the + // fresh recipient's account-leaf charge. + {"no-reservoir", setCodeTxGas(0, recipient, 1, intrinsic+params.AccountWriteAmsterdam+authWorstState, []types.SetCodeAuthorization{auth}), intrinsic + params.AccountWriteAmsterdam + authWorstState}, + // The state reservoir is restored by the halt; only the capped regular + // dimension is burnt. + {"with-reservoir", setCodeTxGasAL(0, recipient, 1, params.MaxTxGas+reservoir, al, []types.SetCodeAuthorization{auth}), params.MaxTxGas}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + res, gp, err := applyMsg(t, sdb, tc.tx) + if err != nil { + t.Fatalf("transaction should remain valid: %v", err) + } + if res.Err != vm.ErrOutOfGas { + t.Fatalf("execution error = %v, want out of gas", res.Err) + } + if code := sdb.GetCode(authority); len(code) != 0 || sdb.GetNonce(authority) != 0 { + t.Fatalf("authorization persisted after recipient OOG: code=%x nonce=%d", code, sdb.GetNonce(authority)) + } + if sdb.Exist(recipient) { + t.Fatal("recipient created despite an unpaid runtime charge") + } + if sdb.GetNonce(senderAddr) != 1 { + t.Fatal("sender nonce not consumed") + } + if res.UsedGas != tc.want || gp.cumulativeState != 0 || gp.cumulativeRegular != tc.want { + t.Fatalf("used/gas = %d/<%d,%d>, want %d/<%d,0>", res.UsedGas, gp.cumulativeRegular, gp.cumulativeState, tc.want, tc.want) + } + }) + } +} + // TestEIP2780SelfTransferDelegated verifies that a self-transfer incurs no // recipient touch or value charges, while resolving the sender's own // delegation is still paid for. @@ -400,6 +531,242 @@ func TestEIP2780InsufficientGasForCallCharge(t *testing.T) { } } +// TestEIP2780RecipientKinds covers EIP-161 and precompile distinctions which +// are invisible to the state-independent intrinsic charge. +func TestEIP2780RecipientKinds(t *testing.T) { + const ( + base = params.TxBaseCost2780 + cold = params.ColdAccountAccessAmsterdam + valueCst = params.TxValueCost2780 + params.TransferLogCost2780 + ) + nonceOnly := common.HexToAddress("0xbeef000000000000000000000000000000000005") + precompile := common.BytesToAddress([]byte{4}) // identity; 15 gas for empty input + cases := []struct { + name string + alloc types.GenesisAlloc + tx *types.Transaction + wantRegular, wantState uint64 + }{ + { + name: "nonce-only", + alloc: types.GenesisAlloc{nonceOnly: {Nonce: 1}}, + tx: callTx(0, nonceOnly, 1, 100_000, nil), + wantRegular: base + cold + valueCst, + }, + { + name: "precompile/zero", + tx: callTx(0, precompile, 0, 100_000, nil), + wantRegular: base + cold + 15, + }, + { + name: "precompile/value", + tx: callTx(0, precompile, 1, 300_000, nil), + wantRegular: base + cold + valueCst + 15, + wantState: newAccountState, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, gp, err := applyMsg(t, mkState(senderAlloc(tc.alloc)), tc.tx) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + if gp.cumulativeRegular != tc.wantRegular || gp.cumulativeState != tc.wantState { + t.Fatalf("gas = <%d,%d>, want <%d,%d>", gp.cumulativeRegular, gp.cumulativeState, tc.wantRegular, tc.wantState) + } + }) + } +} + +// TestEIP2780RecipientRefill covers the empty-precompile path: the account +// leaf is charged before dispatch, then refilled when the top frame halts. +func TestEIP2780RecipientRefill(t *testing.T) { + // The pairing precompile rejects this malformed input after the recipient's + // account-leaf charge. The excess over MaxTxGas is a state reservoir. + recipient := common.BytesToAddress([]byte{8}) + gas := params.MaxTxGas + newAccountState + 50_000 + sdb := mkState(senderAlloc(nil)) + res, gp, err := applyMsg(t, sdb, callTx(0, recipient, 1, gas, []byte{0})) + if err != nil || res.Err == nil { + t.Fatalf("result=%v err=%v, want exceptional halt", res, err) + } + if gp.cumulativeState != 0 || gp.cumulativeRegular != params.MaxTxGas { + t.Fatalf("gas = <%d,%d>, want <%d,0> after refill", gp.cumulativeRegular, gp.cumulativeState, params.MaxTxGas) + } + if sdb.Exist(recipient) { + t.Fatal("empty recipient persisted after halted dispatch") + } +} + +// TestEIP2780Coinbase keeps the intrinsic recipient charge separate from the +// runtime warmth of the coinbase account: calling the coinbase directly is +// still charged at the cold rate. The warm rate for a coinbase delegation +// target is covered by TestEIP2780DelegationWarmth. +func TestEIP2780Coinbase(t *testing.T) { + coinbase := common.HexToAddress("0xc01ba5e000000000000000000000000000000001") + res, gp, err := applyMsgCoinbase(t, mkState(senderAlloc(nil)), callTx(0, coinbase, 0, 100_000, nil), coinbase) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + if want := params.TxBaseCost2780 + params.ColdAccountAccessAmsterdam; gp.cumulativeRegular != want { + t.Fatalf("regular gas = %d, want %d", gp.cumulativeRegular, want) + } +} + +// TestEIP2780DelegationWarmth adds the special targets which are warm before +// top-level dispatch but are not access-list entries. +func TestEIP2780DelegationWarmth(t *testing.T) { + const ( + base = params.TxBaseCost2780 + cold = params.ColdAccountAccessAmsterdam + warm = params.WarmAccountAccessAmsterdam + ) + recipient := common.HexToAddress("0xde1e000000000000000000000000000000000008") + precompile := common.BytesToAddress([]byte{4}) + cases := []struct { + name string + target common.Address + coinbase common.Address + wantRegular uint64 + }{ + {"precompile", precompile, common.Address{}, base + cold + warm}, + {"coinbase", common.HexToAddress("0xc01ba5e000000000000000000000000000000002"), common.HexToAddress("0xc01ba5e000000000000000000000000000000002"), base + cold + warm}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sdb := mkState(senderAlloc(types.GenesisAlloc{ + recipient: {Code: types.AddressToDelegation(tc.target)}, + })) + res, gp, err := applyMsgCoinbase(t, sdb, callTx(0, recipient, 0, 100_000, nil), tc.coinbase) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + if gp.cumulativeRegular != tc.wantRegular { + t.Fatalf("regular gas = %d, want %d", gp.cumulativeRegular, tc.wantRegular) + } + }) + } + // A delegation designator pointing to itself eventually faults as bytecode, + // so pin just the pre-frame recipient-target charge rather than the later + // frame result. The recipient was warmed as tx.to before this charge. + sdb := mkState(senderAlloc(types.GenesisAlloc{ + recipient: {Code: types.AddressToDelegation(recipient)}, + })) + to := recipient + st := newStateTransition(amsterdamCoreEVM(sdb), &Message{To: &to, Value: new(uint256.Int)}, NewGasPool(100_000)) + st.gasRemaining = vm.NewGasBudget(1_000, 0) + sdb.AddAddressToAccessList(recipient) + if !st.chargeCallRecipientEIP2780(new(uint256.Int)) || st.gasRemaining.UsedRegularGas != warm { + t.Fatalf("recipient target charge = %d, want warm %d", st.gasRemaining.UsedRegularGas, warm) + } +} + +// TestEIP2780InstallDispatch covers an authority installed during the +// pre-frame authorization pass and dispatched to by that same transaction. +func TestEIP2780InstallDispatch(t *testing.T) { + const ( + base = params.TxBaseCost2780 + cold = params.ColdAccountAccessAmsterdam + perAuth = params.RegularPerAuthBaseCost + valueCst = params.TxValueCost2780 + params.TransferLogCost2780 + ) + auth, authority := signAuth(t, authKeyA, delegate8037, 0) + senderAuth, err := types.SignSetCode(senderKey, types.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(cfg8037.ChainID), Address: delegate8037, Nonce: 1, + }) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + alloc types.GenesisAlloc + tx *types.Transaction + account common.Address + wantRegular, wantState uint64 + wantNonce uint64 + wantBalance *big.Int + }{ + { + name: "sender", + tx: setCodeTxGas(0, senderAddr, 0, 1_000_000, []types.SetCodeAuthorization{senderAuth}), + account: senderAddr, + wantRegular: base + perAuth + cold, + wantState: authBaseState, + wantNonce: 2, + }, + { + name: "fresh-recipient", + tx: setCodeTxGas(0, authority, 1, 1_000_000, []types.SetCodeAuthorization{auth}), + account: authority, + wantRegular: base + cold + valueCst + perAuth + cold, + wantState: authWorstState, + wantNonce: 1, + wantBalance: big.NewInt(1), + }, + { + name: "funded-recipient", + alloc: types.GenesisAlloc{authority: {Balance: big.NewInt(3)}}, + tx: setCodeTxGas(0, authority, 1, 1_000_000, []types.SetCodeAuthorization{auth}), + account: authority, + wantRegular: base + cold + valueCst + perAuth + cold, + wantState: authBaseState, + wantNonce: 1, + wantBalance: big.NewInt(4), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sdb := mkState(senderAlloc(tc.alloc)) + res, gp, err := applyMsg(t, sdb, tc.tx) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + if _, delegated := types.ParseDelegation(sdb.GetCode(tc.account)); !delegated || sdb.GetNonce(tc.account) != tc.wantNonce { + t.Fatalf("delegation/nonce = %x/%d, want delegation/%d", sdb.GetCode(tc.account), sdb.GetNonce(tc.account), tc.wantNonce) + } + if tc.wantBalance != nil && sdb.GetBalance(tc.account).Cmp(uint256.MustFromBig(tc.wantBalance)) != 0 { + t.Fatalf("balance = %v, want %v", sdb.GetBalance(tc.account), tc.wantBalance) + } + if gp.cumulativeRegular != tc.wantRegular || gp.cumulativeState != tc.wantState { + t.Fatalf("gas = <%d,%d>, want <%d,%d>", gp.cumulativeRegular, gp.cumulativeState, tc.wantRegular, tc.wantState) + } + }) + } +} + +// TestEIP2780Floor keeps the EIP-8037 calldata floor in the regular dimension +// when a top-level EIP-2780 account-leaf charge is also present. +func TestEIP2780Floor(t *testing.T) { + recipient := common.HexToAddress("0xbeef000000000000000000000000000000000007") + data := make([]byte, 1_000) + tx := callTx(0, recipient, 1, 300_000, data) + res, gp, err := applyMsg(t, mkState(senderAlloc(nil)), tx) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + floor, err := FloorDataGas(rules8037, senderAddr, &recipient, uint256.NewInt(1), data, nil) + if err != nil { + t.Fatal(err) + } + intrinsic, err := IntrinsicGas(data, nil, nil, senderAddr, &recipient, uint256.NewInt(1), rules8037) + if err != nil { + t.Fatal(err) + } + stateGas := newAccountState + // This is the v7.2.0 boundary: the floor lifts only the regular + // dimension, while the scalar receipt gas remains the actual intrinsic + + // state charge because it is already above the floor. + if !(intrinsic < floor && floor < intrinsic+stateGas) { + t.Fatalf("expected intrinsic < floor < intrinsic + state: %d < %d < %d", intrinsic, floor, intrinsic+stateGas) + } + if gp.cumulativeRegular != floor || gp.cumulativeState != stateGas { + t.Fatalf("gas = <%d,%d>, want floor/state <%d,%d>", gp.cumulativeRegular, gp.cumulativeState, floor, stateGas) + } + if want := intrinsic + stateGas; res.UsedGas != want { + t.Fatalf("receipt gas = %d, want intrinsic + state = %d", res.UsedGas, want) + } +} + // TestEIP2780FirstFrameHaltPreservesPreExecution verifies the gas and state // semantics when the top-most frame — message call or creation — halts // exceptionally after the pre-execution phase completed: diff --git a/core/eip7708_test.go b/core/eip7708_test.go new file mode 100644 index 0000000000..47199631b3 --- /dev/null +++ b/core/eip7708_test.go @@ -0,0 +1,413 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package core + +import ( + "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/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/holiman/uint256" +) + +// EIP-7708 transfer-log tests. The older TestEthTransferLogs keeps its +// historical end-to-end Solidity fixture; these tests use small bytecode +// programs to pin the normative edge cases independently. + +func transferLogs7708(sdb *state.StateDB) []*types.Log { + return sdb.GetLogs(common.Hash{}, 0, common.Hash{}, 0) +} + +func assertTransfer7708(t *testing.T, log *types.Log, from, to common.Address, value uint64) { + t.Helper() + want := types.EthTransferLog(from, to, uint256.NewInt(value)) + if log.Address != want.Address || len(log.Topics) != 3 || log.Topics[0] != want.Topics[0] || + log.Topics[1] != want.Topics[1] || log.Topics[2] != want.Topics[2] || string(log.Data) != string(want.Data) { + t.Fatalf("transfer log = %+v, want %+v", log, want) + } +} + +func callCode7708(target common.Address, value byte, suffix []byte) []byte { + return callOpcode7708(target, value, 0xf1, suffix) +} + +func callOpcode7708(target common.Address, value, opcode byte, suffix []byte) []byte { + // Optional LOG0, then CALL(gas=0xffff, to=target, value=value, empty input + // and output), discard the success flag, and run suffix. + code := []byte{0x60, 0x00, 0x60, 0x00, 0xa0} + code = append(code, + 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, // output/input sizes and offsets + 0x60, value, 0x73) + code = append(code, target.Bytes()...) + code = append(code, 0x61, 0xff, 0xff, opcode, 0x50) + return append(code, suffix...) +} + +func delegateCode7708(target common.Address) []byte { + // DELEGATECALL(gas=0xffff, to=target, empty input and output), followed by + // STOP. Value is inherited from the outer call's executing context. + code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x73} + code = append(code, target.Bytes()...) + return append(code, 0x61, 0xff, 0xff, 0xf4, 0x50, 0x00) +} + +func createTx7708(value uint64, initcode []byte) *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.DynamicFeeTx{ + ChainID: cfg8037.ChainID, Nonce: 0, Value: new(big.Int).SetUint64(value), + Gas: 500_000, GasFeeCap: new(big.Int), GasTipCap: new(big.Int), Data: initcode, + }) +} + +// TestEIP7708Transactions covers ordinary transaction value transfers across +// the principal transaction encodings, including a delegated recipient. +func TestEIP7708Transactions(t *testing.T) { + recipient := common.HexToAddress("0x7708000000000000000000000000000000000001") + auth, _ := signAuth(t, authKeyA, delegate8037, 0) + cases := []struct { + name string + tx func() *types.Transaction + }{ + { + "legacy", + func() *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.LegacyTx{ + Nonce: 0, To: &recipient, Value: big.NewInt(1), Gas: 100_000, GasPrice: new(big.Int), + }) + }, + }, + { + "access-list", + func() *types.Transaction { + return types.MustSignNewTx(senderKey, signer8037, &types.AccessListTx{ + ChainID: cfg8037.ChainID, Nonce: 0, To: &recipient, Value: big.NewInt(1), Gas: 100_000, GasPrice: new(big.Int), + }) + }, + }, + {"dynamic", func() *types.Transaction { return callTx(0, recipient, 1, 100_000, nil) }}, + {"set-code", func() *types.Transaction { + return setCodeTxGas(0, recipient, 1, 1_000_000, []types.SetCodeAuthorization{auth}) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sdb := mkState(senderAlloc(types.GenesisAlloc{recipient: {Balance: big.NewInt(1)}})) + res, _, err := applyMsg(t, sdb, tc.tx()) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("logs = %+v, want one", logs) + } + assertTransfer7708(t, logs[0], senderAddr, recipient, 1) + }) + } + + delegated := common.HexToAddress("0x7708000000000000000000000000000000000002") + sdb := mkState(senderAlloc(types.GenesisAlloc{ + delegated: {Code: types.AddressToDelegation(delegate8037)}, + delegate8037: {Code: []byte{0x00}}, + })) + res, _, err := applyMsg(t, sdb, callTx(0, delegated, 1, 100_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("delegated result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("delegated logs = %+v, want one", logs) + } + // The transfer is to the delegation account, never to its implementation. + assertTransfer7708(t, logs[0], senderAddr, delegated, 1) +} + +// TestEIP7708Special covers recipients that are frequently warmed or handled +// specially by the EVM, and checks that fee recipients do not produce logs. +func TestEIP7708Special(t *testing.T) { + precompile := common.BytesToAddress([]byte{4}) + system := params.SystemAddress + coinbase := common.HexToAddress("0x7708000000000000000000000000000000000003") + cases := []struct { + name string + to common.Address + coinbase bool + }{ + {"precompile", precompile, false}, + {"system", system, false}, + {"coinbase", coinbase, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + tx := callTx(0, tc.to, 1, 300_000, nil) + var ( + res *ExecutionResult + err error + ) + if tc.coinbase { + res, _, err = applyMsgCoinbase(t, sdb, tx, coinbase) + } else { + res, _, err = applyMsg(t, sdb, tx) + } + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("logs = %+v, want one transfer only", logs) + } + assertTransfer7708(t, logs[0], senderAddr, tc.to, 1) + }) + } +} + +// TestEIP7708Calls checks ordering, attribution, and rollback for value CALLs. +func TestEIP7708Calls(t *testing.T) { + caller := common.HexToAddress("0x7708000000000000000000000000000000000010") + callee := common.HexToAddress("0x7708000000000000000000000000000000000011") + reverter := common.HexToAddress("0x7708000000000000000000000000000000000012") + + t.Run("order", func(t *testing.T) { + sdb := mkState(senderAlloc(types.GenesisAlloc{ + caller: {Code: callCode7708(callee, 3, []byte{0x00})}, + callee: {Code: []byte{0x00}}, + })) + res, _, err := applyMsg(t, sdb, callTx(0, caller, 10, 300_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 3 { + t.Fatalf("logs = %+v, want tx transfer, LOG0, inner transfer", logs) + } + assertTransfer7708(t, logs[0], senderAddr, caller, 10) + + // Ordinary log emitted by contract + if logs[1].Address != caller || len(logs[1].Topics) != 0 || len(logs[1].Data) != 0 { + t.Fatalf("ordinary log = %+v, want caller LOG0", logs[1]) + } + assertTransfer7708(t, logs[2], caller, callee, 3) + }) + + t.Run("inner-revert", func(t *testing.T) { + sdb := mkState(senderAlloc(types.GenesisAlloc{ + caller: {Code: callCode7708(reverter, 3, []byte{0x00})}, + reverter: {Code: []byte{0x60, 0x00, 0x60, 0x00, 0xfd}}, + })) + res, _, err := applyMsg(t, sdb, callTx(0, caller, 10, 300_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 2 { // top-level transfer and the caller's LOG0 + t.Fatalf("logs = %+v, want no rolled-back inner transfer", logs) + } + assertTransfer7708(t, logs[0], senderAddr, caller, 10) + }) + + t.Run("outer-revert", func(t *testing.T) { + sdb := mkState(senderAlloc(types.GenesisAlloc{ + caller: {Code: callCode7708(callee, 3, []byte{0x60, 0x00, 0x60, 0x00, 0xfd})}, + callee: {Code: []byte{0x00}}, + })) + res, _, err := applyMsg(t, sdb, callTx(0, caller, 10, 300_000, nil)) + if err != nil || res.Err != vm.ErrExecutionReverted { + t.Fatalf("result=%v err=%v, want execution revert", res, err) + } + if logs := transferLogs7708(sdb); len(logs) != 0 { + t.Fatalf("reverted transaction retained logs: %+v", logs) + } + }) + + t.Run("callcode", func(t *testing.T) { + sdb := mkState(senderAlloc(types.GenesisAlloc{ + caller: {Code: callOpcode7708(callee, 3, 0xf2, []byte{0x00})}, + callee: {Code: []byte{0x00}}, + })) + res, _, err := applyMsg(t, sdb, callTx(0, caller, 10, 300_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 2 { + t.Fatalf("logs = %+v, want only top-level transfer and LOG0", logs) + } + assertTransfer7708(t, logs[0], senderAddr, caller, 10) + }) + + t.Run("delegatecall", func(t *testing.T) { + implementation := common.HexToAddress("0x7708000000000000000000000000000000000013") + sdb := mkState(senderAlloc(types.GenesisAlloc{ + caller: {Code: delegateCode7708(implementation)}, + implementation: {Code: callCode7708(callee, 3, []byte{0x00})}, + callee: {Code: []byte{0x00}}, + })) + res, _, err := applyMsg(t, sdb, callTx(0, caller, 10, 300_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 3 { + t.Fatalf("logs = %+v, want top-level transfer, delegated LOG0, inner transfer", logs) + } + assertTransfer7708(t, logs[0], senderAddr, caller, 10) + if logs[1].Address != caller { + t.Fatalf("delegated LOG0 address = %s, want executing context %s", logs[1].Address, caller) + } + assertTransfer7708(t, logs[2], caller, callee, 3) + }) +} + +// TestEIP7708Create checks successful CREATE/CREATE2 endowments and that +// failing initcode rolls the transfer log back with the failed creation. +func TestEIP7708Create(t *testing.T) { + t.Run("transaction", func(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + res, _, err := applyMsg(t, sdb, createTx7708(5, []byte{0x00})) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("logs = %+v, want one", logs) + } + assertTransfer7708(t, logs[0], senderAddr, crypto.CreateAddress(senderAddr, 0), 5) + }) + + t.Run("revert", func(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + res, _, err := applyMsg(t, sdb, createTx7708(5, []byte{0x60, 0x00, 0x60, 0x00, 0xfd})) + if err != nil || res.Err != vm.ErrExecutionReverted { + t.Fatalf("result=%v err=%v, want execution revert", res, err) + } + if logs := transferLogs7708(sdb); len(logs) != 0 { + t.Fatalf("failed create retained logs: %+v", logs) + } + }) + + t.Run("create2", func(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + evm := amsterdamCoreEVM(sdb) + _, created, _, err := evm.Create2(senderAddr, []byte{0x00}, vm.NewGasBudget(500_000, 0), uint256.NewInt(5), new(uint256.Int)) + if err != nil { + t.Fatal(err) + } + logs := transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("logs = %+v, want one", logs) + } + assertTransfer7708(t, logs[0], senderAddr, created, 5) + }) +} + +// TestEIP7708Selfdestruct verifies the EIP-8246-compatible SELFDESTRUCT +// cases: different beneficiaries transfer and log, self beneficiaries do not. +func TestEIP7708Selfdestruct(t *testing.T) { + contract := common.HexToAddress("0x7708000000000000000000000000000000000020") + beneficiary := common.HexToAddress("0x7708000000000000000000000000000000000021") + code := append([]byte{0x73}, beneficiary.Bytes()...) + code = append(code, 0xff) + sdb := mkState(senderAlloc(types.GenesisAlloc{contract: {Code: code}})) + res, _, err := applyMsg(t, sdb, callTx(0, contract, 7, 300_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + logs := transferLogs7708(sdb) + if len(logs) != 2 { + t.Fatalf("logs = %+v, want transaction and selfdestruct transfers", logs) + } + assertTransfer7708(t, logs[0], senderAddr, contract, 7) + assertTransfer7708(t, logs[1], contract, beneficiary, 7) + + selfCode := append([]byte{0x73}, contract.Bytes()...) + selfCode = append(selfCode, 0xff) + sdb = mkState(senderAlloc(types.GenesisAlloc{contract: {Code: selfCode}})) + res, _, err = applyMsg(t, sdb, callTx(0, contract, 7, 300_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("self result=%v err=%v", res, err) + } + logs = transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("selfdestruct-to-self logs = %+v, want top-level transfer only", logs) + } + assertTransfer7708(t, logs[0], senderAddr, contract, 7) +} + +// TestEIP7708Negative and TestEIP7708Transition pin the no-log cases and the +// activation guard independently of transaction construction. +func TestEIP7708Negative(t *testing.T) { + recipient := common.HexToAddress("0x7708000000000000000000000000000000000030") + for _, tc := range []struct { + name string + to common.Address + value int64 + }{ + {"zero", recipient, 0}, + {"self", senderAddr, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + sdb := mkState(senderAlloc(nil)) + res, _, err := applyMsg(t, sdb, callTx(0, tc.to, tc.value, 100_000, nil)) + if err != nil || res.Err != nil { + t.Fatalf("result=%v err=%v", res, err) + } + if logs := transferLogs7708(sdb); len(logs) != 0 { + t.Fatalf("unexpected logs: %+v", logs) + } + }) + } +} + +func TestEIP7708Transition(t *testing.T) { + from := common.HexToAddress("0x7708000000000000000000000000000000000040") + to := common.HexToAddress("0x7708000000000000000000000000000000000041") + sdb := mkState(types.GenesisAlloc{from: {Balance: big.NewInt(2)}}) + Transfer(sdb, from, to, uint256.NewInt(1), ¶ms.Rules{}) + if logs := transferLogs7708(sdb); len(logs) != 0 { + t.Fatalf("pre-Amsterdam logs = %+v, want none", logs) + } + Transfer(sdb, from, to, uint256.NewInt(1), &rules8037) + logs := transferLogs7708(sdb) + if len(logs) != 1 { + t.Fatalf("Amsterdam logs = %+v, want one", logs) + } + assertTransfer7708(t, logs[0], from, to, 1) +} + +// TestEIP7708Fees ensures a priority-fee credit to coinbase remains outside +// EIP-7708: the transaction value transfer is the receipt's only log. +func TestEIP7708Fees(t *testing.T) { + env := newBALTestEnv(nil) + coinbase := common.HexToAddress("0x7708000000000000000000000000000000000050") + recipient := common.HexToAddress("0x7708000000000000000000000000000000000051") + engine := beacon.New(ethash.NewFaker()) + _, _, receipts := GenerateChainWithGenesis(env.gspec, engine, 1, func(_ int, g *BlockGen) { + g.SetCoinbase(coinbase) + g.AddTx(env.tx(0, &recipient, big.NewInt(1), txGasNewAccount, 1, nil)) + }) + logs := receipts[0][0].Logs + if len(logs) != 1 { + t.Fatalf("logs = %+v, want transaction transfer only", logs) + } + assertTransfer7708(t, logs[0], env.from, recipient, 1) +} diff --git a/core/bal_test.go b/core/eip7928_test.go similarity index 63% rename from core/bal_test.go rename to core/eip7928_test.go index 64c2714b16..816161a34f 100644 --- a/core/bal_test.go +++ b/core/eip7928_test.go @@ -152,6 +152,52 @@ func assertEmpty(t *testing.T, aa *bal.AccountAccess) { } } +type balStorageChangeExpectation struct { + index uint32 + value common.Hash +} + +func assertStorageChanges(t *testing.T, aa *bal.AccountAccess, key common.Hash, want []balStorageChangeExpectation) { + t.Helper() + wantKey := new(uint256.Int).SetBytes(key[:]) + for _, slot := range aa.StorageChanges { + if slot.Slot.Cmp(wantKey) != 0 { + continue + } + if len(slot.SlotChanges) != len(want) { + t.Fatalf("slot %x changes: have %+v, want %d entries", key, slot.SlotChanges, len(want)) + } + for i, expected := range want { + if slot.SlotChanges[i].BlockAccessIndex != expected.index { + t.Fatalf("slot %x change %d index: have %d, want %d", key, i, slot.SlotChanges[i].BlockAccessIndex, expected.index) + } + wantValue := new(uint256.Int).SetBytes(expected.value[:]) + if slot.SlotChanges[i].PostValue.Cmp(wantValue) != 0 { + t.Fatalf("slot %x change %d value: have %s, want %s", key, i, slot.SlotChanges[i].PostValue, wantValue) + } + } + return + } + t.Fatalf("slot %x missing from storage_changes", key) +} + +func assertStorageChangeAt(t *testing.T, aa *bal.AccountAccess, key common.Hash, index uint32) { + t.Helper() + wantKey := new(uint256.Int).SetBytes(key[:]) + for _, slot := range aa.StorageChanges { + if slot.Slot.Cmp(wantKey) != 0 { + continue + } + for _, change := range slot.SlotChanges { + if change.BlockAccessIndex == index { + return + } + } + t.Fatalf("slot %x has no change at index %d: %+v", key, index, slot.SlotChanges) + } + t.Fatalf("slot %x missing from storage_changes", key) +} + // txGasNewAccount covers the base tx cost plus the EIP-8037 account-creation // state-gas charge (STATE_BYTES_PER_NEW_ACCOUNT × CPSB ≈ 183,600) that is // incurred when value is transferred to a non-existent account under Amsterdam. @@ -230,21 +276,30 @@ func TestBALEmptyBlockExcludesCoinbase(t *testing.T) { assertAbsent(t, b, coinbase) } -// TestBALCoinbaseTipCapturesBalance: positive priority fee credits coinbase -// and the balance change appears in the BAL. -func TestBALCoinbaseTipCapturesBalance(t *testing.T) { - coinbase := common.Address{0xc0} - to := common.HexToAddress("0xabba") +// TestBALCoinbasePerTxBalance checks that fee-recipient balances are recorded +// after each transaction, rather than only once at the end of the block. +func TestBALCoinbasePerTxBalance(t *testing.T) { + coinbase := common.HexToAddress("0xc01babe") + to := common.HexToAddress("0xc0ffee") env := newBALTestEnv(nil) - b, _ := env.run(t, func(g *BlockGen) { + b, receipts := env.run(t, func(g *BlockGen) { g.SetCoinbase(coinbase) - g.AddTx(env.tx(0, &to, big.NewInt(0), params.TxGas, 2 /* gwei tip */, nil)) + g.AddTx(env.tx(0, &to, big.NewInt(0), 100_000, 1, nil)) + g.AddTx(env.tx(1, &to, big.NewInt(0), 100_000, 1, nil)) }) - cb := assertPresent(t, b, coinbase) - if len(cb.BalanceChanges) == 0 || cb.BalanceChanges[0].PostBalance.Sign() == 0 { - t.Fatalf("coinbase missing positive balance change: %+v", cb.BalanceChanges) + feeRecipient := assertPresent(t, b, coinbase) + if len(feeRecipient.BalanceChanges) != 2 { + t.Fatalf("fee recipient must have one balance per transaction: %+v", feeRecipient.BalanceChanges) + } + first := new(big.Int).Mul(new(big.Int).SetUint64(receipts[0].GasUsed), newGwei(1)) + second := new(big.Int).Add(first, new(big.Int).Mul(new(big.Int).SetUint64(receipts[1].GasUsed), newGwei(1))) + for i, want := range []*big.Int{first, second} { + change := feeRecipient.BalanceChanges[i] + if change.BlockAccessIndex != uint32(i+1) || change.PostBalance.ToBig().Cmp(want) != 0 { + t.Fatalf("fee recipient change %d: have %+v, want index %d balance %s", i, change, i+1, want) + } } } @@ -582,7 +637,7 @@ func TestBALCallCodeToDelegatedTargetBalanceFail(t *testing.T) { assertEmpty(t, assertPresent(t, b, impl)) } -// ============================== Revert behaviour ============================== +// ============================== Reverts and exceptional halts ============================== // TestBALRevertedTxStillIncluded: a tx whose top-level call REVERTs still // records the touched contract in the BAL with an empty change set. @@ -619,30 +674,32 @@ func TestBALSenderRecordedOnRevert(t *testing.T) { } } -// ============================== Storage inclusion ============================== - -// TestBALStorageWriteRecorded: SSTORE places the slot in storage_changes and -// keeps it out of storage_reads. -func TestBALStorageWriteRecorded(t *testing.T) { - contract := common.HexToAddress("0xc1") - slot := common.BigToHash(big.NewInt(0x01)) - // PUSH1 0x42 PUSH1 0x01 SSTORE STOP - code := []byte{0x60, 0x42, 0x60, 0x01, 0x55, 0x00} - env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) - - b, _ := env.run(t, func(g *BlockGen) { - g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) +// TestBALDelegationTargetOOG exercises the exceptional-halt +// boundary in EIP-7928. The top-level recipient is loaded to discover its +// delegation, but the runtime budget is insufficient for the cold target +// access, so the implementation must not enter the BAL. +func TestBALDelegationTargetOOG(t *testing.T) { + authority := common.HexToAddress("0xa7702") + implementation := common.HexToAddress("0x1a11") + env := newBALTestEnv(types.GenesisAlloc{ + authority: {Code: types.AddressToDelegation(implementation), Balance: common.Big0}, + implementation: {Code: []byte{0x00}, Balance: common.Big0}, }) - aa := assertPresent(t, b, contract) - if !hasStorageWrite(b, contract, slot) { - t.Fatalf("expected slot 0x01 in storage_changes\n%s", b.PrettyPrint()) - } - if hasSlotIn(aa.StorageReads, slot) { - t.Fatalf("slot 0x01 must NOT appear in storage_reads") + b, receipts := env.run(t, func(g *BlockGen) { + // This transaction has exactly enough gas for its intrinsic charges, but + // less than the 3,000 cold-account runtime charge needed to load target. + g.AddTx(env.tx(0, &authority, big.NewInt(0), 15_000, 0, nil)) + }) + if receipts[0].Status != types.ReceiptStatusFailed { + t.Fatalf("expected runtime out-of-gas receipt, have status %d", receipts[0].Status) } + assertEmpty(t, assertPresent(t, b, authority)) + assertAbsent(t, b, implementation) } +// ============================== Storage inclusion ============================== + // TestBALStorageSloadOnly: SLOAD without a write puts the slot in storage_reads. func TestBALStorageSloadOnly(t *testing.T) { contract := common.HexToAddress("0xc1") @@ -890,6 +947,38 @@ func TestBALInEVMCreatePreAccessAbortDestinationExcluded(t *testing.T) { } } +// TestBALInEVMCreateOOGDestination distinguishes a CREATE precheck abort from +// an account-creation runtime OOG. The latter calls StateDB.Empty on the +// destination to determine whether the creation charge is due, so the +// destination has been accessed and must appear in the BAL even though the +// failed charge halts the transaction before evm.create runs. +func TestBALInEVMCreateOOGDestination(t *testing.T) { + factory := common.HexToAddress("0xfac4") + // PUSH1 0 (length) PUSH1 0 (offset) PUSH1 0 (value) CREATE POP STOP. + // The factory has enough regular gas for CREATE's opcode cost but not enough + // combined gas to pay Amsterdam's 183,600 account-creation state charge. + code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0xf0, 0x50, 0x00} + env := newBALTestEnv(types.GenesisAlloc{ + factory: {Code: code, Balance: common.Big0, Nonce: 1}, + }) + + b, receipts := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &factory, big.NewInt(0), 30_000, 0, nil)) + }) + if receipts[0].Status != types.ReceiptStatusFailed { + t.Fatalf("expected account-creation runtime OOG, have status %d", receipts[0].Status) + } + + wouldBeDest := crypto.CreateAddress(factory, 1) + assertEmpty(t, assertPresent(t, b, wouldBeDest)) + + // evm.create is never entered, so its creator-nonce bump does not occur. + aa := assertPresent(t, b, factory) + if len(aa.NonceChanges) != 0 { + t.Fatalf("factory nonce must not be bumped before account-creation charge succeeds: %+v", aa.NonceChanges) + } +} + // TestBALInEVMCreateDeploysContract: a CREATE issued by an existing contract // (not a top-level CREATE tx) records the deployed address in the BAL. func TestBALInEVMCreateDeploysContract(t *testing.T) { @@ -1066,7 +1155,60 @@ func TestBALSelfDestructToSelfPrefundedUnchanged(t *testing.T) { assertEmpty(t, aa) } -// ============================== Mid-tx balance round-trip ============================== +// TestBALSelfDestructStorageRead checks the in-transaction +// SELFDESTRUCT rule: storage changed by an account that is subsequently +// deleted is retained as a read footprint, not as a storage change. The +// deleted account also must not carry nonce or code changes. +func TestBALSelfDestructStorageRead(t *testing.T) { + beneficiary := common.HexToAddress("0xbeefbeef") + slot := common.BigToHash(big.NewInt(0x05)) + env := newBALTestEnv(nil) + // SSTORE(5, 0x42); SELFDESTRUCT(beneficiary). This executes in init code, + // so the created account is deleted under EIP-6780. + init := []byte{0x60, 0x42, 0x60, 0x05, 0x55, 0x73} + init = append(init, beneficiary.Bytes()...) + init = append(init, 0xff) + + b, receipts := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, nil, big.NewInt(100), 1_000_000, 0, init)) + }) + + deleted := assertPresent(t, b, receipts[0].ContractAddress) + if !hasSlotIn(deleted.StorageReads, slot) { + t.Fatalf("deleted account storage slot %x must be in storage_reads\n%s", slot, b.PrettyPrint()) + } + if hasStorageWrite(b, deleted.Address, slot) { + t.Fatalf("deleted account storage slot %x must not remain in storage_changes\n%s", slot, b.PrettyPrint()) + } + if len(deleted.NonceChanges) != 0 || len(deleted.CodeChanges) != 0 { + t.Fatalf("deleted account must not record nonce or code: %+v", deleted) + } +} + +// TestBALSelfDestructDelegatedBeneficiary checks that SELFDESTRUCT treats a +// delegated authority as its beneficiary account, without loading the +// implementation as executable code. +func TestBALSelfDestructDelegatedBeneficiary(t *testing.T) { + victim := common.HexToAddress("0x5e1f") + authority := common.HexToAddress("0xa7702") + implementation := common.HexToAddress("0x1a11") + victimCode := append([]byte{0x73}, authority.Bytes()...) + victimCode = append(victimCode, 0xff) // SELFDESTRUCT + env := newBALTestEnv(types.GenesisAlloc{ + victim: {Code: victimCode, Balance: common.Big0}, + authority: {Code: types.AddressToDelegation(implementation), Balance: common.Big0}, + implementation: {Code: []byte{0x00}, Balance: common.Big0}, + }) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &victim, big.NewInt(0), 1_000_000, 0, nil)) + }) + + assertEmpty(t, assertPresent(t, b, authority)) + assertAbsent(t, b, implementation) +} + +// ============================== Balance accounting ============================== // TestBALMidTxBalanceRoundTrip: when an address's balance changes during a // transaction but returns to its pre-transaction value, the address is still @@ -1107,6 +1249,38 @@ func TestBALMidTxBalanceRoundTrip(t *testing.T) { } } +// TestBALGasRefundSenderBalance ensures a gas-refunding SSTORE still records +// the sender's post-transaction balance, after the refund has been applied +// to the transaction's final gas charge. +func TestBALGasRefundSenderBalance(t *testing.T) { + contract := common.HexToAddress("0xc1") + slot := common.BigToHash(big.NewInt(0x05)) + env := newBALTestEnv(types.GenesisAlloc{ + contract: { + Code: []byte{0x5f, 0x60, 0x05, 0x55, 0x00}, // SSTORE(5, 0); STOP + Balance: common.Big0, + Storage: map[common.Hash]common.Hash{slot: common.BigToHash(big.NewInt(1))}, + }, + }) + + _, blocks, receipts := GenerateChainWithGenesis(env.gspec, beacon.New(ethash.NewFaker()), 1, func(_ int, g *BlockGen) { + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) + }) + b := blocks[0].AccessList() + if b == nil { + t.Fatal("expected non-nil block access list") + } + sender := assertPresent(t, b, env.from) + if len(sender.BalanceChanges) != 1 || sender.BalanceChanges[0].BlockAccessIndex != 1 { + t.Fatalf("sender needs one post-tx balance at index 1: %+v", sender.BalanceChanges) + } + gasCost := new(big.Int).Mul(new(big.Int).SetUint64(receipts[0][0].GasUsed), blocks[0].BaseFee()) + want := new(big.Int).Sub(newGwei(1_000_000_000), gasCost) + if sender.BalanceChanges[0].PostBalance.ToBig().Cmp(want) != 0 { + t.Fatalf("sender post-refund balance: have %s, want %s", sender.BalanceChanges[0].PostBalance, want) + } +} + // ============================== System contracts (pre/post-execution) ============================== // TestBALSystemContractsPresent: per EIP-7928, "System contract addresses @@ -1146,6 +1320,107 @@ func TestBALSystemContractsPresent(t *testing.T) { } } +// TestBALPreExecutionStorage checks the precise pre-execution entries required +// by EIP-7928: both EIP-2935 and EIP-4788 changes belong to index zero, before +// any transaction in the block. +func TestBALPreExecutionStorage(t *testing.T) { + beaconRoot := common.HexToHash("0xbeac") + env := newBALTestEnv(nil) + + b, _ := env.run(t, func(g *BlockGen) { + g.SetParentBeaconRoot(beaconRoot) + }) + + // The generated block is number 1 and timestamp 10. EIP-2935 stores the + // parent hash at (block.number - 1) % 8191, i.e. slot zero here. + history := assertPresent(t, b, params.HistoryStorageAddress) + assertStorageChanges(t, history, common.Hash{}, []balStorageChangeExpectation{{ + index: 0, + value: env.gspec.ToBlock().Hash(), + }}) + + // EIP-4788 stores timestamp at timestamp % 8191 and the parent beacon root + // at that slot plus 8191. + const timestamp = 10 + beacon := assertPresent(t, b, params.BeaconRootsAddress) + assertStorageChanges(t, beacon, common.BigToHash(big.NewInt(timestamp)), []balStorageChangeExpectation{{ + index: 0, + value: common.BigToHash(big.NewInt(timestamp)), + }}) + assertStorageChanges(t, beacon, common.BigToHash(big.NewInt(timestamp+8191)), []balStorageChangeExpectation{{ + index: 0, + value: beaconRoot, + }}) +} + +// TestBALPostExecutionQueueReads covers the EIP-7002 and EIP-7251 rule that +// a post-execution system call accesses queue metadata in slots 0..3 at index +// n+1, while the queued payload slots are read-only. +func TestBALPostExecutionQueueReads(t *testing.T) { + withdrawalData := common.FromHex("b917cfdc0d25b72d55cf94db328e1629b7f4fde2c30cdacf873b664416f76a0c7f7cc50c9f72a3cb84be88144cde91250000000000000d80") + consolidationData := common.FromHex("b917cfdc0d25b72d55cf94db328e1629b7f4fde2c30cdacf873b664416f76a0c7f7cc50c9f72a3cb84be88144cde9125b9812f7d0b1f2f969b52bbb2d316b0c2fa7c9dba85c428c5e6c27766bcc4b0c6e874702ff1eb1c7024b08524a9771601") + + for _, tc := range []struct { + name string + addr common.Address + data []byte + }{ + {"withdrawal queue (EIP-7002)", params.WithdrawalQueueAddress, withdrawalData}, + {"consolidation queue (EIP-7251)", params.ConsolidationQueueAddress, consolidationData}, + } { + t.Run(tc.name, func(t *testing.T) { + env := newBALTestEnv(nil) + // A request transaction writes several new storage slots under + // Amsterdam's state-gas schedule. Raise the test chain's gas limit so + // all 17 requests fit in the first block. + env.gspec.GasLimit = 200_000_000 + _, blocks, _ := GenerateChainWithGenesis(env.gspec, beacon.New(ethash.NewFaker()), 2, func(i int, g *BlockGen) { + if i == 1 { + // Make the post-execution system call occur after one ordinary + // transaction, proving it uses n + 1 rather than a fixed index. + to := common.HexToAddress("0xf00") + g.AddTx(env.tx(17, &to, big.NewInt(0), 100_000, 0, nil)) + return + } + // The system call processes at most 16 requests per block. Leave one + // payload for block 2 so its storage access is genuinely a read. + for nonce := uint64(0); nonce < 17; nonce++ { + g.AddTx(env.tx(nonce, &tc.addr, newGwei(1), 5_000_000, 0, tc.data)) + } + }) + + b := blocks[1].AccessList() + if b == nil { + t.Fatal("expected non-nil block access list") + } + aa := assertPresent(t, b, tc.addr) + // Block 2 contains one user transaction, so its post-execution call is + // index 2 (= n + 1). + for slot := int64(0); slot < 4; slot++ { + key := common.BigToHash(big.NewInt(slot)) + if hasSlotIn(aa.StorageReads, key) { + continue // A metadata slot whose value is unchanged is read-only. + } + assertStorageChangeAt(t, aa, key, 2) + } + foundPayloadRead := false + for _, slot := range aa.StorageReads { + if slot.Uint64() >= 4 { + foundPayloadRead = true + } + } + if !foundPayloadRead { + t.Fatalf("post-execution queue call must leave payload slots in storage_reads\n%s", b.PrettyPrint()) + } + for _, change := range aa.StorageChanges { + if change.Slot.Uint64() >= 4 { + t.Fatalf("post-execution queue call must not write payload slot %s\n%s", change.Slot, b.PrettyPrint()) + } + } + }) + } +} + // ============================== Withdrawals ============================== // TestBALWithdrawalZeroAmountIncluded: a withdrawal with amount 0 still puts @@ -1335,6 +1610,49 @@ func TestBALAuthFailedAfterLoadEmptyChangeSet(t *testing.T) { } } +// TestBALAuthOOGRecipientExcluded covers the exceptional halt boundary where +// an EIP-7702 authorization exhausts runtime gas before the transaction's +// recipient is loaded. The authority was already loaded by authorization +// validation, but tx.to must not enter the BAL. +func TestBALAuthOOGRecipientExcluded(t *testing.T) { + authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020") + authority := crypto.PubkeyToAddress(authKey.PublicKey) + recipient := common.HexToAddress("0xdec1a1") + implementation := common.HexToAddress("0x1a11") + env := newBALTestEnv(nil) + auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(env.cfg.ChainID), + Address: implementation, + Nonce: 0, + }) + if err != nil { + t.Fatalf("sign auth: %v", err) + } + + b, receipts := env.run(t, func(g *BlockGen) { + tx, err := types.SignTx(types.NewTx(&types.SetCodeTx{ + ChainID: uint256.MustFromBig(env.cfg.ChainID), + Nonce: 0, + To: recipient, + Value: new(uint256.Int), + Gas: 30_000, + GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())), + GasTipCap: new(uint256.Int), + AuthList: []types.SetCodeAuthorization{auth}, + }), env.signer, env.key) + if err != nil { + t.Fatalf("sign SetCodeTx: %v", err) + } + g.AddTx(tx) + }) + if receipts[0].Status != types.ReceiptStatusFailed { + t.Fatalf("expected authorization runtime OOG, have status %d", receipts[0].Status) + } + assertEmpty(t, assertPresent(t, b, authority)) + assertAbsent(t, b, recipient) + assertAbsent(t, b, implementation) +} + // TestBALMultipleAuthsOnlyLoadedIncluded: a SetCode tx with a mix of valid and // pre-load-failed auths lists only the loaded authorities in the BAL. func TestBALMultipleAuthsOnlyLoadedIncluded(t *testing.T) { @@ -1453,3 +1771,409 @@ func TestBALAuthCodeOverwrittenFinalRecorded(t *testing.T) { t.Fatalf("expected final nonce 2, got %+v", aa.NonceChanges) } } + +// TestBALReapplyDelegation covers an authorization whose final code equals its +// pre-transaction code. The authority nonce changes, but the unchanged +// delegation must not create a code change or implementation read. +func TestBALReapplyDelegation(t *testing.T) { + authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020") + authority := crypto.PubkeyToAddress(authKey.PublicKey) + implementation := common.HexToAddress("0x1a11") + env := newBALTestEnv(types.GenesisAlloc{ + authority: {Nonce: 1, Code: types.AddressToDelegation(implementation), Balance: common.Big0}, + implementation: {Code: []byte{0x00}, Balance: common.Big0}, + }) + auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(env.cfg.ChainID), + Address: implementation, + Nonce: 1, + }) + if err != nil { + t.Fatalf("sign auth: %v", err) + } + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.newSetCodeTx(t, 0, env.from, []types.SetCodeAuthorization{auth})) + }) + + aa := assertPresent(t, b, authority) + if len(aa.NonceChanges) != 1 || aa.NonceChanges[0].PostNonce != 2 { + t.Fatalf("reapplied delegation nonce: %+v", aa.NonceChanges) + } + if len(aa.CodeChanges) != 0 { + t.Fatalf("reapplying unchanged delegation must not record code: %+v", aa.CodeChanges) + } + assertAbsent(t, b, implementation) +} + +// TestBALSetCodeTxDelegatedRecipient verifies that authorizations are applied +// before the top-level call. A newly delegated tx.to must immediately load and +// execute its implementation. +func TestBALSetCodeTxDelegatedRecipient(t *testing.T) { + authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020") + authority := crypto.PubkeyToAddress(authKey.PublicKey) + implementation := common.HexToAddress("0x1a11") + slot := common.BigToHash(big.NewInt(0x07)) + implementationCode := []byte{0x60, 0x07, 0x54, 0x50, 0x00} // SLOAD(7); POP; STOP. + env := newBALTestEnv(types.GenesisAlloc{ + implementation: {Code: implementationCode, Balance: common.Big0}, + }) + auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(env.cfg.ChainID), + Address: implementation, + Nonce: 0, + }) + if err != nil { + t.Fatalf("sign auth: %v", err) + } + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.newSetCodeTx(t, 0, authority, []types.SetCodeAuthorization{auth})) + }) + + authorityAccess := assertPresent(t, b, authority) + if len(authorityAccess.NonceChanges) != 1 || authorityAccess.NonceChanges[0].PostNonce != 1 { + t.Fatalf("authority nonce after installation: %+v", authorityAccess.NonceChanges) + } + if len(authorityAccess.CodeChanges) != 1 || !bytes.Equal(authorityAccess.CodeChanges[0].NewCode, types.AddressToDelegation(implementation)) { + t.Fatalf("authority delegation code after installation: %+v", authorityAccess.CodeChanges) + } + if !hasSlotIn(authorityAccess.StorageReads, slot) { + t.Fatalf("same-transaction delegated execution must read authority storage\n%s", b.PrettyPrint()) + } + assertEmpty(t, assertPresent(t, b, implementation)) +} + +// ============================== Read-list regression cases ============================== + +// TestBALStorageWriteThenRead covers the opposite ordering of +// TestBALStorageReadThenWriteOnlyInWrites. Once an index changes a slot, all +// subsequent reads of that slot must be hidden by storage_changes. +func TestBALStorageWriteThenRead(t *testing.T) { + contract := common.HexToAddress("0xc1") + slot := common.BigToHash(big.NewInt(0x05)) + // SSTORE(0x05, 0x42); SLOAD(0x05); POP; STOP. + code := []byte{0x60, 0x42, 0x60, 0x05, 0x55, 0x60, 0x05, 0x54, 0x50, 0x00} + env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) + }) + + aa := assertPresent(t, b, contract) + assertStorageChanges(t, aa, slot, []balStorageChangeExpectation{{index: 1, value: common.BigToHash(big.NewInt(0x42))}}) + if hasSlotIn(aa.StorageReads, slot) { + t.Fatalf("written slot %x must not remain in storage_reads\n%s", slot, b.PrettyPrint()) + } +} + +// TestBALStorageNoOpAfterWrite checks that the no-op decision +// is relative to the state before the current block-access index. Tx2 writes +// the value that Tx1 already committed, so it must not replace Tx1's change +// with a read entry. +func TestBALStorageNoOpAfterWrite(t *testing.T) { + contract := common.HexToAddress("0xc1") + slot := common.BigToHash(big.NewInt(0x05)) + // SSTORE(0x05, 0x42); STOP. + code := []byte{0x60, 0x42, 0x60, 0x05, 0x55, 0x00} + env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) + g.AddTx(env.tx(1, &contract, big.NewInt(0), 1_000_000, 0, nil)) + }) + + aa := assertPresent(t, b, contract) + assertStorageChanges(t, aa, slot, []balStorageChangeExpectation{{index: 1, value: common.BigToHash(big.NewInt(0x42))}}) + if hasSlotIn(aa.StorageReads, slot) { + t.Fatalf("later no-op must not add slot %x to storage_reads\n%s", slot, b.PrettyPrint()) + } +} + +// TestBALStorageChangesAcrossTxs verifies that a slot changed at two +// distinct transaction indices retains both post-index values, including a +// later reset to its pre-block value. +func TestBALStorageChangesAcrossTxs(t *testing.T) { + contract := common.HexToAddress("0xc1") + slot := common.BigToHash(big.NewInt(0x05)) + // CALLDATALOAD(0); SSTORE(0x05, value); STOP. + code := []byte{0x60, 0x00, 0x35, 0x60, 0x05, 0x55, 0x00} + env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, common.LeftPadBytes([]byte{0x42}, 32))) + g.AddTx(env.tx(1, &contract, big.NewInt(0), 1_000_000, 0, make([]byte, 32))) + }) + + aa := assertPresent(t, b, contract) + assertStorageChanges(t, aa, slot, []balStorageChangeExpectation{ + {index: 1, value: common.BigToHash(big.NewInt(0x42))}, + {index: 2, value: common.Hash{}}, + }) + if hasSlotIn(aa.StorageReads, slot) { + t.Fatalf("slot with committed changes must not be in storage_reads\n%s", b.PrettyPrint()) + } +} + +// TestBALRevertedSStoreRead verifies that SSTORE performs an implicit read once +// its access boundary is crossed. The later REVERT discards the write but not +// that read footprint. +func TestBALRevertedSStoreRead(t *testing.T) { + contract := common.HexToAddress("0xc1") + slot := common.BigToHash(big.NewInt(0x05)) + // SSTORE(0x05, 0x42); REVERT(0, 0). + code := []byte{0x60, 0x42, 0x60, 0x05, 0x55, 0x60, 0x00, 0x60, 0x00, 0xfd} + env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) + }) + + aa := assertPresent(t, b, contract) + if !hasSlotIn(aa.StorageReads, slot) { + t.Fatalf("reverted SSTORE must leave slot %x in storage_reads\n%s", slot, b.PrettyPrint()) + } + if hasStorageWrite(b, contract, slot) { + t.Fatalf("reverted SSTORE must not leave a storage change\n%s", b.PrettyPrint()) + } +} + +// TestBALParentRevertSStoreRead checks the journal boundary between call frames: +// a child returns successfully after SSTORE, then its parent reverts the whole +// call tree. The child's slot is still a BAL read. +func TestBALParentRevertSStoreRead(t *testing.T) { + child := common.HexToAddress("0xc1") + parent := common.HexToAddress("0xc2") + slot := common.BigToHash(big.NewInt(0x05)) + childCode := []byte{0x60, 0x42, 0x60, 0x05, 0x55, 0x00} + parentCode := makeStubCaller(0xf1 /* CALL */, child) + parentCode = append(parentCode[:len(parentCode)-1], 0x60, 0x00, 0x60, 0x00, 0xfd) + env := newBALTestEnv(types.GenesisAlloc{ + child: {Code: childCode, Balance: common.Big0}, + parent: {Code: parentCode, Balance: common.Big0}, + }) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &parent, big.NewInt(0), 1_000_000, 0, nil)) + }) + + aa := assertPresent(t, b, child) + if !hasSlotIn(aa.StorageReads, slot) { + t.Fatalf("parent-reverted child write must leave slot %x in storage_reads\n%s", slot, b.PrettyPrint()) + } + if hasStorageWrite(b, child, slot) { + t.Fatalf("parent-reverted child write must not leave a storage change\n%s", b.PrettyPrint()) + } +} + +// TestBALStorageReadsSorted exercises the final encoding of a read-only set. +// Repeated reads must produce one key, and keys are ordered lexicographically +// regardless of execution order. +func TestBALStorageReadsSorted(t *testing.T) { + contract := common.HexToAddress("0xc1") + // SLOAD(9); SLOAD(1); SLOAD(9); STOP. + code := []byte{ + 0x60, 0x09, 0x54, 0x50, + 0x60, 0x01, 0x54, 0x50, + 0x60, 0x09, 0x54, 0x50, + 0x00, + } + env := newBALTestEnv(types.GenesisAlloc{contract: {Code: code, Balance: common.Big0}}) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &contract, big.NewInt(0), 1_000_000, 0, nil)) + }) + + aa := assertPresent(t, b, contract) + if len(aa.StorageReads) != 2 || aa.StorageReads[0].Uint64() != 1 || aa.StorageReads[1].Uint64() != 9 { + t.Fatalf("storage_reads must be deduplicated and sorted: %+v", aa.StorageReads) + } +} + +// TestBALAccessListSlotExcluded ensures an EIP-2930 storage-key warming entry +// changes gas only. It must not create a storage_reads entry unless the EVM +// actually executes an access to that slot. +func TestBALAccessListSlotExcluded(t *testing.T) { + contract := common.HexToAddress("0xc1") + slot := common.BigToHash(big.NewInt(0x07)) + env := newBALTestEnv(types.GenesisAlloc{contract: {Code: []byte{0x00}, Balance: common.Big0}}) + + b, _ := env.run(t, func(g *BlockGen) { + tx := types.MustSignNewTx(env.key, env.signer, &types.DynamicFeeTx{ + ChainID: env.cfg.ChainID, + Nonce: 0, + To: &contract, + Value: new(big.Int), + Gas: 1_000_000, + GasFeeCap: newGwei(10), + GasTipCap: new(big.Int), + AccessList: types.AccessList{{ + Address: contract, + StorageKeys: []common.Hash{slot}, + }}, + }) + g.AddTx(tx) + }) + + aa := assertPresent(t, b, contract) + if hasSlotIn(aa.StorageReads, slot) || hasStorageWrite(b, contract, slot) { + t.Fatalf("untouched access-list slot %x must be absent from BAL\n%s", slot, b.PrettyPrint()) + } +} + +// ============================== EIP-7702 execution-time delegation ============================== + +func makeDelegationProbe(op byte, target common.Address) []byte { + if op == 0x3c { // EXTCODECOPY needs length, code offset and memory offset. + code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x73} + code = append(code, target.Bytes()...) + return append(code, op, 0x00) + } + code := append([]byte{0x73}, target.Bytes()...) + return append(code, op, 0x50, 0x00) // opcode, POP, STOP +} + +// TestBALDelegationInspection ensures the code-reading opcodes observe an +// authority's 7702 indicator as its own code. They access the authority +// but MUST NOT load the implementation address. +func TestBALDelegationInspection(t *testing.T) { + authority := common.HexToAddress("0xa7702") + implementation := common.HexToAddress("0x1a11") + for _, tc := range []struct { + name string + op byte + }{ + {"balance", 0x31}, + {"extcodesize", 0x3b}, + {"extcodecopy", 0x3c}, + {"extcodehash", 0x3f}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := common.HexToAddress("0xca11") + env := newBALTestEnv(types.GenesisAlloc{ + caller: {Code: makeDelegationProbe(tc.op, authority), Balance: common.Big0}, + authority: {Code: types.AddressToDelegation(implementation), Balance: common.Big0}, + implementation: {Code: []byte{0x00}, Balance: common.Big0}, + }) + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) + }) + assertEmpty(t, assertPresent(t, b, authority)) + assertAbsent(t, b, implementation) + }) + } +} + +// TestBALDelegatedCallStorage checks CALL's storage context. The implementation +// code is fetched from implementation, but its SLOAD executes in the delegated +// authority's storage, not implementation's. +func TestBALDelegatedCallStorage(t *testing.T) { + authority := common.HexToAddress("0xa7702") + implementation := common.HexToAddress("0x1a11") + slot := common.BigToHash(big.NewInt(0x07)) + implCode := []byte{0x60, 0x07, 0x54, 0x50, 0x00} // SLOAD(7), POP, STOP + env := newBALTestEnv(types.GenesisAlloc{ + authority: {Code: types.AddressToDelegation(implementation), Balance: common.Big0}, + implementation: {Code: implCode, Balance: common.Big0}, + }) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &authority, big.NewInt(0), 1_000_000, 0, nil)) + }) + + aa := assertPresent(t, b, authority) + if !hasSlotIn(aa.StorageReads, slot) { + t.Fatalf("delegated CALL read must belong to authority\n%s", b.PrettyPrint()) + } + assertEmpty(t, assertPresent(t, b, implementation)) +} + +// TestBALDelegatedCallFamilyStorage checks the storage context of the CALL +// family against a delegated authority: the resolved implementation supplies +// code only, while the storage read belongs to the executing context — the +// authority for CALL/STATICCALL, the caller for DELEGATECALL/CALLCODE. +func TestBALDelegatedCallFamilyStorage(t *testing.T) { + caller := common.HexToAddress("0xca11") + authority := common.HexToAddress("0xa7702") + implementation := common.HexToAddress("0x1a11") + slot := common.BigToHash(big.NewInt(0x07)) + implCode := []byte{0x60, 0x07, 0x54, 0x50, 0x00} // SLOAD(7), POP, STOP + cases := []struct { + name string + op byte + readOwner common.Address + }{ + {"call", 0xf1, authority}, + {"callcode", 0xf2, caller}, + {"delegatecall", 0xf4, caller}, + {"staticcall", 0xfa, authority}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newBALTestEnv(types.GenesisAlloc{ + caller: {Code: makeStubCaller(tc.op, authority), Balance: common.Big0}, + authority: {Code: types.AddressToDelegation(implementation), Balance: common.Big0}, + implementation: {Code: implCode, Balance: common.Big0}, + }) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) + }) + + owner := assertPresent(t, b, tc.readOwner) + if !hasSlotIn(owner.StorageReads, slot) { + t.Fatalf("%s read must belong to %x\n%s", tc.name, tc.readOwner, b.PrettyPrint()) + } + for _, other := range []common.Address{caller, authority} { + if other != tc.readOwner { + assertEmpty(t, assertPresent(t, b, other)) + } + } + assertEmpty(t, assertPresent(t, b, implementation)) + }) + } +} + +// TestBALDelegationOneHop verifies that resolving A -> B does not +// recursively resolve B -> C. A and B are state accesses; C is not. +func TestBALDelegationOneHop(t *testing.T) { + caller := common.HexToAddress("0xca11") + authority := common.HexToAddress("0xa7702") + delegated := common.HexToAddress("0xb7702") + secondHop := common.HexToAddress("0xc7702") + env := newBALTestEnv(types.GenesisAlloc{ + caller: {Code: makeStubCaller(0xf1 /* CALL */, authority), Balance: common.Big0}, + authority: {Code: types.AddressToDelegation(delegated), Balance: common.Big0}, + delegated: {Code: types.AddressToDelegation(secondHop), Balance: common.Big0}, + secondHop: {Code: []byte{0x00}, Balance: common.Big0}, + }) + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &caller, big.NewInt(0), 1_000_000, 0, nil)) + }) + + assertPresent(t, b, authority) + assertPresent(t, b, delegated) + assertAbsent(t, b, secondHop) +} + +// TestBALDelegatedSender distinguishes transaction origination from execution. +// A sender may carry a valid delegation indicator, but its implementation is +// not accessed unless a call actually targets sender. +func TestBALDelegatedSender(t *testing.T) { + to := common.HexToAddress("0xb0b") + implementation := common.HexToAddress("0x1a11") + env := newBALTestEnv(types.GenesisAlloc{ + implementation: {Code: []byte{0x00}, Balance: common.Big0}, + }) + sender := env.gspec.Alloc[env.from] + sender.Code = types.AddressToDelegation(implementation) + env.gspec.Alloc[env.from] = sender + + b, _ := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, &to, big.NewInt(0), params.TxGas, 0, nil)) + }) + + assertPresent(t, b, env.from) + assertAbsent(t, b, implementation) +} diff --git a/core/eip8037_test.go b/core/eip8037_test.go index 2b56749f0b..80e7e6f1cf 100644 --- a/core/eip8037_test.go +++ b/core/eip8037_test.go @@ -192,6 +192,9 @@ func assertPoolSane(t *testing.T, res *ExecutionResult, gp *GasPool, floor uint6 if res.UsedGas > res.MaxUsedGas { t.Fatalf("post-refund gas %d exceeds peak %d", res.UsedGas, res.MaxUsedGas) } + if gp.cumulativeRegular > res.MaxUsedGas { + t.Fatalf("regular %d exceeds peak %d", gp.cumulativeRegular, res.MaxUsedGas) + } if gp.cumulativeState > res.MaxUsedGas { t.Fatalf("state %d exceeds peak %d", gp.cumulativeState, res.MaxUsedGas) } diff --git a/core/eip8246_test.go b/core/eip8246_test.go index 18049d2279..b37e96d923 100644 --- a/core/eip8246_test.go +++ b/core/eip8246_test.go @@ -31,7 +31,8 @@ import ( // TestEIP8246SelfdestructNoBurn verifies that, once EIP-8246 is active // (Amsterdam), a contract that is created and self-destructs to itself within // the same transaction keeps its balance instead of burning it: the account -// survives as a balance-only account (no code, zero nonce, balance preserved). +// survives as a balance-only account (no code, zero nonce, balance preserved) +// whose storage is cleared at transaction finalization. // // https://eips.ethereum.org/EIPS/eip-8246 func TestEIP8246SelfdestructNoBurn(t *testing.T) { @@ -42,9 +43,11 @@ func TestEIP8246SelfdestructNoBurn(t *testing.T) { signer = types.LatestSigner(&config) engine = beacon.New(ethash.NewFaker()) value = big.NewInt(1_000_000) - // Init code: ADDRESS (0x30) ; SELFDESTRUCT (0xff). The created contract - // self-destructs to itself during its own creation transaction. - initcode = common.FromHex("30ff") + slot = common.BigToHash(big.NewInt(0x05)) + // Init code: SSTORE(5, 0x2a); ADDRESS (0x30); SELFDESTRUCT (0xff). The + // created contract stores a value and self-destructs to itself during + // its own creation transaction. + initcode = []byte{0x60, 0x2a, 0x60, 0x05, 0x55, 0x30, 0xff} ) // TODO: drop this hacky Amsterdam config initialization once the final // Amsterdam config is available (mirrors TestEthTransferLogs). @@ -99,4 +102,147 @@ func TestEIP8246SelfdestructNoBurn(t *testing.T) { if got := state.GetCodeSize(created); got != 0 { t.Errorf("created account code size = %d, want 0 (code must be cleared)", got) } + if got := state.GetState(created, slot); got != (common.Hash{}) { + t.Errorf("created storage slot %x = %x, want 0 (storage must be cleared)", slot, got) + } +} + +// TestEIP8246SelfdestructRefunded verifies that ETH sent back to a +// same-transaction selfdestructed account is retained at finalization instead +// of being burned. The factory funds the account twice after SELFDESTRUCT. +func TestEIP8246SelfdestructRefunded(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + sender = crypto.PubkeyToAddress(key.PublicKey) + factory = common.HexToAddress("0xfac8246") + beneficiary = common.HexToAddress("0xbeef") + config = *params.MergedTestChainConfig + signer = types.LatestSigner(&config) + engine = beacon.New(ethash.NewFaker()) + ) + config.AmsterdamTime = new(uint64) + // The child initcode selfdestructs to another account. The factory then + // sends it 7 and 8 wei after it is marked for selfdestruction. + childInit := append([]byte{0x73}, beneficiary.Bytes()...) + childInit = append(childInit, 0xff) + var word [32]byte + copy(word[32-len(childInit):], childInit) + factoryCode := append([]byte{0x7f}, word[:]...) + factoryCode = append(factoryCode, 0x5f, 0x52) // PUSH0; MSTORE + // CREATE(value=0, offset=10, length=22), then store the returned address. + factoryCode = append(factoryCode, 0x60, byte(len(childInit)), 0x60, byte(32-len(childInit)), 0x5f, 0xf0, 0x5f, 0x52) + for _, amount := range []byte{7, 8} { + // CALL(child, value=amount) with empty input/output. + factoryCode = append(factoryCode, 0x5f, 0x5f, 0x5f, 0x5f, 0x60, amount, 0x5f, 0x51, 0x5a, 0xf1, 0x50) + } + factoryCode = append(factoryCode, 0x00) + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{ + sender: {Balance: newGwei(1_000_000_000)}, + factory: {Nonce: 1, Code: factoryCode, Balance: big.NewInt(15)}, + }, + } + child := crypto.CreateAddress(factory, 1) + db, blocks, _ := GenerateChainWithGenesis(gspec, engine, 1, func(_ int, b *BlockGen) { + b.AddTx(types.MustSignNewTx(key, signer, &types.DynamicFeeTx{ + ChainID: gspec.Config.ChainID, + Nonce: 0, + To: &factory, + Gas: 1_000_000, + GasFeeCap: newGwei(5), + GasTipCap: newGwei(5), + })) + }) + chain, err := NewBlockChain(db, gspec, engine, nil) + if err != nil { + t.Fatalf("failed to create chain: %v", err) + } + defer chain.Stop() + state, err := chain.StateAt(blocks[0].Header()) + if err != nil { + t.Fatalf("failed to obtain block state: %v", err) + } + if got := state.GetBalance(child).Uint64(); got != 15 { + t.Errorf("refunded child balance = %d, want 15", got) + } + if got := state.GetNonce(child); got != 0 { + t.Errorf("refunded child nonce = %d, want 0", got) + } + if got := state.GetCodeSize(child); got != 0 { + t.Errorf("refunded child code size = %d, want 0", got) + } +} + +// TestEIP8246Create2RecreatesBalanceOnly verifies that an EIP-8246 +// balance-only account does not block recreating the same CREATE2 address in a +// later transaction. The second creation contributes another wei to the +// preserved balance, proving that it executed rather than collided. +func TestEIP8246Create2RecreatesBalanceOnly(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + sender = crypto.PubkeyToAddress(key.PublicKey) + factory = common.HexToAddress("0xfac8246") + config = *params.MergedTestChainConfig + signer = types.LatestSigner(&config) + engine = beacon.New(ethash.NewFaker()) + init = []byte{0x30, 0xff} // ADDRESS; SELFDESTRUCT + ) + config.AmsterdamTime = new(uint64) + var word [32]byte + copy(word[32-len(init):], init) + factoryCode := append([]byte{0x7f}, word[:]...) + factoryCode = append(factoryCode, + 0x5f, 0x52, // PUSH0; MSTORE + 0x60, 0x01, // salt + 0x60, byte(len(init)), + 0x60, byte(32-len(init)), + 0x34, // CALLVALUE + 0xf5, // CREATE2 + 0x50, // POP + 0x00, + ) + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{ + sender: {Balance: newGwei(1_000_000_000)}, + factory: {Nonce: 1, Code: factoryCode, Balance: common.Big0}, + }, + } + var salt [32]byte + salt[31] = 1 + child := crypto.CreateAddress2(factory, salt, crypto.Keccak256(init)) + db, blocks, _ := GenerateChainWithGenesis(gspec, engine, 2, func(i int, b *BlockGen) { + value := big.NewInt(5) + if i == 1 { + value.SetInt64(1) + } + b.AddTx(types.MustSignNewTx(key, signer, &types.DynamicFeeTx{ + ChainID: gspec.Config.ChainID, + Nonce: uint64(i), + To: &factory, + Gas: 1_000_000, + GasFeeCap: newGwei(5), + GasTipCap: newGwei(5), + Value: value, + })) + }) + chain, err := NewBlockChain(db, gspec, engine, nil) + if err != nil { + t.Fatalf("failed to create chain: %v", err) + } + defer chain.Stop() + state, err := chain.StateAt(blocks[1].Header()) + if err != nil { + t.Fatalf("failed to obtain block state: %v", err) + } + if got := state.GetBalance(child).Uint64(); got != 6 { + t.Errorf("CREATE2 child balance = %d, want 6", got) + } + if got := state.GetNonce(child); got != 0 { + t.Errorf("CREATE2 child nonce = %d, want 0", got) + } + if got := state.GetCodeSize(child); got != 0 { + t.Errorf("CREATE2 child code size = %d, want 0", got) + } } diff --git a/core/vm/eip8038_test.go b/core/vm/eip8038_test.go index f20a9d8a0e..2d9f50ca8b 100644 --- a/core/vm/eip8038_test.go +++ b/core/vm/eip8038_test.go @@ -210,6 +210,145 @@ func TestEIP8038AccountAccess(t *testing.T) { }) } +// callFamily8038 builds a zero-input/output call-family operation that forwards +// all remaining regular gas and discards its success flag. CALL and CALLCODE +// take a value argument; DELEGATECALL and STATICCALL do not. +func callFamily8038(to common.Address, op OpCode, value byte) []byte { + code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00} + if op == CALL || op == CALLCODE { + code = append(code, 0x60, value) + } + code = append(code, 0x73) + code = append(code, to.Bytes()...) + return append(code, 0x5a, byte(op), 0x50, 0x00) // GAS; ; POP; STOP +} + +// TestEIP8038Calls pins the re-priced account access and value-transfer costs +// for every member of the CALL family. The opcode's constant cost is the warm +// access component, so a cold target adds only COLD_ACCOUNT_ACCESS-WARM. +func TestEIP8038Calls(t *testing.T) { + const ( + push1 = uint64(3) + push20 = uint64(3) + gasOp = uint64(2) + pop = uint64(2) + ) + cold := params.ColdAccountAccessAmsterdam - params.WarmAccountAccessAmsterdam + callBase := 5*push1 + push20 + gasOp + pop + params.WarmAccountAccessAmsterdam + plainBase := 4*push1 + push20 + gasOp + pop + params.WarmAccountAccessAmsterdam + target := common.BytesToAddress([]byte("call-target")) + + cases := []struct { + name string + op OpCode + value byte + fundSelf bool + wantReg uint64 + wantState int64 + }{ + {"call/cold", CALL, 0, false, callBase + cold, 0}, + // A callee that immediately returns gives the 2,300 stipend back, so + // the net regular cost is ACCOUNT_WRITE. + {"call/value", CALL, 1, true, callBase + cold + params.AccountWriteAmsterdam, stateGasNewAccount}, + {"callcode/value", CALLCODE, 1, true, callBase + cold + params.AccountWriteAmsterdam, 0}, + {"delegatecall/cold", DELEGATECALL, 0, false, plainBase + cold, 0}, + {"staticcall/cold", STATICCALL, 0, false, plainBase + cold, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var setup func(*state.StateDB, common.Address) + if tc.fundSelf { + setup = fund(common.BytesToAddress([]byte("self")), 1) + } + res, _, err := run8038(t, callFamily8038(target, tc.op, tc.value), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if res.UsedRegularGas != tc.wantReg { + t.Fatalf("regular gas = %d, want %d", res.UsedRegularGas, tc.wantReg) + } + if res.UsedStateGas != tc.wantState { + t.Fatalf("state gas = %d, want %d", res.UsedStateGas, tc.wantState) + } + }) + } + + // The first CALL makes its target warm. A second CALL pays no dynamic + // access surcharge, leaving only the CALL base cost (which includes warm). + first := callFamily8038(target, CALL, 0) + code := append(first[:len(first)-1], callFamily8038(target, CALL, 0)...) + res, _, err := run8038(t, code, hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + if want := 2*callBase + cold; res.UsedRegularGas != want { + t.Fatalf("cold+warm CALL = %d, want %d", res.UsedRegularGas, want) + } + + // Calling an EIP-7702 authority accesses both the authority and its + // delegation target. The authority uses CALL's warm-included pricing; the + // separately resolved cold target pays a full COLD_ACCOUNT_ACCESS charge. + authority := common.BytesToAddress([]byte("delegated-authority")) + implementation := common.BytesToAddress([]byte("delegated-target")) + setup := func(db *state.StateDB, _ common.Address) { + db.CreateAccount(authority) + db.SetCode(authority, types.AddressToDelegation(implementation), tracing.CodeChangeUnspecified) + db.CreateAccount(implementation) + db.SetCode(implementation, []byte{0x00}, tracing.CodeChangeUnspecified) + } + res, _, err = run8038(t, callFamily8038(authority, CALL, 0), hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if want := callBase + cold + params.ColdAccountAccessAmsterdam; res.UsedRegularGas != want { + t.Fatalf("delegated CALL = %d, want %d (authority + target)", res.UsedRegularGas, want) + } + + // A value CALL receives the 2,300 stipend even when it asks to forward no + // regular gas. If the child burns that stipend, the full CALL_VALUE + // (ACCOUNT_WRITE + stipend) remains charged to the caller. + stipendTarget := common.BytesToAddress([]byte("stipend-target")) + base := callFamily8038(stipendTarget, CALL, 1) + code = append(base[:len(base)-4], 0x60, 0x00, byte(CALL), 0x50, 0x00) // PUSH1 0; CALL; POP; STOP + setup = func(db *state.StateDB, self common.Address) { + db.AddBalance(self, uint256.NewInt(1), tracing.BalanceChangeUnspecified) + db.CreateAccount(stipendTarget) + db.SetCode(stipendTarget, []byte{0xfe}, tracing.CodeChangeUnspecified) + } + res, _, err = run8038(t, code, hugeBudget(), new(uint256.Int), setup) + if err != nil { + t.Fatal(err) + } + if want := 5*push1 + push20 + push1 + pop + params.WarmAccountAccessAmsterdam + cold + params.CallValueTransferAmsterdam; res.UsedRegularGas != want { + t.Fatalf("value CALL with burnt stipend = %d, want %d", res.UsedRegularGas, want) + } +} + +// TestEIP8038Create checks that CREATE and CREATE2 always pay CREATE_ACCESS +// in regular gas. With otherwise identical initcode, CREATE2 additionally has +// one salt push and the address-hash word charge. +func TestEIP8038Create(t *testing.T) { + create, _, err := run8038(t, deployCode(deploy0Init, false, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + create2, _, err := run8038(t, deployCode(deploy0Init, true, 0), hugeBudget(), new(uint256.Int), nil) + if err != nil { + t.Fatal(err) + } + // Outer setup is PUSH32 + PUSH1 + MSTORE (including one-word expansion), + // then three CREATE operands. The child initcode is two PUSH1s and RETURN. + const outer = uint64(3 + 3 + 3 + 3 + 3*3) + const init = uint64(2 * 3) + want := outer + params.CreateAccessAmsterdam + params.InitCodeWordGas + init + if create.UsedRegularGas != want { + t.Fatalf("CREATE regular gas = %d, want %d", create.UsedRegularGas, want) + } + if want := create.UsedRegularGas + 3 + params.Keccak256WordGas; create2.UsedRegularGas != want { + t.Fatalf("CREATE2 regular gas = %d, want %d", create2.UsedRegularGas, want) + } +} + // TestEIP8038SelfdestructAccountWrite checks that SELFDESTRUCT sending a positive // balance to an empty account is charged the cold access, an additional // ACCOUNT_WRITE (regular) and GAS_NEW_ACCOUNT (state).