diff --git a/core/bal_test.go b/core/bal_test.go index 6b79f86c43..84579ca564 100644 --- a/core/bal_test.go +++ b/core/bal_test.go @@ -946,6 +946,62 @@ func TestBALSelfDestructPreExistingContract(t *testing.T) { } } +// TestBALSelfDestructToSelfKeepsBalance: under EIP-8246 a freshly created +// contract that self-destructs to itself keeps its balance (it is not burnt and +// the account is not removed). The surviving balance-only account must therefore +// be recorded in the BAL with its preserved balance. +func TestBALSelfDestructToSelfKeepsBalance(t *testing.T) { + env := newBALTestEnv(nil) + // Init code: ADDRESS SELFDESTRUCT — the contract self-destructs to itself + // during its own creation transaction (satisfying EIP-6780's same-tx rule). + // ADDRESS (0x30) ; SELFDESTRUCT (0xff) + init := []byte{0x30, 0xff} + + b, receipts := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, nil, big.NewInt(100), 1_000_000, 0, init)) + }) + + created := receipts[0].ContractAddress + cc := assertPresent(t, b, created) + // EIP-8246: balance preserved (not burnt), account survives -> the BAL must + // record the created address with its retained balance. + if len(cc.BalanceChanges) != 1 || cc.BalanceChanges[0].PostBalance.Uint64() != 100 { + t.Fatalf("self-destruct-to-self must preserve balance 100 in the BAL: %+v", cc.BalanceChanges) + } +} + +// TestBALSelfDestructToSelfPrefundedUnchanged: a pre-funded address onto which a +// contract is deployed and which self-destructs to itself in the same +// transaction. Under EIP-8246 the account survives with its balance unchanged, +// so the BAL must list it only as an access (no balance/nonce/code change). +func TestBALSelfDestructToSelfPrefundedUnchanged(t *testing.T) { + // The contract address created by the sender's nonce-0 transaction; it is + // pre-funded in genesis (balance only: nonce 0, no code, no storage), which + // EIP-7610 permits as a deployment target. + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + created := crypto.CreateAddress(crypto.PubkeyToAddress(key.PublicKey), 0) + + env := newBALTestEnv(types.GenesisAlloc{ + created: {Balance: big.NewInt(77)}, + }) + // Init code: ADDRESS SELFDESTRUCT, deployed with zero value so the balance is + // untouched (stays at the pre-funded 77). + init := []byte{0x30, 0xff} + + b, receipts := env.run(t, func(g *BlockGen) { + g.AddTx(env.tx(0, nil, big.NewInt(0), 1_000_000, 0, init)) + }) + + if receipts[0].ContractAddress != created { + t.Fatalf("unexpected created address: have %x want %x", receipts[0].ContractAddress, created) + } + aa := assertPresent(t, b, created) + // EIP-8246: balance preserved and equal to the pre-transaction value, so no + // balance change; nonce and code end where they started (0 / empty). The + // account is only read, with an empty change set. + assertEmpty(t, aa) +} + // ============================== Mid-tx balance round-trip ============================== // TestBALMidTxBalanceRoundTrip: when an address's balance changes during a diff --git a/core/eip8246_test.go b/core/eip8246_test.go new file mode 100644 index 0000000000..2fd39e3d44 --- /dev/null +++ b/core/eip8246_test.go @@ -0,0 +1,105 @@ +// 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/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" +) + +// 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). +// +// https://eips.ethereum.org/EIPS/eip-8246 +func TestEIP8246SelfdestructNoBurn(t *testing.T) { + var ( + key1, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + addr1 = crypto.PubkeyToAddress(key1.PublicKey) + config = *params.MergedTestChainConfig + 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") + ) + // TODO: drop this hacky Amsterdam config initialization once the final + // Amsterdam config is available (mirrors TestEthTransferLogs). + config.AmsterdamTime = new(uint64) + blobConfig := *config.BlobScheduleConfig + blobConfig.Amsterdam = blobConfig.Osaka + config.BlobScheduleConfig = &blobConfig + + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{ + addr1: {Balance: newGwei(1_000_000_000)}, + }, + } + // The contract created by addr1's first (nonce 0) transaction. + created := crypto.CreateAddress(addr1, 0) + + db, blocks, _ := GenerateChainWithGenesis(gspec, engine, 1, func(i int, b *BlockGen) { + tx := types.MustSignNewTx(key1, signer, &types.DynamicFeeTx{ + ChainID: gspec.Config.ChainID, + Nonce: 0, + To: nil, // contract creation + Gas: 1_000_000, + GasFeeCap: newGwei(5), + GasTipCap: newGwei(5), + Value: value, + Data: initcode, + }) + b.AddTx(tx) + }) + + chain, err := NewBlockChain(db, gspec, engine, nil) + if err != nil { + t.Fatalf("failed to create chain: %v", err) + } + defer chain.Stop() + // Read the post-state of the generated block directly. InsertChain is avoided + // on purpose: it would additionally verify the EIP-7928 block access list, + // which the chain-generation harness on this branch does not yet populate + // consistently — an orthogonal concern to the EIP-8246 state semantics under + // test here. + state, err := chain.StateAt(blocks[0].Header()) + if err != nil { + t.Fatalf("failed to obtain block state: %v", err) + } + // EIP-8246: the self-destructed, freshly-created contract keeps its balance + // rather than burning it, so the account survives. + if got := state.GetBalance(created); got.ToBig().Cmp(value) != 0 { + t.Errorf("created account balance = %v, want %v (EIP-8246: balance must be preserved, not burned)", got, value) + } + // It survives as a balance-only account: nonce reset to 0 and no code. + if got := state.GetNonce(created); got != 0 { + t.Errorf("created account nonce = %d, want 0", got) + } + if got := state.GetCodeSize(created); got != 0 { + t.Errorf("created account code size = %d, want 0 (code must be cleared)", got) + } +} diff --git a/core/state/statedb.go b/core/state/statedb.go index 1c49d46020..176445a575 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -764,50 +764,15 @@ func (s *StateDB) GetRefund() uint64 { return s.refund } -type removedAccountWithBalance struct { - address common.Address - balance *uint256.Int -} - -// LogsForBurnAccounts returns the eth burn logs for accounts scheduled for -// removal which still have positive balance. The purpose of this function is -// to handle a corner case of EIP-7708 where a self-destructed account might -// still receive funds between sending/burning its previous balance and actual -// removal. In this case the burning of these remaining balances still need to -// be logged. -// Specification EIP-7708: https://eips.ethereum.org/EIPS/eip-7708 -// -// This function should only be invoked at the transaction boundary, specifically -// before the Finalise. -func (s *StateDB) LogsForBurnAccounts() []*types.Log { - var list []removedAccountWithBalance - for addr := range s.journal.mutations { - if obj, exist := s.stateObjects[addr]; exist && obj.selfDestructed && !obj.Balance().IsZero() { - list = append(list, removedAccountWithBalance{ - address: obj.address, - balance: obj.Balance(), - }) - } - } - if list == nil { - return nil - } - sort.Slice(list, func(i, j int) bool { - return list[i].address.Cmp(list[j].address) < 0 - }) - logs := make([]*types.Log, len(list)) - for i, acct := range list { - logs[i] = types.EthBurnLog(acct.address, acct.balance) - } - return logs -} - // Finalise finalises the state by removing the destructed objects and clears // the journal as well as the refunds. Finalise, however, will not push any updates // into the tries just yet. Only IntermediateRoot or Commit will do that. func (s *StateDB) Finalise(deleteEmptyObjects bool) *bal.ConstructionBlockAccessList { + if s.stateAccessList != nil { + return s.finaliseAmsterdam(deleteEmptyObjects) + } addressesToPrefetch := make([]common.Address, 0, len(s.journal.mutations)) - for addr, state := range s.journal.mutations { + for addr := range s.journal.mutations { obj, exist := s.stateObjects[addr] if !exist { // RIPEMD160 (0x03) gets an extra dirty marker for a historical @@ -831,46 +796,103 @@ func (s *StateDB) Finalise(deleteEmptyObjects bool) *bal.ConstructionBlockAccess if _, ok := s.stateObjectsDestruct[obj.address]; !ok { s.stateObjectsDestruct[obj.address] = obj } - // Aggregate the account mutation into the block-level accessList - // if Amsterdam has been activated. - if s.stateAccessList != nil { - // Notably, if the account is deleted during the transaction, - // its pre-transaction nonce, code, and storage must be empty. - // - // EIP-6780 restricts self-destruct to contracts deployed within - // the same transaction, while EIP-7610 rejects deployments to - // destinations with non-empty storage, non-zero nonce and non-empty - // code. - // - // Therefore, when an account is deleted, its pre-transaction nonce - // code and storage is guaranteed to be empty, leaving nothing to - // clean up here. - balance := uint256.NewInt(0) - if state.balanceSet && balance.Cmp(state.balance) != 0 { - s.stateAccessList.BalanceChange(s.blockAccessIndex, addr, balance) - } - } } else { - // Aggregate the account mutation into the block-level accessList - // if Amsterdam has been activated. - if s.stateAccessList != nil { - balance := obj.Balance() - if state.balanceSet && balance.Cmp(state.balance) != 0 { - s.stateAccessList.BalanceChange(s.blockAccessIndex, addr, balance) - } - nonce := obj.Nonce() - if state.nonceSet && nonce != state.nonce { - s.stateAccessList.NonceChange(addr, s.blockAccessIndex, nonce) - } - if state.codeSet { - if code := obj.Code(); !bytes.Equal(code, state.code) { - s.stateAccessList.CodeChange(addr, s.blockAccessIndex, code) - } - } - } obj.finalise() s.markUpdate(addr) } + addressesToPrefetch = append(addressesToPrefetch, addr) // Copy needed for closure + } + if s.prefetcher != nil && len(addressesToPrefetch) > 0 { + if err := s.prefetcher.prefetch(common.Hash{}, s.originalRoot, common.Address{}, addressesToPrefetch, nil, false); err != nil { + log.Error("Failed to prefetch addresses", "addresses", len(addressesToPrefetch), "err", err) + } + } + // Invalidate journal because reverting across transactions is not allowed. + s.clearJournalAndRefund() + + return nil +} + +func (s *StateDB) recordAccessListChanges(addr common.Address, state *journalMutationState) { + var ( + balance = uint256.NewInt(0) + nonce uint64 + ) + obj := s.stateObjects[addr] // nil when the account was removed + if obj != nil { + balance, nonce = obj.Balance(), obj.Nonce() + } + if state.balanceSet && balance.Cmp(state.balance) != 0 { + s.stateAccessList.BalanceChange(s.blockAccessIndex, addr, balance) + } + if state.nonceSet && nonce != state.nonce { + s.stateAccessList.NonceChange(addr, s.blockAccessIndex, nonce) + } + if state.codeSet { + var code []byte + if obj != nil { + code = obj.Code() + } + if !bytes.Equal(code, state.code) { + s.stateAccessList.CodeChange(addr, s.blockAccessIndex, code) + } + } +} + +// finaliseAmsterdam is the Amsterdam-and-later variant of Finalise. +func (s *StateDB) finaliseAmsterdam(deleteEmptyObjects bool) *bal.ConstructionBlockAccessList { + addressesToPrefetch := make([]common.Address, 0, len(s.journal.mutations)) + for addr, state := range s.journal.mutations { + obj, exist := s.stateObjects[addr] + if !exist { + // RIPEMD160 (0x03) gets an extra dirty marker for a historical + // mainnet consensus exception (at block 1714175, in tx + // 0x1237f737031e40bcde4a8b7e717b2d15e3ecadfe49bb1bbc71ee9deb09c6fcf2) + // around empty-account touch/revert handling. + // + // That marker survives journal revert, so the account may remain in + // s.journal.mutations even though its state object was rolled + // back and no longer exists. In that case there is nothing to + // finalise or delete, so ignore it here. + continue + } + switch { + case obj.selfDestructed: + // EIP-8264: accounts marked for self-destruction, instead of + // being deleted, are modified as follows: + // - nonce is reset to 0, + // - balance is unchanged, + // - code is cleared, + // - all storage is cleared + if !obj.Balance().IsZero() { + o := newObject(s, obj.address, obj.origin) + o.setBalance(new(uint256.Int).Set(obj.Balance())) + s.setStateObject(o) + s.markUpdate(addr) + } else { + delete(s.stateObjects, obj.address) + s.markDelete(addr) + if _, ok := s.stateObjectsDestruct[obj.address]; !ok { + s.stateObjectsDestruct[obj.address] = obj + } + } + + case deleteEmptyObjects && obj.empty(): + // EIP-161: a touched, empty account is removed. + delete(s.stateObjects, obj.address) + s.markDelete(addr) + if _, ok := s.stateObjectsDestruct[obj.address]; !ok { + s.stateObjectsDestruct[obj.address] = obj + } + + default: + obj.finalise() + s.markUpdate(addr) + } + // Aggregate the resulting account metadata change + // into the block-level access list. + s.recordAccessListChanges(addr, state) + // At this point, also ship the address off to the precacher. The precacher // will start loading tries, and when the change is eventually committed, // the commit-phase will be a lot faster diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index 98d01343a4..73e8253311 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -230,10 +230,6 @@ func (s *hookedStateDB) AddLog(log *types.Log) { } } -func (s *hookedStateDB) LogsForBurnAccounts() []*types.Log { - return s.inner.LogsForBurnAccounts() -} - func (s *hookedStateDB) Finalise(deleteEmptyObjects bool) *bal.ConstructionBlockAccessList { if s.hooks.OnBalanceChange == nil && s.hooks.OnNonceChangeV2 == nil && s.hooks.OnNonceChange == nil && s.hooks.OnCodeChangeV2 == nil && s.hooks.OnCodeChange == nil { // Short circuit if no relevant hooks are set. @@ -256,18 +252,24 @@ func (s *hookedStateDB) Finalise(deleteEmptyObjects bool) *bal.ConstructionBlock return bytes.Compare(selfDestructedAddrs[i][:], selfDestructedAddrs[j][:]) < 0 }) + // EIP-8246 (Amsterdam) removes the SELFDESTRUCT burn: a self-destructed + // account that retains a non-zero balance is preserved as a balance-only + // account rather than removed, so its balance is no longer burnt. + burnsBalance := s.inner.stateAccessList == nil + for _, addr := range selfDestructedAddrs { obj := s.inner.stateObjects[addr] // Bingo: state object was self-destructed, call relevant hooks. - // If ether was sent to account post-selfdestruct, record as burnt. - if s.hooks.OnBalanceChange != nil { + if burnsBalance && s.hooks.OnBalanceChange != nil { if bal := obj.Balance(); bal.Sign() != 0 { s.hooks.OnBalanceChange(addr, bal.ToBig(), new(big.Int), tracing.BalanceDecreaseSelfdestructBurn) } } // Nonce is set to reset on self-destruct. + // + // TODO(rjl) shall we emit the nonce change if the pre-tx nonce was zero? if s.hooks.OnNonceChangeV2 != nil { s.hooks.OnNonceChangeV2(addr, obj.Nonce(), 0, tracing.NonceChangeSelfdestruct) } else if s.hooks.OnNonceChange != nil { diff --git a/core/state_transition.go b/core/state_transition.go index 3b1642e0b9..2923e110c7 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -790,12 +790,6 @@ func (st *stateTransition) execute() (*ExecutionResult, error) { } } - // EIP-7708: Emit the ETH-burn logs - if rules.IsAmsterdam { - for _, log := range st.evm.StateDB.LogsForBurnAccounts() { - st.evm.StateDB.AddLog(log) - } - } return &ExecutionResult{ UsedGas: gasUsed, MaxUsedGas: peakUsed, diff --git a/core/types/log.go b/core/types/log.go index 487ca57b5a..3ea1e0db7c 100644 --- a/core/types/log.go +++ b/core/types/log.go @@ -79,17 +79,3 @@ func EthTransferLog(from, to common.Address, amount *uint256.Int) *Log { Data: amount32[:], } } - -// EthBurnLog creates an ETH burn log according to EIP-7708. -// Specification: https://eips.ethereum.org/EIPS/eip-7708 -func EthBurnLog(from common.Address, amount *uint256.Int) *Log { - amount32 := amount.Bytes32() - return &Log{ - Address: params.SystemAddress, - Topics: []common.Hash{ - params.EthBurnLogEvent, - common.BytesToHash(from.Bytes()), - }, - Data: amount32[:], - } -} diff --git a/core/vm/instructions.go b/core/vm/instructions.go index b20cce4c0d..328623848e 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -928,8 +928,15 @@ func opSelfdestruct6780(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, erro if newContract { if this != beneficiary { // Skip no-op transfer when self-destructing to self. evm.StateDB.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct) + evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct) + } else if !evm.chainRules.IsAmsterdam { + // Self-destructing to self burns the balance prior to EIP-8246. + // EIP-8246 (Amsterdam) removes this burn: the balance is left + // untouched and the account is preserved as a balance-only account + // at transaction finalization (unless its balance is zero, in which + // case EIP-161 deletes it). + evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct) } - evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct) evm.StateDB.SelfDestruct(this) } @@ -938,12 +945,10 @@ func opSelfdestruct6780(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, erro evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct) evm.StateDB.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct) } - if evm.chainRules.IsAmsterdam && !balance.IsZero() { - if this != beneficiary { - evm.StateDB.AddLog(types.EthTransferLog(this, beneficiary, balance)) - } else if newContract { - evm.StateDB.AddLog(types.EthBurnLog(this, balance)) - } + // EIP-7708: emit a transfer log for the moved balance. EIP-8246 removes the + // SELFDESTRUCT burn entirely, so there is no longer a burn to log. + if evm.chainRules.IsAmsterdam && !balance.IsZero() && this != beneficiary { + evm.StateDB.AddLog(types.EthTransferLog(this, beneficiary, balance)) } if tracer := evm.Config.Tracer; tracer != nil { diff --git a/core/vm/interface.go b/core/vm/interface.go index a9938c2a28..5bba39069c 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -90,7 +90,6 @@ type StateDB interface { Snapshot() int AddLog(*types.Log) - LogsForBurnAccounts() []*types.Log AddPreimage(common.Hash, []byte) Witness() *stateless.Witness