core: implement EIP-8246 (#35219)

EIP: https://eips.ethereum.org/EIPS/eip-8246

Supersedes #35218
This commit is contained in:
rjl493456442 2026-06-30 08:58:43 +08:00 committed by GitHub
parent 9700b5b10e
commit 68671a4530
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 278 additions and 109 deletions

View file

@ -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

105
core/eip8246_test.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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[:],
}
}

View file

@ -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 {

View file

@ -90,7 +90,6 @@ type StateDB interface {
Snapshot() int
AddLog(*types.Log)
LogsForBurnAccounts() []*types.Log
AddPreimage(common.Hash, []byte)
Witness() *stateless.Witness