diff --git a/consensus/beacon/consensus.go b/consensus/beacon/consensus.go
index 0dff36a29c..4237418e73 100644
--- a/consensus/beacon/consensus.go
+++ b/consensus/beacon/consensus.go
@@ -356,7 +356,7 @@ func (beacon *Beacon) Finalize(chain consensus.ChainHeaderReader, header *types.
prev := state.AddBalance(w.Address, amount, tracing.BalanceIncreaseWithdrawal)
// Populate the block-level accessList if Amsterdam is enabled
- if bal != nil {
+ if chain.Config().IsAmsterdam(header.Number, header.Time) {
if w.Amount == 0 {
// Zero amount withdrawal, account is accessed potential
// without state changes.
diff --git a/core/bal_test.go b/core/bal_test.go
new file mode 100644
index 0000000000..f0b9dc6443
--- /dev/null
+++ b/core/bal_test.go
@@ -0,0 +1,1319 @@
+// 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 (
+ "bytes"
+ "crypto/ecdsa"
+ "maps"
+ "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/types"
+ "github.com/ethereum/go-ethereum/core/types/bal"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/holiman/uint256"
+)
+
+// EIP-7928 BAL inclusion tests.
+//
+// Each test exercises a single rule from the spec and asserts both presence
+// and absence in the resulting block access list.
+
+// balChainConfig returns a MergedTestChainConfig clone with Amsterdam active from genesis.
+func balChainConfig() *params.ChainConfig {
+ cfg := *params.MergedTestChainConfig
+ cfg.AmsterdamTime = new(uint64)
+ blob := *cfg.BlobScheduleConfig
+ blob.Amsterdam = blob.Osaka
+ cfg.BlobScheduleConfig = &blob
+ return &cfg
+}
+
+// balTestEnv bundles common identities used across the tests.
+type balTestEnv struct {
+ cfg *params.ChainConfig
+ signer types.Signer
+ key *ecdsa.PrivateKey
+ from common.Address
+ gspec *Genesis
+}
+
+// newBALTestEnv builds an Amsterdam chain config, funds a sender and pre-deploys
+// the EIP-7928 system contracts. Extra accounts can be merged into Alloc.
+func newBALTestEnv(extra types.GenesisAlloc) *balTestEnv {
+ cfg := balChainConfig()
+ key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ from := crypto.PubkeyToAddress(key.PublicKey)
+
+ alloc := types.GenesisAlloc{
+ from: {Balance: newGwei(1_000_000_000)},
+ params.BeaconRootsAddress: {Nonce: 1, Code: params.BeaconRootsCode, Balance: common.Big0},
+ params.HistoryStorageAddress: {Nonce: 1, Code: params.HistoryStorageCode, Balance: common.Big0},
+ params.WithdrawalQueueAddress: {Nonce: 1, Code: params.WithdrawalQueueCode, Balance: common.Big0},
+ params.ConsolidationQueueAddress: {Nonce: 1, Code: params.ConsolidationQueueCode, Balance: common.Big0},
+ }
+ maps.Copy(alloc, extra)
+ return &balTestEnv{
+ cfg: cfg,
+ signer: types.LatestSigner(cfg),
+ key: key,
+ from: from,
+ gspec: &Genesis{Config: cfg, Alloc: alloc},
+ }
+}
+
+// run generates exactly one Amsterdam block and returns its BAL.
+func (e *balTestEnv) run(t *testing.T, gen func(*BlockGen)) (*bal.BlockAccessList, types.Receipts) {
+ t.Helper()
+ engine := beacon.New(ethash.NewFaker())
+ _, blocks, receipts := GenerateChainWithGenesis(e.gspec, engine, 1, func(_ int, b *BlockGen) {
+ gen(b)
+ })
+ if blocks[0].AccessList() == nil {
+ t.Fatal("expected non-nil block access list")
+ }
+ return blocks[0].AccessList(), receipts[0]
+}
+
+// --- assertion helpers ---
+
+func findAccount(b *bal.BlockAccessList, addr common.Address) *bal.AccountAccess {
+ for i := range *b {
+ if (*b)[i].Address == addr {
+ return &(*b)[i]
+ }
+ }
+ return nil
+}
+
+func hasSlotIn(slots []*uint256.Int, key common.Hash) bool {
+ want := new(uint256.Int).SetBytes(key[:])
+ for _, s := range slots {
+ if s.Cmp(want) == 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func hasStorageWrite(b *bal.BlockAccessList, addr common.Address, key common.Hash) bool {
+ aa := findAccount(b, addr)
+ if aa == nil {
+ return false
+ }
+ want := new(uint256.Int).SetBytes(key[:])
+ for _, w := range aa.StorageWrites {
+ if w.Slot.Cmp(want) == 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func assertPresent(t *testing.T, b *bal.BlockAccessList, addr common.Address) *bal.AccountAccess {
+ t.Helper()
+ aa := findAccount(b, addr)
+ if aa == nil {
+ t.Fatalf("address %x missing from BAL\n%s", addr, b.PrettyPrint())
+ }
+ return aa
+}
+
+func assertAbsent(t *testing.T, b *bal.BlockAccessList, addr common.Address) {
+ t.Helper()
+ if findAccount(b, addr) != nil {
+ t.Fatalf("address %x must NOT be in BAL\n%s", addr, b.PrettyPrint())
+ }
+}
+
+func assertEmpty(t *testing.T, aa *bal.AccountAccess) {
+ t.Helper()
+ if len(aa.StorageWrites) != 0 || len(aa.StorageReads) != 0 ||
+ len(aa.BalanceChanges) != 0 || len(aa.NonceChanges) != 0 || len(aa.CodeChanges) != 0 {
+ t.Fatalf("expected empty change set for %x, got %+v", aa.Address, aa)
+ }
+}
+
+// --- tx builders ---
+
+func (e *balTestEnv) tx(nonce uint64, to *common.Address, value *big.Int, gas uint64, tipGwei int64, data []byte) *types.Transaction {
+ return types.MustSignNewTx(e.key, e.signer, &types.DynamicFeeTx{
+ ChainID: e.cfg.ChainID,
+ Nonce: nonce,
+ To: to,
+ Value: value,
+ Gas: gas,
+ GasFeeCap: newGwei(10),
+ GasTipCap: newGwei(tipGwei),
+ Data: data,
+ })
+}
+
+// ============================== Account inclusion ==============================
+
+// TestBALTxSenderAndRecipient: a value transfer records balance+nonce for sender
+// and a balance entry for the recipient.
+func TestBALTxSenderAndRecipient(t *testing.T) {
+ to := common.HexToAddress("0xc0ffee")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &to, big.NewInt(1000), params.TxGas, 0, nil))
+ })
+
+ sender := assertPresent(t, b, env.from)
+ if len(sender.NonceChanges) == 0 || sender.NonceChanges[0].Nonce != 1 {
+ t.Fatalf("sender nonce not bumped: %+v", sender.NonceChanges)
+ }
+ if len(sender.BalanceChanges) == 0 {
+ t.Fatalf("sender missing balance change")
+ }
+ recipient := assertPresent(t, b, to)
+ if len(recipient.BalanceChanges) != 1 || recipient.BalanceChanges[0].Balance.Uint64() != 1000 {
+ t.Fatalf("recipient balance: %+v", recipient.BalanceChanges)
+ }
+}
+
+// TestBALZeroValueRecipient: a tx with value 0 still lists the recipient,
+// but without a balance entry.
+func TestBALZeroValueRecipient(t *testing.T) {
+ to := common.HexToAddress("0x0123456789abcdef")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &to, big.NewInt(0), params.TxGas, 0, nil))
+ })
+
+ r := assertPresent(t, b, to)
+ if len(r.BalanceChanges) != 0 {
+ t.Fatalf("zero-value recipient should have no balance entry: %+v", r.BalanceChanges)
+ }
+}
+
+// TestBALEmptyBlockExcludesCoinbase: an empty block (no txs, no withdrawals)
+// never touches the coinbase, so it must NOT appear in the BAL — the zero
+// block reward alone does not trigger inclusion.
+func TestBALEmptyBlockExcludesCoinbase(t *testing.T) {
+ coinbase := common.Address{0xc0}
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ // SetCoinbase initialises b.bal but does not record any access.
+ g.SetCoinbase(coinbase)
+ })
+ 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")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.SetCoinbase(coinbase)
+ g.AddTx(env.tx(0, &to, big.NewInt(0), params.TxGas, 2 /* gwei tip */, nil))
+ })
+
+ cb := assertPresent(t, b, coinbase)
+ if len(cb.BalanceChanges) == 0 || cb.BalanceChanges[0].Balance.Sign() == 0 {
+ t.Fatalf("coinbase missing positive balance change: %+v", cb.BalanceChanges)
+ }
+}
+
+// TestBALSystemAddressExcluded: SYSTEM_ADDRESS (0xff…fe) is not in the BAL
+// for a regular block.
+func TestBALSystemAddressExcluded(t *testing.T) {
+ to := common.HexToAddress("0xabba")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &to, big.NewInt(0), params.TxGas, 0, nil))
+ })
+ assertAbsent(t, b, params.SystemAddress)
+}
+
+// TestBALSystemAddressIncludedWhenTouched: SYSTEM_ADDRESS becomes a regular
+// account in the BAL once it experiences state access (here: receives value).
+func TestBALSystemAddressIncludedWhenTouched(t *testing.T) {
+ sys := params.SystemAddress
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &sys, big.NewInt(1000), params.TxGas, 0, nil))
+ })
+
+ aa := assertPresent(t, b, sys)
+ if len(aa.BalanceChanges) != 1 || aa.BalanceChanges[0].Balance.Uint64() != 1000 {
+ t.Fatalf("system-address balance change missing: %+v", aa.BalanceChanges)
+ }
+}
+
+// TestBALPrecompileInvokedFromContractIncluded: a precompile that is invoked
+// indirectly — via STATICCALL from a regular contract — must still appear in
+// the BAL with no balance entry.
+func TestBALPrecompileInvokedFromContractIncluded(t *testing.T) {
+ identity := common.BytesToAddress([]byte{0x04})
+ caller := common.HexToAddress("0xca11")
+ // PUSH1 0 (retSize) PUSH1 0 (retOff) PUSH1 0 (argsSize) PUSH1 0 (argsOff)
+ // PUSH20 0x04 GAS STATICCALL POP STOP
+ code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x73}
+ code = append(code, identity.Bytes()...)
+ code = append(code, 0x5a, 0xfa, 0x50, 0x00)
+
+ env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}})
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, identity)
+ if len(aa.BalanceChanges) != 0 {
+ t.Fatalf("precompile invoked via STATICCALL must not record balance: %+v", aa.BalanceChanges)
+ }
+}
+
+// TestBALPrecompileCalledNoValueIncluded: a tx targeting the identity precompile
+// with zero value lists the precompile but records no balance entry.
+func TestBALPrecompileCalledNoValueIncluded(t *testing.T) {
+ identity := common.BytesToAddress([]byte{0x04})
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &identity, big.NewInt(0), 50_000, 0, []byte{0xde, 0xad}))
+ })
+
+ aa := assertPresent(t, b, identity)
+ if len(aa.BalanceChanges) != 0 {
+ t.Fatalf("precompile must not record balance change: %+v", aa.BalanceChanges)
+ }
+}
+
+// TestBALPrecompileValueTransferRecordsBalance: a precompile receives ETH only
+// in the form of a value transfer — the balance entry is then recorded.
+func TestBALPrecompileValueTransferRecordsBalance(t *testing.T) {
+ identity := common.BytesToAddress([]byte{0x04})
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &identity, big.NewInt(5), 50_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, identity)
+ if len(aa.BalanceChanges) != 1 || aa.BalanceChanges[0].Balance.Uint64() != 5 {
+ t.Fatalf("precompile balance change wrong: %+v", aa.BalanceChanges)
+ }
+}
+
+// TestBALBalanceProbeOnNonExistent: BALANCE against a never-allocated address
+// still adds it to the BAL with an empty change set.
+func TestBALBalanceProbeOnNonExistent(t *testing.T) {
+ probe := common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
+ caller := common.HexToAddress("0xc1")
+ code := append([]byte{0x73}, probe.Bytes()...) // PUSH20 probe
+ code = append(code, 0x31, 0x50, 0x00) // BALANCE, POP, STOP
+
+ env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}})
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ assertEmpty(t, assertPresent(t, b, probe))
+}
+
+// TestBALExtCodeSizeProbeOnNonExistent: EXTCODESIZE against a never-allocated
+// address adds it to the BAL with an empty change set.
+func TestBALExtCodeSizeProbeOnNonExistent(t *testing.T) {
+ probe := common.HexToAddress("0xcafecafecafecafecafecafecafecafecafecafe")
+ caller := common.HexToAddress("0xc1")
+ code := append([]byte{0x73}, probe.Bytes()...) // PUSH20 probe
+ code = append(code, 0x3b, 0x50, 0x00) // EXTCODESIZE, POP, STOP
+
+ env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}})
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ assertEmpty(t, assertPresent(t, b, probe))
+}
+
+// TestBALExtCodeHashProbeOnNonExistent: EXTCODEHASH against a never-allocated
+// address adds it to the BAL with an empty change set.
+func TestBALExtCodeHashProbeOnNonExistent(t *testing.T) {
+ probe := common.HexToAddress("0xfacefacefacefacefacefacefacefacefacefacE")
+ caller := common.HexToAddress("0xc1")
+ code := append([]byte{0x73}, probe.Bytes()...) // PUSH20 probe
+ code = append(code, 0x3f, 0x50, 0x00) // EXTCODEHASH, POP, STOP
+
+ env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}})
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ assertEmpty(t, assertPresent(t, b, probe))
+}
+
+// TestBALExtCodeCopyProbeOnNonExistent: EXTCODECOPY against a never-allocated
+// address adds it to the BAL with an empty change set.
+func TestBALExtCodeCopyProbeOnNonExistent(t *testing.T) {
+ probe := common.HexToAddress("0xfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed")
+ caller := common.HexToAddress("0xc1")
+ // PUSH1 0 (length) PUSH1 0 (codeOffset) PUSH1 0 (destOffset)
+ // PUSH20 probe EXTCODECOPY STOP
+ code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x73}
+ code = append(code, probe.Bytes()...)
+ code = append(code, 0x3c, 0x00) // EXTCODECOPY, STOP
+
+ env := newBALTestEnv(types.GenesisAlloc{caller: {Code: code, Balance: common.Big0}})
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ assertEmpty(t, assertPresent(t, b, probe))
+}
+
+// TestBALAccessListNotAutoPromoted: an EIP-2930 access-list entry that is
+// never actually touched must NOT appear in the BAL.
+func TestBALAccessListNotAutoPromoted(t *testing.T) {
+ to := common.HexToAddress("0xabba")
+ dormant := common.HexToAddress("0xd0d0")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ tx := types.MustSignNewTx(env.key, env.signer, &types.DynamicFeeTx{
+ ChainID: env.cfg.ChainID,
+ Nonce: 0,
+ To: &to,
+ Value: big.NewInt(0),
+ Gas: params.TxGas + 4000,
+ GasFeeCap: newGwei(10),
+ GasTipCap: newGwei(0),
+ AccessList: types.AccessList{{Address: dormant, StorageKeys: nil}},
+ })
+ g.AddTx(tx)
+ })
+
+ assertAbsent(t, b, dormant)
+}
+
+// ============================== CALL family ==============================
+
+// makeStubCaller emits a single CALL-family op against `target` then STOPs,
+// with zero call data and discarded return data.
+//
+// op = 0xf1 (CALL) / 0xf2 (CALLCODE):
+// stack = retSize, retOff, argsSize, argsOff, value, addr, gas
+// op = 0xf4 (DELEGATECALL) / 0xfa (STATICCALL):
+// stack = retSize, retOff, argsSize, argsOff, addr, gas
+func makeStubCaller(op byte, target common.Address) []byte {
+ // retSize, retOff, argsSize, argsOff = 0
+ prelude := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00}
+ if op == 0xf1 || op == 0xf2 { // CALL/CALLCODE need an extra value=0
+ prelude = append(prelude, 0x60, 0x00)
+ }
+ prelude = append(prelude, 0x73) // PUSH20
+ prelude = append(prelude, target.Bytes()...)
+ prelude = append(prelude, 0x5a) // GAS
+ prelude = append(prelude, op)
+ prelude = append(prelude, 0x50, 0x00) // POP, STOP
+ return prelude
+}
+
+// TestBALCallTargetWithEmptyChangeSet: a zero-value CALL to an existing
+// contract that has no state changes lists the target with empty entries.
+func TestBALCallTargetWithEmptyChangeSet(t *testing.T) {
+ target := common.HexToAddress("0xbabe")
+ env := newBALTestEnv(types.GenesisAlloc{
+ target: {Code: []byte{0x00}, Balance: common.Big0}, // STOP
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &target, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ assertEmpty(t, assertPresent(t, b, target))
+}
+
+// TestBALCallCodeTargetIncluded: CALLCODE puts the target in the BAL with an
+// empty change set (CALLCODE executes target's code in the caller's storage
+// context, so the target itself records no state changes).
+func TestBALCallCodeTargetIncluded(t *testing.T) {
+ target := common.HexToAddress("0xdeed")
+ caller := common.HexToAddress("0xca11")
+ env := newBALTestEnv(types.GenesisAlloc{
+ caller: {Code: makeStubCaller(0xf2 /* CALLCODE */, target), Balance: common.Big0},
+ target: {Code: []byte{0x00}, Balance: common.Big0},
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 200_000, 0, nil))
+ })
+
+ assertPresent(t, b, caller)
+ assertEmpty(t, assertPresent(t, b, target))
+}
+
+// TestBALDelegateCallTargetIncluded: DELEGATECALL puts both caller and target
+// in the BAL even when neither produces state changes.
+func TestBALDelegateCallTargetIncluded(t *testing.T) {
+ target := common.HexToAddress("0xdeed")
+ caller := common.HexToAddress("0xca11")
+ env := newBALTestEnv(types.GenesisAlloc{
+ caller: {Code: makeStubCaller(0xf4 /* DELEGATECALL */, target), Balance: common.Big0},
+ target: {Code: []byte{0x00}, Balance: common.Big0},
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 200_000, 0, nil))
+ })
+
+ assertPresent(t, b, caller)
+ assertEmpty(t, assertPresent(t, b, target))
+}
+
+// TestBALStaticCallTargetIncluded: STATICCALL puts the target in the BAL with
+// no balance entry recorded.
+func TestBALStaticCallTargetIncluded(t *testing.T) {
+ target := common.HexToAddress("0xdeed")
+ caller := common.HexToAddress("0xca11")
+ env := newBALTestEnv(types.GenesisAlloc{
+ caller: {Code: makeStubCaller(0xfa /* STATICCALL */, target), Balance: common.Big0},
+ target: {Code: []byte{0x00}, Balance: common.Big0},
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &caller, big.NewInt(0), 200_000, 0, nil))
+ })
+
+ assertPresent(t, b, caller)
+ assertEmpty(t, assertPresent(t, b, target))
+}
+
+// ============================== Revert behaviour ==============================
+
+// TestBALRevertedTxStillIncluded: a tx whose top-level call REVERTs still
+// records the touched contract in the BAL with an empty change set.
+func TestBALRevertedTxStillIncluded(t *testing.T) {
+ reverter := common.HexToAddress("0xbeef")
+ // PUSH1 0 PUSH1 0 REVERT
+ revertCode := []byte{0x60, 0x00, 0x60, 0x00, 0xfd}
+ env := newBALTestEnv(types.GenesisAlloc{reverter: {Code: revertCode, Balance: common.Big0}})
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &reverter, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ assertEmpty(t, assertPresent(t, b, reverter))
+}
+
+// TestBALSenderRecordedOnRevert: even when the top-level call reverts, the
+// sender's final nonce and balance MUST be recorded.
+func TestBALSenderRecordedOnRevert(t *testing.T) {
+ reverter := common.HexToAddress("0xbeef")
+ revertCode := []byte{0x60, 0x00, 0x60, 0x00, 0xfd}
+ env := newBALTestEnv(types.GenesisAlloc{reverter: {Code: revertCode, Balance: common.Big0}})
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &reverter, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ sender := assertPresent(t, b, env.from)
+ if len(sender.NonceChanges) == 0 || sender.NonceChanges[0].Nonce != 1 {
+ t.Fatalf("sender nonce must be bumped even on revert: %+v", sender.NonceChanges)
+ }
+ if len(sender.BalanceChanges) == 0 {
+ t.Fatalf("sender balance change (gas paid) must be present on revert")
+ }
+}
+
+// ============================== 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), 100_000, 0, nil))
+ })
+
+ 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")
+ }
+}
+
+// TestBALStorageSloadOnly: SLOAD without a write puts the slot in storage_reads.
+func TestBALStorageSloadOnly(t *testing.T) {
+ contract := common.HexToAddress("0xc1")
+ slot := common.BigToHash(big.NewInt(0x07))
+ // PUSH1 0x07 SLOAD POP STOP
+ code := []byte{0x60, 0x07, 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), 100_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, contract)
+ if !hasSlotIn(aa.StorageReads, slot) {
+ t.Fatalf("expected slot in storage_reads\n%s", b.PrettyPrint())
+ }
+ if hasStorageWrite(b, contract, slot) {
+ t.Fatalf("slot must NOT appear in storage_changes")
+ }
+}
+
+// TestBALStorageReadThenWriteOnlyInWrites: SLOAD followed by SSTORE on the
+// same slot drops the slot from storage_reads (write-wins invariant).
+func TestBALStorageReadThenWriteOnlyInWrites(t *testing.T) {
+ contract := common.HexToAddress("0xc1")
+ slot := common.BigToHash(big.NewInt(0x05))
+ // PUSH1 5 SLOAD POP PUSH1 0x42 PUSH1 5 SSTORE STOP
+ code := []byte{
+ 0x60, 0x05, 0x54, 0x50,
+ 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), 100_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, contract)
+ if !hasStorageWrite(b, contract, slot) {
+ t.Fatalf("slot must be in storage_changes\n%s", b.PrettyPrint())
+ }
+ if hasSlotIn(aa.StorageReads, slot) {
+ t.Fatalf("slot must NOT appear in storage_reads (write-wins)\n%s", b.PrettyPrint())
+ }
+}
+
+// TestBALNoOpSSTOREDemotesToRead: an SSTORE whose value equals the committed
+// value lands the slot in storage_reads only.
+func TestBALNoOpSSTOREDemotesToRead(t *testing.T) {
+ contract := common.HexToAddress("0xc1")
+ slot := common.BigToHash(big.NewInt(0x09))
+ // SSTORE(0x09, 0x42) — slot pre-state is 0x42, so the write is a no-op.
+ code := []byte{0x60, 0x42, 0x60, 0x09, 0x55, 0x00}
+ env := newBALTestEnv(types.GenesisAlloc{
+ contract: {
+ Code: code,
+ Balance: common.Big0,
+ Storage: map[common.Hash]common.Hash{slot: common.BigToHash(big.NewInt(0x42))},
+ },
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, contract)
+ if !hasSlotIn(aa.StorageReads, slot) {
+ t.Fatalf("no-op SSTORE should leave slot in storage_reads\n%s", b.PrettyPrint())
+ }
+ if hasStorageWrite(b, contract, slot) {
+ t.Fatalf("no-op SSTORE must NOT register a write")
+ }
+}
+
+// TestBALStorageWriteZeroIsAWrite: writing 0 to a non-zero slot is still a
+// state change and lands in storage_changes.
+func TestBALStorageWriteZeroIsAWrite(t *testing.T) {
+ contract := common.HexToAddress("0xc1")
+ slot := common.BigToHash(big.NewInt(0x03))
+ // PUSH1 0 PUSH1 3 SSTORE STOP
+ code := []byte{0x60, 0x00, 0x60, 0x03, 0x55, 0x00}
+ env := newBALTestEnv(types.GenesisAlloc{
+ contract: {
+ Code: code,
+ Balance: common.Big0,
+ Storage: map[common.Hash]common.Hash{slot: common.BigToHash(big.NewInt(0x42))},
+ },
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &contract, big.NewInt(0), 100_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, contract)
+ if !hasStorageWrite(b, contract, slot) {
+ t.Fatalf("SSTORE to zero must record a write\n%s", b.PrettyPrint())
+ }
+ for _, w := range aa.StorageWrites {
+ if w.Slot.Uint64() == 0x03 {
+ if len(w.Accesses) != 1 || !w.Accesses[0].ValueAfter.IsZero() {
+ t.Fatalf("expected post-value 0 for slot 0x03, got %+v", w.Accesses)
+ }
+ }
+ }
+}
+
+// ============================== CREATE / contract deployment ==============================
+
+// TestBALCreateDeploysCode: a successful contract-creation tx records the new
+// address with nonce 0→1, a balance entry (value transferred), and a code entry.
+func TestBALCreateDeploysCode(t *testing.T) {
+ env := newBALTestEnv(nil)
+ // Init: deploy runtime [0x00] (single STOP byte).
+ // PUSH1 0 PUSH1 0 MSTORE8 PUSH1 1 PUSH1 0 RETURN
+ init := []byte{0x60, 0x00, 0x60, 0x00, 0x53, 0x60, 0x01, 0x60, 0x00, 0xf3}
+
+ b, receipts := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(7), 200_000, 0, init))
+ })
+
+ created := receipts[0].ContractAddress
+ aa := assertPresent(t, b, created)
+ if len(aa.NonceChanges) != 1 || aa.NonceChanges[0].Nonce != 1 {
+ t.Fatalf("expected nonce 0→1, got %+v", aa.NonceChanges)
+ }
+ if len(aa.CodeChanges) != 1 || !bytes.Equal(aa.CodeChanges[0].Code, []byte{0x00}) {
+ t.Fatalf("expected code [0x00], got %+v", aa.CodeChanges)
+ }
+ if len(aa.BalanceChanges) != 1 || aa.BalanceChanges[0].Balance.Uint64() != 7 {
+ t.Fatalf("expected balance 7, got %+v", aa.BalanceChanges)
+ }
+}
+
+// TestBALCreateEmptyRuntimeNoCodeEntry: when init code returns 0 bytes the
+// new address is still listed with nonce 0→1 but no code entry.
+func TestBALCreateEmptyRuntimeNoCodeEntry(t *testing.T) {
+ env := newBALTestEnv(nil)
+ // Init: PUSH1 0 PUSH1 0 RETURN → returns 0 bytes
+ init := []byte{0x60, 0x00, 0x60, 0x00, 0xf3}
+
+ b, receipts := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init))
+ })
+
+ created := receipts[0].ContractAddress
+ aa := assertPresent(t, b, created)
+ if len(aa.NonceChanges) != 1 || aa.NonceChanges[0].Nonce != 1 {
+ t.Fatalf("expected nonce 0→1, got %+v", aa.NonceChanges)
+ }
+ if len(aa.CodeChanges) != 0 {
+ t.Fatalf("empty runtime must NOT record a code entry, got %+v", aa.CodeChanges)
+ }
+}
+
+// TestBALCreateInitRevertEmptyChangeSet: when init code reverts, the would-be
+// contract address is in the BAL with an empty change set.
+func TestBALCreateInitRevertEmptyChangeSet(t *testing.T) {
+ env := newBALTestEnv(nil)
+ // PUSH1 0 PUSH1 0 REVERT
+ init := []byte{0x60, 0x00, 0x60, 0x00, 0xfd}
+
+ b, receipts := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init))
+ })
+
+ created := receipts[0].ContractAddress
+ assertEmpty(t, assertPresent(t, b, created))
+}
+
+// TestBALCreateInitOOGEmptyChangeSet: init code that runs out of gas leaves
+// the deployed address in the BAL with an empty change set.
+func TestBALCreateInitOOGEmptyChangeSet(t *testing.T) {
+ env := newBALTestEnv(nil)
+ // Infinite loop: JUMPDEST PUSH1 0 JUMP — burns gas until OOG.
+ init := []byte{0x5b, 0x60, 0x00, 0x56}
+
+ b, receipts := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(0), 60_000, 0, init))
+ })
+
+ created := receipts[0].ContractAddress
+ assertEmpty(t, assertPresent(t, b, created))
+}
+
+// TestBALCreateAddressCollisionStillIncluded: when CREATE targets an address
+// that already holds a contract, the deployment fails but the address was
+// probed during execution and MUST appear in the BAL with an empty change set.
+func TestBALCreateAddressCollisionStillIncluded(t *testing.T) {
+ env := newBALTestEnv(nil)
+ // For a top-level CREATE tx the deployed address is CreateAddress(sender, 0).
+ // Pre-allocate a contract at that address to provoke ErrContractAddressCollision.
+ collide := crypto.CreateAddress(env.from, 0)
+ env.gspec.Alloc[collide] = types.Account{
+ Nonce: 1,
+ Code: []byte{0x00},
+ Balance: common.Big0,
+ }
+
+ // Init code doesn't matter — execution never starts.
+ init := []byte{0x60, 0x00, 0x60, 0x00, 0xf3}
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init))
+ })
+
+ aa := assertPresent(t, b, collide)
+ // The address must be present but the pre-existing nonce/code MUST NOT
+ // be overwritten by the failed creation.
+ if len(aa.NonceChanges) != 0 {
+ t.Fatalf("collision must not bump nonce: %+v", aa.NonceChanges)
+ }
+ if len(aa.CodeChanges) != 0 {
+ t.Fatalf("collision must not write code: %+v", aa.CodeChanges)
+ }
+}
+
+// TestBALInEVMCreatePreAccessAbortDestinationExcluded: if a CREATE frame
+// aborts BEFORE the destination is read from state (here: the caller has 0
+// balance and CREATE requests value > 0, tripping evm.create's CanTransfer
+// check before GetCodeHash), the would-be address MUST NOT appear in the
+// BAL — only "if target account is accessed" qualifies for inclusion.
+func TestBALInEVMCreatePreAccessAbortDestinationExcluded(t *testing.T) {
+ factory := common.HexToAddress("0xfac4")
+ // PUSH1 0 (length) PUSH1 0 (offset) PUSH1 1 (value) CREATE POP STOP
+ code := []byte{0x60, 0x00, 0x60, 0x00, 0x60, 0x01, 0xf0, 0x50, 0x00}
+ env := newBALTestEnv(types.GenesisAlloc{
+ factory: {Code: code, Balance: common.Big0, Nonce: 1}, // factory has no balance
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &factory, big.NewInt(0), 200_000, 0, nil))
+ })
+
+ // The address that WOULD have been deployed had the create succeeded.
+ wouldBeDest := crypto.CreateAddress(factory, 1)
+ assertAbsent(t, b, wouldBeDest)
+
+ // The factory itself is in BAL (it ran), but its nonce MUST NOT have been
+ // bumped because evm.create returned before the SetNonce call.
+ aa := assertPresent(t, b, factory)
+ if len(aa.NonceChanges) != 0 {
+ t.Fatalf("factory nonce must not be bumped on pre-access abort: %+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) {
+ factory := common.HexToAddress("0xfac4")
+ // Factory code:
+ // Write 5-byte init code (0x60 0x00 0x60 0x00 0xf3) into memory starting at offset 0.
+ // Then CREATE(value=0, offset=0, length=5).
+ //
+ // Layout: store the init code as a single 32-byte word at offset 0 via MSTORE
+ // with leftmost 27 bytes garbage, then call CREATE with offset = 27, length = 5.
+ initBlob := []byte{0x60, 0x00, 0x60, 0x00, 0xf3}
+ var word [32]byte
+ copy(word[32-len(initBlob):], initBlob)
+ code := []byte{0x7f} // PUSH32
+ code = append(code, word[:]...)
+ code = append(code, 0x60, 0x00, 0x52) // PUSH1 0, MSTORE
+ // CREATE expects [value, offset, length] with value on bottom of stack.
+ code = append(code,
+ 0x60, 0x05, // PUSH1 5 (length)
+ 0x60, 0x1b, // PUSH1 27 (offset)
+ 0x60, 0x00, // PUSH1 0 (value)
+ 0xf0, // CREATE
+ 0x00, // STOP (discard result)
+ )
+
+ env := newBALTestEnv(types.GenesisAlloc{factory: {Code: code, Balance: common.Big0, Nonce: 1}})
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &factory, big.NewInt(0), 300_000, 0, nil))
+ })
+
+ // Deployed address depends on the factory's nonce at the moment of CREATE,
+ // which is the factory's genesis nonce (1).
+ deployed := crypto.CreateAddress(factory, 1)
+ aa := assertPresent(t, b, deployed)
+ if len(aa.NonceChanges) != 1 || aa.NonceChanges[0].Nonce != 1 {
+ t.Fatalf("deployed contract nonce: %+v", aa.NonceChanges)
+ }
+}
+
+// ============================== SELFDESTRUCT ==============================
+
+// TestBALSelfDestructBeneficiaryWithZeroBalance: SELFDESTRUCT to a fresh
+// beneficiary when the destructing account has 0 balance — both addresses are
+// listed with empty change sets (no balance entry).
+func TestBALSelfDestructBeneficiaryWithZeroBalance(t *testing.T) {
+ beneficiary := common.HexToAddress("0xbeefbeef")
+ env := newBALTestEnv(nil)
+ // Init code performs SELFDESTRUCT to beneficiary inside the constructor,
+ // so EIP-6780's same-tx requirement is satisfied. The destructing account
+ // starts with balance 0 because the creation tx sends 0 value.
+ // PUSH20 SELFDESTRUCT
+ init := append([]byte{0x73}, beneficiary.Bytes()...)
+ init = append(init, 0xff)
+
+ b, receipts := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(0), 200_000, 0, init))
+ })
+
+ created := receipts[0].ContractAddress
+ ben := assertPresent(t, b, beneficiary)
+ if len(ben.BalanceChanges) != 0 {
+ t.Fatalf("zero-value SELFDESTRUCT must not credit beneficiary: %+v", ben.BalanceChanges)
+ }
+ cc := assertPresent(t, b, created)
+ if len(cc.BalanceChanges) != 0 {
+ t.Fatalf("destructing contract must not record a balance entry: %+v", cc.BalanceChanges)
+ }
+}
+
+// TestBALSelfDestructBeneficiaryWithValueTransfer: SELFDESTRUCT from a freshly
+// created contract that received positive value — beneficiary records the
+// credit; destructing account's balance entry is omitted because its
+// pre-transaction balance was 0.
+func TestBALSelfDestructBeneficiaryWithValueTransfer(t *testing.T) {
+ beneficiary := common.HexToAddress("0xbeefbeef")
+ env := newBALTestEnv(nil)
+ // Init code: PUSH20 SELFDESTRUCT
+ init := append([]byte{0x73}, beneficiary.Bytes()...)
+ init = append(init, 0xff)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, nil, big.NewInt(100), 200_000, 0, init))
+ })
+
+ ben := assertPresent(t, b, beneficiary)
+ if len(ben.BalanceChanges) != 1 || ben.BalanceChanges[0].Balance.Uint64() != 100 {
+ t.Fatalf("beneficiary balance must be credited with 100: %+v", ben.BalanceChanges)
+ }
+}
+
+// TestBALSelfDestructPreExistingContract: SELFDESTRUCT on a pre-existing
+// contract with positive balance records balance→0 for the contract and the
+// corresponding credit on the beneficiary. EIP-6780 means the contract is
+// only credited and not deleted, but its balance moves regardless.
+func TestBALSelfDestructPreExistingContract(t *testing.T) {
+ suicidal := common.HexToAddress("0x5e1f")
+ beneficiary := common.HexToAddress("0xbeefbeef")
+ // PUSH20 SELFDESTRUCT
+ code := append([]byte{0x73}, beneficiary.Bytes()...)
+ code = append(code, 0xff)
+
+ env := newBALTestEnv(types.GenesisAlloc{
+ suicidal: {Code: code, Balance: big.NewInt(50)},
+ })
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &suicidal, big.NewInt(0), 200_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, suicidal)
+ if len(aa.BalanceChanges) != 1 || !aa.BalanceChanges[0].Balance.IsZero() {
+ t.Fatalf("suicidal contract balance should drop to 0: %+v", aa.BalanceChanges)
+ }
+ ben := assertPresent(t, b, beneficiary)
+ if len(ben.BalanceChanges) != 1 || ben.BalanceChanges[0].Balance.Uint64() != 50 {
+ t.Fatalf("beneficiary should receive 50: %+v", ben.BalanceChanges)
+ }
+}
+
+// ============================== Mid-tx balance round-trip ==============================
+
+// TestBALMidTxBalanceRoundTrip: when an address's balance changes during a
+// transaction but returns to its pre-transaction value, the address is still
+// listed in the BAL but MUST NOT have a balance entry.
+func TestBALMidTxBalanceRoundTrip(t *testing.T) {
+ bouncer := common.HexToAddress("0xb0unce")
+ // On receiving value, the bouncer immediately CALLs CALLER with CALLVALUE
+ // and zero data. Net effect: bouncer.balance returns to its pre-tx value.
+ //
+ // PUSH1 0 (retSize)
+ // PUSH1 0 (retOff)
+ // PUSH1 0 (argsSize)
+ // PUSH1 0 (argsOff)
+ // CALLVALUE
+ // CALLER
+ // GAS
+ // CALL
+ // POP
+ // STOP
+ code := []byte{
+ 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00,
+ 0x34, // CALLVALUE
+ 0x33, // CALLER
+ 0x5a, // GAS
+ 0xf1, // CALL
+ 0x50, // POP
+ 0x00, // STOP
+ }
+ env := newBALTestEnv(types.GenesisAlloc{bouncer: {Code: code, Balance: common.Big0}})
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.tx(0, &bouncer, big.NewInt(1234), 200_000, 0, nil))
+ })
+
+ aa := assertPresent(t, b, bouncer)
+ if len(aa.BalanceChanges) != 0 {
+ t.Fatalf("mid-tx round-trip must not record a balance entry: %+v", aa.BalanceChanges)
+ }
+}
+
+// ============================== System contracts (pre/post-execution) ==============================
+
+// TestBALSystemContractsPresent: per EIP-7928, "System contract addresses
+// accessed during pre/post-execution" MUST be included in the BAL. That
+// means all four of the post-merge system contracts touched by every
+// Amsterdam block:
+//
+// - EIP-4788 beacon roots (pre-execution, when ParentBeaconRoot is set)
+// - EIP-2935 history storage (pre-execution)
+// - EIP-7002 withdrawal queue (post-execution)
+// - EIP-7251 consolidation queue (post-execution)
+func TestBALSystemContractsPresent(t *testing.T) {
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ // SetCoinbase initialises b.bal; SetParentBeaconRoot triggers EIP-4788.
+ g.SetCoinbase(common.Address{0xc0})
+ g.SetParentBeaconRoot(common.Hash{0xbe, 0xac})
+ })
+
+ for _, sys := range []struct {
+ name string
+ addr common.Address
+ }{
+ {"BeaconRoots (4788)", params.BeaconRootsAddress},
+ {"HistoryStorage (2935)", params.HistoryStorageAddress},
+ {"WithdrawalQueue (7002)", params.WithdrawalQueueAddress},
+ {"ConsolidationQueue (7251)", params.ConsolidationQueueAddress},
+ } {
+ if findAccount(b, sys.addr) == nil {
+ t.Errorf("%s (%x) MUST appear in BAL but is missing\n%s", sys.name, sys.addr, b.PrettyPrint())
+ }
+ }
+}
+
+// ============================== Withdrawals ==============================
+
+// TestBALWithdrawalZeroAmountIncluded: a withdrawal with amount 0 still puts
+// the recipient in the BAL (with no balance entry).
+func TestBALWithdrawalZeroAmountIncluded(t *testing.T) {
+ recipient := common.HexToAddress("0xdada")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.SetCoinbase(common.Address{0xc0})
+ g.AddWithdrawal(&types.Withdrawal{Validator: 1, Address: recipient, Amount: 0})
+ })
+
+ r := assertPresent(t, b, recipient)
+ if len(r.BalanceChanges) != 0 {
+ t.Fatalf("zero-amount withdrawal must not record balance: %+v", r.BalanceChanges)
+ }
+}
+
+// TestBALWithdrawalNonZeroAmountRecordsBalance: a positive-amount withdrawal
+// records a balance change for the recipient.
+func TestBALWithdrawalNonZeroAmountRecordsBalance(t *testing.T) {
+ recipient := common.HexToAddress("0xdada")
+ env := newBALTestEnv(nil)
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.SetCoinbase(common.Address{0xc0})
+ g.AddWithdrawal(&types.Withdrawal{Validator: 1, Address: recipient, Amount: 7})
+ })
+
+ r := assertPresent(t, b, recipient)
+ if len(r.BalanceChanges) != 1 || r.BalanceChanges[0].Balance.Sign() == 0 {
+ t.Fatalf("withdrawal balance change missing: %+v", r.BalanceChanges)
+ }
+}
+
+// ============================== EIP-7702 authority ==============================
+
+// TestBALAuthorityIncludedOnSetCodeTx: the authority of an EIP-7702 set-code
+// transaction is added to the BAL once its delegation is loaded, recording
+// both the nonce bump and the delegation-pointer code entry.
+func TestBALAuthorityIncludedOnSetCodeTx(t *testing.T) {
+ env := newBALTestEnv(nil)
+ authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ authority := crypto.PubkeyToAddress(authKey.PublicKey)
+ delegate := common.HexToAddress("0xdeadbeef")
+
+ auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: delegate,
+ Nonce: 0,
+ })
+ if err != nil {
+ t.Fatalf("sign auth: %v", err)
+ }
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ tx := types.MustSignNewTx(env.key, env.signer, &types.SetCodeTx{
+ ChainID: uint256.MustFromBig(env.cfg.ChainID),
+ Nonce: 0,
+ To: env.from,
+ Value: new(uint256.Int),
+ Gas: 200_000,
+ GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())),
+ GasTipCap: new(uint256.Int),
+ AuthList: []types.SetCodeAuthorization{auth},
+ })
+ g.AddTx(tx)
+ })
+
+ aa := assertPresent(t, b, authority)
+ if len(aa.NonceChanges) == 0 {
+ t.Fatalf("authority nonce should be bumped by delegation: %+v", aa.NonceChanges)
+ }
+ if len(aa.CodeChanges) == 0 {
+ t.Fatalf("authority code (delegation pointer) should be recorded: %+v", aa.CodeChanges)
+ }
+}
+
+// TestBALDelegationTargetNotIncludedOnAuthOnly: the EIP-7702 delegation target
+// MUST NOT appear in the BAL when only the authorization is installed and the
+// target is never loaded as an execution target.
+func TestBALDelegationTargetNotIncludedOnAuthOnly(t *testing.T) {
+ env := newBALTestEnv(nil)
+ authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ delegate := common.HexToAddress("0xdeadbeef") // never accessed
+
+ auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: delegate,
+ Nonce: 0,
+ })
+ if err != nil {
+ t.Fatalf("sign auth: %v", err)
+ }
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ tx := types.MustSignNewTx(env.key, env.signer, &types.SetCodeTx{
+ ChainID: uint256.MustFromBig(env.cfg.ChainID),
+ Nonce: 0,
+ To: env.from, // tx.to is an EOA with no code: delegate is never called
+ Value: new(uint256.Int),
+ Gas: 200_000,
+ GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())),
+ GasTipCap: new(uint256.Int),
+ AuthList: []types.SetCodeAuthorization{auth},
+ })
+ g.AddTx(tx)
+ })
+
+ assertAbsent(t, b, delegate)
+}
+
+// newSetCodeTx is a small constructor used by the multi-auth tests below.
+func (e *balTestEnv) newSetCodeTx(t *testing.T, nonce uint64, to common.Address, auths []types.SetCodeAuthorization) *types.Transaction {
+ t.Helper()
+ tx, err := types.SignTx(types.NewTx(&types.SetCodeTx{
+ ChainID: uint256.MustFromBig(e.cfg.ChainID),
+ Nonce: nonce,
+ To: to,
+ Value: new(uint256.Int),
+ Gas: 400_000,
+ GasFeeCap: uint256.NewInt(uint64(newGwei(10).Int64())),
+ GasTipCap: new(uint256.Int),
+ AuthList: auths,
+ }), e.signer, e.key)
+ if err != nil {
+ t.Fatalf("sign SetCodeTx: %v", err)
+ }
+ return tx
+}
+
+// TestBALAuthFailedBeforeLoadExcluded: an EIP-7702 auth whose ChainID check
+// fails returns before the authority is loaded, so the authority address
+// MUST NOT appear in the BAL.
+func TestBALAuthFailedBeforeLoadExcluded(t *testing.T) {
+ env := newBALTestEnv(nil)
+ authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ authority := crypto.PubkeyToAddress(authKey.PublicKey)
+
+ auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.NewInt(999), // wrong chain → fails ChainID check (pre-load)
+ Address: common.HexToAddress("0xdeadbeef"),
+ Nonce: 0,
+ })
+ 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}))
+ })
+
+ assertAbsent(t, b, authority)
+}
+
+// TestBALAuthFailedAfterLoadEmptyChangeSet: an EIP-7702 auth that fails the
+// nonce check happens AFTER the authority's code is loaded (and the address
+// added to accessed_addresses), so the authority MUST appear in the BAL —
+// but with no nonce or code change.
+func TestBALAuthFailedAfterLoadEmptyChangeSet(t *testing.T) {
+ env := newBALTestEnv(nil)
+ authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ authority := crypto.PubkeyToAddress(authKey.PublicKey)
+
+ // The authority's actual nonce is 0; supplying auth.Nonce=99 makes
+ // validation fail only after the code has been loaded.
+ auth, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: common.HexToAddress("0xdeadbeef"),
+ Nonce: 99,
+ })
+ 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) != 0 {
+ t.Fatalf("failed auth must not bump nonce: %+v", aa.NonceChanges)
+ }
+ if len(aa.CodeChanges) != 0 {
+ t.Fatalf("failed auth must not record a code change: %+v", aa.CodeChanges)
+ }
+}
+
+// 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) {
+ env := newBALTestEnv(nil)
+ goodKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ badKey, _ := crypto.HexToECDSA("0303030303030303030303030303030303030303030303030303003030303030")
+ good := crypto.PubkeyToAddress(goodKey.PublicKey)
+ bad := crypto.PubkeyToAddress(badKey.PublicKey)
+ delegate := common.HexToAddress("0xdeadbeef")
+
+ goodAuth, err := types.SignSetCode(goodKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: delegate,
+ Nonce: 0,
+ })
+ if err != nil {
+ t.Fatalf("sign good auth: %v", err)
+ }
+ badAuth, err := types.SignSetCode(badKey, types.SetCodeAuthorization{
+ ChainID: *uint256.NewInt(999), // fails before load
+ Address: delegate,
+ Nonce: 0,
+ })
+ if err != nil {
+ t.Fatalf("sign bad auth: %v", err)
+ }
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.newSetCodeTx(t, 0, env.from, []types.SetCodeAuthorization{goodAuth, badAuth}))
+ })
+
+ assertPresent(t, b, good) // loaded → in BAL
+ assertAbsent(t, b, bad) // never loaded → not in BAL
+}
+
+// TestBALAuthCodeRoundTripNoCodeEntry: two auths on the same authority that
+// (1) install a delegation and (2) clear it again. Final code equals pre-tx
+// code (empty), so the BAL records only the cumulative nonce bump and NO
+// code change.
+func TestBALAuthCodeRoundTripNoCodeEntry(t *testing.T) {
+ env := newBALTestEnv(nil)
+ authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ authority := crypto.PubkeyToAddress(authKey.PublicKey)
+ delegateA := common.HexToAddress("0xa11ce")
+
+ auth1, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: delegateA, // empty → A
+ Nonce: 0,
+ })
+ if err != nil {
+ t.Fatalf("sign auth1: %v", err)
+ }
+ auth2, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: common.Address{}, // delegation to zero clears the code (A → empty)
+ Nonce: 1,
+ })
+ if err != nil {
+ t.Fatalf("sign auth2: %v", err)
+ }
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.newSetCodeTx(t, 0, env.from, []types.SetCodeAuthorization{auth1, auth2}))
+ })
+
+ aa := assertPresent(t, b, authority)
+ if len(aa.NonceChanges) != 1 || aa.NonceChanges[0].Nonce != 2 {
+ t.Fatalf("expected final nonce 2, got %+v", aa.NonceChanges)
+ }
+ if len(aa.CodeChanges) != 0 {
+ t.Fatalf("code round-trip (empty→A→empty) must NOT record a code change: %+v", aa.CodeChanges)
+ }
+}
+
+// TestBALAuthCodeOverwrittenFinalRecorded: two auths on the same authority
+// switching delegation A → B record exactly one code change carrying the
+// final delegation pointer (B), not the intermediate value.
+func TestBALAuthCodeOverwrittenFinalRecorded(t *testing.T) {
+ env := newBALTestEnv(nil)
+ authKey, _ := crypto.HexToECDSA("0202020202020202020202020202020202020202020202020202002020202020")
+ authority := crypto.PubkeyToAddress(authKey.PublicKey)
+ delegateA := common.HexToAddress("0xa11ce")
+ delegateB := common.HexToAddress("0xb0b0b0")
+
+ auth1, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: delegateA,
+ Nonce: 0,
+ })
+ if err != nil {
+ t.Fatalf("sign auth1: %v", err)
+ }
+ auth2, err := types.SignSetCode(authKey, types.SetCodeAuthorization{
+ ChainID: *uint256.MustFromBig(env.cfg.ChainID),
+ Address: delegateB,
+ Nonce: 1,
+ })
+ if err != nil {
+ t.Fatalf("sign auth2: %v", err)
+ }
+
+ b, _ := env.run(t, func(g *BlockGen) {
+ g.AddTx(env.newSetCodeTx(t, 0, env.from, []types.SetCodeAuthorization{auth1, auth2}))
+ })
+
+ aa := assertPresent(t, b, authority)
+ if len(aa.CodeChanges) != 1 {
+ t.Fatalf("expected exactly 1 code change (final), got %+v", aa.CodeChanges)
+ }
+ want := types.AddressToDelegation(delegateB)
+ if !bytes.Equal(aa.CodeChanges[0].Code, want) {
+ t.Fatalf("final code mismatch: want %x, got %x", want, aa.CodeChanges[0].Code)
+ }
+ if len(aa.NonceChanges) != 1 || aa.NonceChanges[0].Nonce != 2 {
+ t.Fatalf("expected final nonce 2, got %+v", aa.NonceChanges)
+ }
+}
diff --git a/core/chain_makers.go b/core/chain_makers.go
index 8bab68d131..2e856b5161 100644
--- a/core/chain_makers.go
+++ b/core/chain_makers.go
@@ -67,7 +67,6 @@ func (b *BlockGen) SetCoinbase(addr common.Address) {
}
b.header.Coinbase = addr
b.gasPool = NewGasPool(b.header.GasLimit)
- b.bal = bal.NewConstructionBlockAccessList()
}
// SetExtra sets the extra data field of the generated block.
@@ -359,6 +358,7 @@ func GenerateChain(config *params.ChainConfig, parent *types.Block, engine conse
genblock := func(i int, parent *types.Block, triedb *triedb.Database, statedb *state.StateDB) (*types.Block, types.Receipts) {
b := &BlockGen{i: i, cm: cm, parent: parent, statedb: statedb, engine: engine}
b.header = cm.makeHeader(parent, statedb, b.engine)
+ b.bal = bal.NewConstructionBlockAccessList()
// Set the difficulty for clique block. The chain maker doesn't have access
// to a chain, so the difficulty will be left unset (nil). Set it here to the
diff --git a/core/state_processor.go b/core/state_processor.go
index eec5be9ff8..a230a79fac 100644
--- a/core/state_processor.go
+++ b/core/state_processor.go
@@ -119,7 +119,8 @@ func (p *StateProcessor) Process(ctx context.Context, block *types.Block, stated
}
blockAccessList.Merge(bal)
- // Finalize the block, applying any consensus engine specific extras (e.g. block rewards)
+ // Finalize the block, applying any consensus engine specific extras
+ // (e.g. block rewards).
//
// TODO(rjl493456442) integrate it into the PostExecution.
p.chain.Engine().Finalize(p.chain, header, tracingStateDB, block.Body(), uint32(len(block.Transactions())+1), blockAccessList)
@@ -165,17 +166,19 @@ func PostExecution(ctx context.Context, config *params.ChainConfig, number *big.
}
// Read requests if Prague is enabled.
if config.IsPrague(number, time) {
+ rules := config.Rules(number, true, time) // IsMerge is always true
+
requests = [][]byte{}
// EIP-6110
if err := ParseDepositLogs(&requests, allLogs, config); err != nil {
return nil, nil, fmt.Errorf("failed to parse deposit logs: %w", err)
}
// EIP-7002
- if err := ProcessWithdrawalQueue(&requests, evm, blockAccessIndex, blockAccessList); err != nil {
+ if err := ProcessWithdrawalQueue(&requests, rules, evm, blockAccessIndex, blockAccessList); err != nil {
return nil, nil, fmt.Errorf("failed to process withdrawal queue: %w", err)
}
// EIP-7251
- if err := ProcessConsolidationQueue(&requests, evm, blockAccessIndex, blockAccessList); err != nil {
+ if err := ProcessConsolidationQueue(&requests, rules, evm, blockAccessIndex, blockAccessList); err != nil {
return nil, nil, fmt.Errorf("failed to process consolidation queue: %w", err)
}
}
@@ -284,6 +287,7 @@ func ProcessBeaconBlockRoot(beaconRoot common.Hash, evm *vm.EVM, blockAccessList
Data: beaconRoot[:],
}
evm.SetTxContext(NewEVMTxContext(msg))
+ evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil)
evm.StateDB.SetTxContext(common.Hash{}, 0, 0)
evm.StateDB.AddAddressToAccessList(params.BeaconRootsAddress)
_, _, _ = evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560)
@@ -312,6 +316,7 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList *
Data: prevHash.Bytes(),
}
evm.SetTxContext(NewEVMTxContext(msg))
+ evm.StateDB.Prepare(evm.GetRules(), common.Address{}, common.Address{}, nil, nil, nil)
evm.StateDB.SetTxContext(common.Hash{}, 0, 0)
evm.StateDB.AddAddressToAccessList(params.HistoryStorageAddress)
_, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560)
@@ -326,17 +331,17 @@ func ProcessParentBlockHash(prevHash common.Hash, evm *vm.EVM, blockAccessList *
// ProcessWithdrawalQueue calls the EIP-7002 withdrawal queue contract.
// It returns the opaque request data returned by the contract.
-func ProcessWithdrawalQueue(requests *[][]byte, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
- return processRequestsSystemCall(requests, evm, 0x01, params.WithdrawalQueueAddress, blockAccessIndex, blockAccessList)
+func ProcessWithdrawalQueue(requests *[][]byte, rules params.Rules, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
+ return processRequestsSystemCall(requests, rules, evm, 0x01, params.WithdrawalQueueAddress, blockAccessIndex, blockAccessList)
}
// ProcessConsolidationQueue calls the EIP-7251 consolidation queue contract.
// It returns the opaque request data returned by the contract.
-func ProcessConsolidationQueue(requests *[][]byte, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
- return processRequestsSystemCall(requests, evm, 0x02, params.ConsolidationQueueAddress, blockAccessIndex, blockAccessList)
+func ProcessConsolidationQueue(requests *[][]byte, rules params.Rules, evm *vm.EVM, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
+ return processRequestsSystemCall(requests, rules, evm, 0x02, params.ConsolidationQueueAddress, blockAccessIndex, blockAccessList)
}
-func processRequestsSystemCall(requests *[][]byte, evm *vm.EVM, requestType byte, addr common.Address, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
+func processRequestsSystemCall(requests *[][]byte, rules params.Rules, evm *vm.EVM, requestType byte, addr common.Address, blockAccessIndex uint32, blockAccessList *bal.ConstructionBlockAccessList) error {
if tracer := evm.Config.Tracer; tracer != nil {
onSystemCallStart(tracer, evm.GetVMContext())
if tracer.OnSystemCallEnd != nil {
@@ -352,6 +357,7 @@ func processRequestsSystemCall(requests *[][]byte, evm *vm.EVM, requestType byte
To: &addr,
}
evm.SetTxContext(NewEVMTxContext(msg))
+ evm.StateDB.Prepare(rules, common.Address{}, common.Address{}, nil, nil, nil)
evm.StateDB.SetTxContext(common.Hash{}, 0, blockAccessIndex)
evm.StateDB.AddAddressToAccessList(addr)
ret, _, err := evm.Call(msg.From, *msg.To, msg.Data, vm.NewGasBudget(30_000_000), common.U2560)
@@ -362,6 +368,8 @@ func processRequestsSystemCall(requests *[][]byte, evm *vm.EVM, requestType byte
if err != nil {
return fmt.Errorf("system call failed to execute: %v", err)
}
+ blockAccessList.Merge(bal)
+
if len(ret) == 0 {
return nil // skip empty output
}
@@ -370,7 +378,6 @@ func processRequestsSystemCall(requests *[][]byte, evm *vm.EVM, requestType byte
requestsData[0] = requestType
copy(requestsData[1:], ret)
*requests = append(*requests, requestsData)
- blockAccessList.Merge(bal)
return nil
}
diff --git a/core/vm/evm.go b/core/vm/evm.go
index 9fe6faa3a2..3b4e9647b5 100644
--- a/core/vm/evm.go
+++ b/core/vm/evm.go
@@ -18,6 +18,7 @@ package vm
import (
"errors"
+ "fmt"
"math/big"
"sync/atomic"
@@ -145,6 +146,9 @@ func NewEVM(blockCtx BlockContext, statedb StateDB, chainConfig *params.ChainCon
jumpDests: newMapJumpDests(),
arena: newArena(),
}
+ if !evm.chainRules.IsAmsterdam {
+ fmt.Println("DEBUG")
+ }
evm.precompiles = activePrecompiledContracts(evm.chainRules)
switch {
@@ -709,3 +713,8 @@ func (evm *EVM) GetVMContext() *tracing.VMContext {
StateDB: evm.StateDB,
}
}
+
+// GetRules returns the chain rules used throughout the EVM execution.
+func (evm *EVM) GetRules() params.Rules {
+ return evm.chainRules
+}