From bb54996797bbd70d5abe5259de1dd4ce2b6dadf7 Mon Sep 17 00:00:00 2001 From: Daniel Liu <139250065@qq.com> Date: Tue, 20 Oct 2026 17:49:16 +0800 Subject: [PATCH 1/2] feat(core/vm,core/state): implement EIP-8246 SELFDESTRUCT burn removal #35219 EIP-8246 removes the remaining SELFDESTRUCT burns from Amsterdam onwards: - a contract created in the same transaction that executes SELFDESTRUCT with itself as beneficiary keeps its balance instead of burning it; - at transaction finalisation every account marked for destruction is reduced to a balance-only account - nonce reset to 0, code and storage cleared, balance untouched - instead of being deleted. An account left with a zero balance is empty and is still removed by EIP-161. The upstream implementation (go-ethereum #35219) keys both halves on the fork rules at finalisation, but this tree's StateDB carries no rules and its SelfDestruct clears the balance eagerly. The decision is therefore taken where the interpreter already knows the fork: SelfDestruct6780 takes a preserveBalance flag derived from chainRules.IsAmsterdam, the destruct journal entry clears the object flag when it is reverted, and Finalise and hookedStateDB.Finalise read it off the object. The balance move itself stays fork-gated, so the pre-Amsterdam path keeps its historical transfer pair and only Amsterdam drops it. Everything else - gas, the transfer to a distinct beneficiary, readability of the account until the end of the transaction, and the EIP-6780 create-in-same-transaction restriction - is unchanged. A preserved account is tracked in stateObjectsDestruct and rebuilt from an empty object that keeps its pre-block origin, so handleDestruction deletes the old storage trie at commit, exactly like a delete-and-resurrect within one block, and the block-level diff records the account as having existed at block start rather than as a fresh one. Nonce 0, empty code and an empty storage root keep the address usable as a CREATE2 target, as the EIP requires. EIP-7708 is not implemented in this repository, so unlike upstream there is no burn log to remove from the SELFDESTRUCT path. Tests: core/state covers the preserved balance, the zero-balance deletion, ether arriving after the destruct, the destruct-to-elsewhere shape, revert, the pre-Amsterdam burn, the tracer hooks and the block diff of a preserved account; core drives the same contract through block processing with Amsterdam at block 0 and with Amsterdam disabled, checks a same-transaction destruct-to-other followed by refunded funds (7+8 wei from two CALLs), and re-creates a preserved balance-only account through CREATE2 in a later block. core/vm pins the balance-change events of a self-destruct to self on both sides of the fork (two decreases and one increase before Amsterdam, none on Amsterdam), the only observable effect of the interpreter's fork gate. Verified on c2725ee4de (the merge-base): make all and gofmt/goimports are clean, golangci-lint --new-from-rev=c2725ee4de reports 0 issues, and go test ./core/ ./core/state/... ./core/vm/... ./eth/tracers/... ./internal/ethapi/... passes. Closes #2766 Ref: #35219 --- core/eip8246_test.go | 279 ++++++++++++++++++++ core/state/journal.go | 13 + core/state/state_object.go | 15 +- core/state/statedb.go | 90 ++++++- core/state/statedb_eip8246_test.go | 403 +++++++++++++++++++++++++++++ core/state/statedb_hooked.go | 18 +- core/vm/eip8246_test.go | 130 ++++++++++ core/vm/instructions.go | 28 +- core/vm/interface.go | 10 +- 9 files changed, 969 insertions(+), 17 deletions(-) create mode 100644 core/eip8246_test.go create mode 100644 core/state/statedb_eip8246_test.go create mode 100644 core/vm/eip8246_test.go diff --git a/core/eip8246_test.go b/core/eip8246_test.go new file mode 100644 index 000000000000..2d22bcf49f04 --- /dev/null +++ b/core/eip8246_test.go @@ -0,0 +1,279 @@ +// 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 ( + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/consensus/ethash" + "github.com/XinFinOrg/XDPoSChain/core/rawdb" + "github.com/XinFinOrg/XDPoSChain/core/state" + "github.com/XinFinOrg/XDPoSChain/core/types" + "github.com/XinFinOrg/XDPoSChain/crypto" + "github.com/XinFinOrg/XDPoSChain/params" +) + +// eip8246CreateTx returns a transaction that deploys the EIP-8246 probe +// contract: the initcode stores a value in slot 5, then self-destructs with +// itself as the beneficiary, all within the creation transaction. +func eip8246CreateTx(t *testing.T, key *ecdsa.PrivateKey, config *params.ChainConfig, value *big.Int) *types.Transaction { + t.Helper() + + // PUSH1 0x2a; PUSH1 0x05; SSTORE; ADDRESS; SELFDESTRUCT + initcode := []byte{0x60, 0x2a, 0x60, 0x05, 0x55, 0x30, 0xff} + gasPrice := new(big.Int).Mul(big.NewInt(20), big.NewInt(params.GWei)) + tx, err := types.SignTx(types.NewContractCreation(0, value, 1_000_000, gasPrice, initcode), types.LatestSigner(config), key) + if err != nil { + t.Fatalf("failed to sign the creation transaction: %v", err) + } + return tx +} + +// TestEIP8246SelfdestructNoBurn drives the Amsterdam rule through block +// processing: a contract created and self-destructed to itself within the +// creation transaction keeps its balance, while nonce, code and storage are +// cleared at finalisation. +func TestEIP8246SelfdestructNoBurn(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + sender = crypto.PubkeyToAddress(key.PublicKey) + value = big.NewInt(1_000_000) + slot = common.BigToHash(big.NewInt(0x05)) + config = *params.AllEthashProtocolChanges // Amsterdam active from block 0 + created = crypto.CreateAddress(sender, 0) + ) + db := rawdb.NewMemoryDatabase() + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{sender: {Balance: big.NewInt(params.Ether)}}, + } + genesis := gspec.MustCommit(db) + + blocks, receipts := GenerateChain(&config, genesis, ethash.NewFaker(), db, 1, func(i int, b *BlockGen) { + b.AddTx(eip8246CreateTx(t, key, &config, value)) + }) + if status := receipts[0][0].Status; status != types.ReceiptStatusSuccessful { + t.Fatalf("creation transaction failed with status %d", status) + } + statedb, err := state.New(blocks[0].Root(), state.NewDatabase(db)) + if err != nil { + t.Fatalf("failed to open the post-state: %v", err) + } + if !statedb.Exist(created) { + t.Fatalf("created contract was deleted instead of preserved") + } + if got := statedb.GetBalance(created); got.Cmp(value) != 0 { + t.Errorf("created contract balance = %v, want %v", got, value) + } + if got := statedb.GetNonce(created); got != 0 { + t.Errorf("created contract nonce = %d, want 0", got) + } + if code := statedb.GetCode(created); len(code) != 0 { + t.Errorf("created contract code = %x, want empty", code) + } + if got := statedb.GetState(created, slot); got != (common.Hash{}) { + t.Errorf("created contract storage = %x, want empty", got) + } +} + +// TestEIP8246SelfdestructRefunded verifies that ETH sent back to a +// same-transaction selfdestructed account is retained at finalisation instead +// of being dropped with the deleted account. The factory funds the account +// twice after SELFDESTRUCT. +func TestEIP8246SelfdestructRefunded(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + sender = crypto.PubkeyToAddress(key.PublicKey) + factory = common.HexToAddress("0xfac8246") + beneficiary = common.HexToAddress("0xbeef") + config = *params.AllEthashProtocolChanges // Amsterdam active from block 0 + gasPrice = new(big.Int).Mul(big.NewInt(20), big.NewInt(params.GWei)) + ) + // The child initcode selfdestructs to another account. The factory then + // sends it 7 and 8 wei after it is marked for selfdestruction. + childInit := append([]byte{0x73}, beneficiary.Bytes()...) + childInit = append(childInit, 0xff) + var word [32]byte + copy(word[32-len(childInit):], childInit) + factoryCode := append([]byte{0x7f}, word[:]...) + factoryCode = append(factoryCode, 0x5f, 0x52) // PUSH0; MSTORE + // CREATE(value=0, offset=10, length=22), then store the returned address. + factoryCode = append(factoryCode, 0x60, byte(len(childInit)), 0x60, byte(32-len(childInit)), 0x5f, 0xf0, 0x5f, 0x52) + for _, amount := range []byte{7, 8} { + // CALL(child, value=amount) with empty input/output. + factoryCode = append(factoryCode, 0x5f, 0x5f, 0x5f, 0x5f, 0x60, amount, 0x5f, 0x51, 0x5a, 0xf1, 0x50) + } + factoryCode = append(factoryCode, 0x00) + + db := rawdb.NewMemoryDatabase() + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{ + sender: {Balance: big.NewInt(params.Ether)}, + factory: {Nonce: 1, Code: factoryCode, Balance: big.NewInt(15)}, + }, + } + genesis := gspec.MustCommit(db) + + tx, err := types.SignTx(types.NewTransaction(0, factory, big.NewInt(0), 1_000_000, gasPrice, nil), types.LatestSigner(&config), key) + if err != nil { + t.Fatalf("failed to sign the factory transaction: %v", err) + } + blocks, receipts := GenerateChain(&config, genesis, ethash.NewFaker(), db, 1, func(i int, b *BlockGen) { + b.AddTx(tx) + }) + if status := receipts[0][0].Status; status != types.ReceiptStatusSuccessful { + t.Fatalf("factory transaction failed with status %d", status) + } + child := crypto.CreateAddress(factory, 1) + statedb, err := state.New(blocks[0].Root(), state.NewDatabase(db)) + if err != nil { + t.Fatalf("failed to open the post-state: %v", err) + } + if !statedb.Exist(child) { + t.Fatalf("refunded child was deleted instead of preserved") + } + if got := statedb.GetBalance(child); got.Cmp(big.NewInt(15)) != 0 { + t.Errorf("refunded child balance = %v, want 15", got) + } + if got := statedb.GetNonce(child); got != 0 { + t.Errorf("refunded child nonce = %d, want 0", got) + } + if code := statedb.GetCode(child); len(code) != 0 { + t.Errorf("refunded child code = %x, want empty", code) + } +} + +// TestSelfdestructBurnWithoutEIP8246 pins the pre-Amsterdam block processing: +// the same creation transaction burns the balance and removes the account. +func TestSelfdestructBurnWithoutEIP8246(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + sender = crypto.PubkeyToAddress(key.PublicKey) + value = big.NewInt(1_000_000) + config = *params.AllEthashProtocolChanges + created = crypto.CreateAddress(sender, 0) + ) + config.AmsterdamBlock = nil + + db := rawdb.NewMemoryDatabase() + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{sender: {Balance: big.NewInt(params.Ether)}}, + } + genesis := gspec.MustCommit(db) + + blocks, receipts := GenerateChain(&config, genesis, ethash.NewFaker(), db, 1, func(i int, b *BlockGen) { + b.AddTx(eip8246CreateTx(t, key, &config, value)) + }) + if status := receipts[0][0].Status; status != types.ReceiptStatusSuccessful { + t.Fatalf("creation transaction failed with status %d", status) + } + statedb, err := state.New(blocks[0].Root(), state.NewDatabase(db)) + if err != nil { + t.Fatalf("failed to open the post-state: %v", err) + } + if statedb.Exist(created) { + t.Errorf("self-destructed contract survived without EIP-8246") + } + if got := statedb.GetBalance(created); got.Sign() != 0 { + t.Errorf("self-destructed contract balance = %v, want 0", got) + } +} + +// TestEIP8246Create2RecreatesBalanceOnly verifies that an EIP-8246 +// balance-only account does not block recreating the same CREATE2 address in a +// later transaction. The second creation contributes another wei to the +// preserved balance, proving that it executed rather than collided. +func TestEIP8246Create2RecreatesBalanceOnly(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + sender = crypto.PubkeyToAddress(key.PublicKey) + factory = common.HexToAddress("0xfac8246") + config = *params.AllEthashProtocolChanges // Amsterdam active from block 0 + init = []byte{0x30, 0xff} // ADDRESS; SELFDESTRUCT + gasPrice = new(big.Int).Mul(big.NewInt(20), big.NewInt(params.GWei)) + ) + // Factory code: PUSH32 ; PUSH0; MSTORE; PUSH1 1 (salt); PUSH1 2 (len); + // PUSH1 30 (offset); CALLVALUE; CREATE2; POP; STOP. + var word [32]byte + copy(word[32-len(init):], init) + factoryCode := append([]byte{0x7f}, word[:]...) + factoryCode = append(factoryCode, + 0x5f, 0x52, // PUSH0; MSTORE + 0x60, 0x01, // salt + 0x60, byte(len(init)), + 0x60, byte(32-len(init)), + 0x34, // CALLVALUE + 0xf5, // CREATE2 + 0x50, // POP + 0x00, + ) + gspec := &Genesis{ + Config: &config, + Alloc: types.GenesisAlloc{ + sender: {Balance: big.NewInt(params.Ether)}, + factory: {Nonce: 1, Code: factoryCode}, + }, + } + db := rawdb.NewMemoryDatabase() + genesis := gspec.MustCommit(db) + + var salt [32]byte + salt[31] = 1 + child := crypto.CreateAddress2(factory, salt, crypto.Keccak256(init)) + + blocks, receipts := GenerateChain(&config, genesis, ethash.NewFaker(), db, 2, func(i int, b *BlockGen) { + value := big.NewInt(5) + if i == 1 { + value = big.NewInt(1) + } + tx, err := types.SignTx(types.NewTransaction(uint64(i), factory, value, 1_000_000, gasPrice, nil), types.LatestSigner(&config), key) + if err != nil { + t.Fatalf("failed to sign transaction %d: %v", i, err) + } + b.AddTx(tx) + }) + for i, rs := range receipts { + for _, receipt := range rs { + if receipt.Status != types.ReceiptStatusSuccessful { + t.Fatalf("transaction in block %d failed with status %d", i, receipt.Status) + } + } + } + statedb, err := state.New(blocks[1].Root(), state.NewDatabase(db)) + if err != nil { + t.Fatalf("failed to open the post-state: %v", err) + } + // The preserved account keeps 5 from the first block and gains the 1 wei of + // the second creation, so a collision would have left it at 5. + if !statedb.Exist(child) { + t.Fatalf("CREATE2 child does not exist after the second creation") + } + if got := statedb.GetBalance(child); got.Cmp(big.NewInt(6)) != 0 { + t.Errorf("CREATE2 child balance = %v, want 6", got) + } + if got := statedb.GetNonce(child); got != 0 { + t.Errorf("CREATE2 child nonce = %d, want 0", got) + } + if code := statedb.GetCode(child); len(code) != 0 { + t.Errorf("CREATE2 child code = %x, want empty", code) + } +} diff --git a/core/state/journal.go b/core/state/journal.go index 2385e1b24f90..ea8cfc6e3957 100644 --- a/core/state/journal.go +++ b/core/state/journal.go @@ -330,6 +330,10 @@ type ( dirtyCode bool selfDestructed bool newContract bool + // selfDestructedPreserveBalance is the EIP-8246 half of the destruct + // state; it has to travel with selfDestructed or a revert would restore + // an account that is destructed but no longer preserves its balance. + selfDestructedPreserveBalance bool // Block-scoped state as it was before the removal. Every field carries a // had flag so that a missing entry is restored as missing rather than as @@ -364,6 +368,8 @@ func newDeleteAddressChange(obj *stateObject) deleteAddressChange { dirtyCode: obj.dirtyCode, selfDestructed: obj.selfDestructed, newContract: obj.newContract, + + selfDestructedPreserveBalance: obj.selfDestructedPreserveBalance, } ch.prevDestruct, ch.hadDestruct = s.stateObjectsDestruct[obj.address] ch.prevDestruct = copyStateAccount(ch.prevDestruct) @@ -408,6 +414,7 @@ func (ch deleteAddressChange) revert(s *StateDB) { obj.dirtyCode = ch.dirtyCode obj.selfDestructed = ch.selfDestructed obj.newContract = ch.newContract + obj.selfDestructedPreserveBalance = ch.selfDestructedPreserveBalance // Reattach the storage trie the removal orphaned. Without it the restored // object would read and commit against a root whose nodes never reached the // database; see the comment on the type. @@ -477,6 +484,8 @@ func (ch deleteAddressChange) copy() journalEntry { hadStorageOrigin: ch.hadStorageOrigin, prevStorageOrigin: copyStorageMap(ch.prevStorageOrigin), hadMutation: ch.hadMutation, + + selfDestructedPreserveBalance: ch.selfDestructedPreserveBalance, } if ch.prevMutation != nil { cpy.prevMutation = ch.prevMutation.copy() @@ -521,7 +530,11 @@ func (ch createContractChange) copy() journalEntry { func (ch selfDestructChange) revert(s *StateDB) { obj := s.getStateObject(ch.account) if obj != nil { + // Both flags are set together on the first destruct of the transaction + // (see StateDB.selfDestruct), so clearing them restores the pre-destruct + // object without needing to journal the preserveBalance bit itself. obj.selfDestructed = false + obj.selfDestructedPreserveBalance = false } } diff --git a/core/state/state_object.go b/core/state/state_object.go index 3c1587e9bb81..86673084b67d 100644 --- a/core/state/state_object.go +++ b/core/state/state_object.go @@ -72,6 +72,14 @@ type stateObject struct { // object was previously existent and is being deployed as a contract within // the current transaction. newContract bool + + // This is an EIP-8246 flag indicating whether the object's balance must + // survive its destruction. It is set from the preserveBalance argument of + // markSelfdestructed, which core/vm/instructions.go derives from + // chainRules.IsAmsterdam; a destructed account carrying the flag is kept as + // a balance-only account at transaction finalisation instead of being + // deleted. + selfDestructedPreserveBalance bool } // empty returns whether the account is considered empty. @@ -102,8 +110,12 @@ func (s *stateObject) EncodeRLP(w io.Writer) error { return rlp.Encode(w, &s.data) } -func (s *stateObject) markSelfdestructed() { +// markSelfdestructed flags the object as destructed at the end of the current +// transaction. If preserveBalance is set (EIP-8246, Amsterdam), the balance is +// kept and the object survives finalisation as a balance-only account. +func (s *stateObject) markSelfdestructed(preserveBalance bool) { s.selfDestructed = true + s.selfDestructedPreserveBalance = preserveBalance } func (s *stateObject) touch() { @@ -393,6 +405,7 @@ func (s *stateObject) deepCopy(db *StateDB) *stateObject { obj.dirtyStorage = s.dirtyStorage.Copy() obj.dirtyCode = s.dirtyCode obj.selfDestructed = s.selfDestructed + obj.selfDestructedPreserveBalance = s.selfDestructedPreserveBalance obj.newContract = s.newContract return obj } diff --git a/core/state/statedb.go b/core/state/statedb.go index 87d9cd96c8c5..0cc231761b93 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -592,9 +592,24 @@ func (s *StateDB) SetStorage(addr common.Address, storage map[common.Hash]common // SelfDestruct marks the given account as selfdestructed. // This clears the account balance. // +// This is the pre-EIP-6780 entry point: the balance is always burnt and no +// EIP-8246 handling applies, because the method carries no fork rules. It is +// unreachable on an Amsterdam chain, whose instruction set is built on Cancun +// and therefore routes SELFDESTRUCT to SelfDestruct6780, so a new caller must +// go through SelfDestruct6780 with preserveBalance instead of expecting this +// method to follow the fork. +// // The account's state object is still available until the state is committed, // getStateObject will return a non-nil account after SelfDestruct. func (s *StateDB) SelfDestruct(addr common.Address) *big.Int { + return s.selfDestruct(addr, false) +} + +// selfDestruct marks the given account as selfdestructed. The balance is +// cleared unless preserveBalance is set, which selects the EIP-8246 flavour +// used from Amsterdam onwards: the balance is left in place and the account is +// turned into a balance-only account at transaction finalisation (see Finalise). +func (s *StateDB) selfDestruct(addr common.Address, preserveBalance bool) *big.Int { stateObject := s.getStateObject(addr) prevBalance := new(big.Int) if stateObject == nil { @@ -603,25 +618,36 @@ func (s *StateDB) SelfDestruct(addr common.Address) *big.Int { prevBalance.Set(stateObject.Balance()) // Regardless of whether it is already destructed or not, we do have to // journal the balance-change, if we set it to zero here. - if prevBalance.Sign() != 0 { + if !preserveBalance && prevBalance.Sign() != 0 { stateObject.SetBalance(new(big.Int)) } // If it is already marked as self-destructed, we do not need to add it // for journalling a second time. if !stateObject.selfDestructed { s.journal.destruct(addr) - stateObject.markSelfdestructed() + stateObject.markSelfdestructed(preserveBalance) } return prevBalance } -func (s *StateDB) SelfDestruct6780(addr common.Address) (*big.Int, bool) { +// SelfDestruct6780 is post-EIP-6780 selfdestruct, which means that it's a +// send-all-to-beneficiary, unless the contract was created in this same +// transaction, in which case it will be destructed. +// +// preserveBalance selects the EIP-8246 (Amsterdam) removal: the balance of the +// destructed contract is kept instead of being burnt, and the account survives +// transaction finalisation as a balance-only account (unless its balance is +// zero, in which case EIP-161 deletes it). +// +// This method returns the prior balance, along with a boolean which is +// true iff the object was indeed destructed. +func (s *StateDB) SelfDestruct6780(addr common.Address, preserveBalance bool) (*big.Int, bool) { stateObject := s.getStateObject(addr) if stateObject == nil { return new(big.Int), false } if stateObject.newContract { - return s.SelfDestruct(addr), true + return s.selfDestruct(addr, preserveBalance), true } return new(big.Int).Set(stateObject.Balance()), false } @@ -915,6 +941,16 @@ func (s *StateDB) Finalise(deleteEmptyObjects bool) { continue } + if obj.selfDestructed && obj.selfDestructedPreserveBalance && obj.Balance().Sign() != 0 { + // EIP-8246: a destructed account that still holds a balance is + // not deleted. It is reduced to a balance-only account instead: + // nonce reset to zero, code and storage cleared, balance + // untouched. A zero balance leaves nothing to preserve, so such an + // account falls through to the self-destruct branch below and is + // removed with the rest of the destructed set. + s.preserveDestructedBalance(obj) + continue + } if obj.selfDestructed || (deleteEmptyObjects && obj.empty()) { delete(s.stateObjects, obj.address) s.markDelete(addr) @@ -941,6 +977,52 @@ func (s *StateDB) Finalise(deleteEmptyObjects bool) { s.clearJournalAndRefund() } +// preserveDestructedBalance implements the EIP-8246 finalisation of an account +// marked for destruction: instead of deleting it, the account keeps its balance +// while its nonce, code and storage are cleared. The previous incarnation is +// tracked as destructed so that handleDestruction wipes its storage at commit +// time, exactly like a delete-and-resurrect sequence within one block. +// +// Upstream rebuilds the object with newObject(s, addr, obj.origin), which +// inherits the origin's fields and relies on its storage root already being +// empty in every reachable case. Here the object is rebuilt from nil instead, +// which guarantees the empty nonce, code and root unconditionally, so the +// storage of the previous incarnation has to be removed explicitly through +// stateObjectsDestruct. +// +// Note that the createObjectChange journal entry does not clear +// stateObjectsDestruct when it is reverted. That is safe today because a +// preserved account stays in stateObjects, so Exist reports true and evm.create +// never calls CreateAccount for it; a register-then-resurrect-then-revert +// sequence is therefore unreachable. +func (s *StateDB) preserveDestructedBalance(obj *stateObject) { + balance := new(big.Int).Set(obj.Balance()) + + // We need to maintain account deletions explicitly (will remain set + // indefinitely). Note only the first occurred self-destruct event is + // tracked. + if _, ok := s.stateObjectsDestruct[obj.address]; !ok { + s.stateObjectsDestruct[obj.address] = obj.origin + } + // Drop the diffs of the destroyed incarnation; the reborn object below + // starts from an empty account. + delete(s.accounts, obj.addrHash) + delete(s.storages, obj.addrHash) + delete(s.accountsOrigin, obj.address) + delete(s.storagesOrigin, obj.address) + + // The reborn account starts empty: nonce 0, no code, empty storage root. + // Its data must not be inherited from the origin, since newObject copies + // the account it is handed, but the origin itself has to be kept: without + // it updateStateObject records the account as absent at block start in the + // block-level diff, and only handleDestruction repairs that at commit time. + reborn := newObject(s, obj.address, nil) + reborn.origin = obj.origin + reborn.setBalance(balance) + s.setStateObject(reborn) + s.markUpdate(obj.address) +} + // IntermediateRoot computes the current root hash of the state trie. // It is called in between transactions to get the root hash that // goes into transaction receipts. diff --git a/core/state/statedb_eip8246_test.go b/core/state/statedb_eip8246_test.go new file mode 100644 index 000000000000..44da0f516c56 --- /dev/null +++ b/core/state/statedb_eip8246_test.go @@ -0,0 +1,403 @@ +// 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 state + +import ( + "bytes" + "math/big" + "testing" + + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/core/tracing" + "github.com/XinFinOrg/XDPoSChain/core/types" +) + +var ( + eip8246Addr = common.HexToAddress("0x8246824682468246824682468246824682468246") + eip8246Slot = common.HexToHash("0x05") + eip8246Value = common.HexToHash("0x2a") + // eip8246Code is the probe initcode shared with core/eip8246_test.go: + // PUSH1 0x2a; PUSH1 0x05; SSTORE; ADDRESS; SELFDESTRUCT. + eip8246Code = []byte{0x60, 0x2a, 0x60, 0x05, 0x55, 0x30, 0xff} +) + +// newContractInTx returns a session in which addr has been deployed by the +// current transaction, the way evm.create sets it up before running the +// initcode: the account exists, is flagged as a new contract, has nonce 1, and +// carries the given code and storage. +func newContractInTx(t *testing.T, balance *big.Int) (*StateDB, Database) { + t.Helper() + + db := NewDatabaseForTesting() + sd, err := New(types.EmptyRootHash, db) + if err != nil { + t.Fatalf("failed to create statedb: %v", err) + } + sd.CreateAccount(eip8246Addr) + sd.CreateContract(eip8246Addr) + sd.SetNonce(eip8246Addr, 1, tracing.NonceChangeNewContract) + sd.SetCode(eip8246Addr, eip8246Code) + sd.SetState(eip8246Addr, eip8246Slot, eip8246Value) + if balance.Sign() != 0 { + sd.AddBalance(eip8246Addr, balance, tracing.BalanceChangeUnspecified) + } + return sd, db +} + +// commitAndReopen commits the given session and opens a fresh one over the new root. +func commitAndReopen(t *testing.T, sd *StateDB, db Database) *StateDB { + t.Helper() + + root, err := sd.Commit(0, true) + if err != nil { + t.Fatalf("failed to commit state: %v", err) + } + if err := db.TrieDB().Commit(root, false); err != nil { + t.Fatalf("failed to commit trie: %v", err) + } + fresh, err := New(root, db) + if err != nil { + t.Fatalf("failed to reopen statedb: %v", err) + } + return fresh +} + +// TestEIP8246SelfDestructPreservesBalance verifies that, on Amsterdam, a +// contract created and self-destructed to itself in the same transaction keeps +// its balance while its nonce, code and storage are cleared at finalisation. +func TestEIP8246SelfDestructPreservesBalance(t *testing.T) { + balance := big.NewInt(1234) + sd, db := newContractInTx(t, balance) + + prev, destructed := sd.SelfDestruct6780(eip8246Addr, true) + if !destructed { + t.Fatalf("new contract was not marked for destruction") + } + if prev.Cmp(balance) != 0 { + t.Errorf("prior balance = %v, want %v", prev, balance) + } + // EIP-8246 item 1: the balance is not burnt at the instruction level. + if got := sd.GetBalance(eip8246Addr); got.Cmp(balance) != 0 { + t.Errorf("balance after self-destruct = %v, want %v", got, balance) + } + sd.Finalise(true) + + assertBalanceOnlyAccount(t, sd, balance) + assertBalanceOnlyAccount(t, commitAndReopen(t, sd, db), balance) +} + +// TestEIP8246SelfDestructZeroBalanceDeleted verifies that a preserved account +// whose balance is zero is still removed by EIP-161 at finalisation. +func TestEIP8246SelfDestructZeroBalanceDeleted(t *testing.T) { + sd, db := newContractInTx(t, new(big.Int)) + + if _, destructed := sd.SelfDestruct6780(eip8246Addr, true); !destructed { + t.Fatalf("new contract was not marked for destruction") + } + sd.Finalise(true) + + if sd.Exist(eip8246Addr) { + t.Errorf("zero-balance destructed account still exists") + } + if root := commitAndReopen(t, sd, db); root.Exist(eip8246Addr) { + t.Errorf("zero-balance destructed account survived the commit") + } +} + +// TestEIP8246SelfDestructKeepsLateFunds verifies EIP-8246 item 2: ether sent to +// a destructed account later in the same transaction is preserved instead of +// burnt at finalisation. +func TestEIP8246SelfDestructKeepsLateFunds(t *testing.T) { + balance := big.NewInt(1234) + sd, db := newContractInTx(t, balance) + + sd.SelfDestruct6780(eip8246Addr, true) + sd.AddBalance(eip8246Addr, big.NewInt(7), tracing.BalanceChangeUnspecified) + sd.Finalise(true) + + want := new(big.Int).Add(balance, big.NewInt(7)) + assertBalanceOnlyAccount(t, sd, want) + assertBalanceOnlyAccount(t, commitAndReopen(t, sd, db), want) +} + +// TestEIP8246SelfDestructToOtherKeepsLateFunds covers the other EIP-8246 +// finalisation shape: the contract sends its balance to a beneficiary and is +// removed, then receives ether before finalisation, which must survive as the +// balance of the balance-only account instead of being burnt. +func TestEIP8246SelfDestructToOtherKeepsLateFunds(t *testing.T) { + balance := big.NewInt(1234) + beneficiary := common.HexToAddress("0xbeef") + sd, db := newContractInTx(t, balance) + + sd.SubBalance(eip8246Addr, balance, tracing.BalanceDecreaseSelfdestruct) + sd.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct) + sd.SelfDestruct6780(eip8246Addr, true) + sd.AddBalance(eip8246Addr, big.NewInt(7), tracing.BalanceChangeUnspecified) + sd.Finalise(true) + + if got := sd.GetBalance(beneficiary); got.Cmp(balance) != 0 { + t.Errorf("beneficiary balance = %v, want %v", got, balance) + } + assertBalanceOnlyAccount(t, sd, big.NewInt(7)) + assertBalanceOnlyAccount(t, commitAndReopen(t, sd, db), big.NewInt(7)) +} + +// TestEIP8246SelfDestructReverted verifies that reverting a preserved +// self-destruct restores the account, including its balance and flags. +func TestEIP8246SelfDestructReverted(t *testing.T) { + balance := big.NewInt(1234) + sd, _ := newContractInTx(t, balance) + + snapshot := sd.Snapshot() + sd.SelfDestruct6780(eip8246Addr, true) + if !sd.HasSelfDestructed(eip8246Addr) { + t.Fatalf("account not marked as destructed before the revert") + } + sd.RevertToSnapshot(snapshot) + + if sd.HasSelfDestructed(eip8246Addr) { + t.Errorf("account still marked as destructed after the revert") + } + sd.Finalise(true) + if sd.GetBalance(eip8246Addr).Cmp(balance) != 0 { + t.Errorf("balance after revert and finalise = %v, want %v", sd.GetBalance(eip8246Addr), balance) + } + if sd.GetNonce(eip8246Addr) != 1 { + t.Errorf("nonce after revert and finalise = %d, want 1", sd.GetNonce(eip8246Addr)) + } + if len(sd.GetCode(eip8246Addr)) == 0 { + t.Errorf("code after revert and finalise is empty") + } + if got := sd.GetState(eip8246Addr, eip8246Slot); got != eip8246Value { + t.Errorf("storage after revert and finalise = %x, want %x", got, eip8246Value) + } +} + +// TestSelfDestructBurnsWithoutEIP8246 pins the pre-Amsterdam behaviour: the +// balance is burnt at the instruction and any ether arriving before finalisation +// is burnt there. +func TestSelfDestructBurnsWithoutEIP8246(t *testing.T) { + sd, db := newContractInTx(t, big.NewInt(1234)) + + sd.SelfDestruct6780(eip8246Addr, false) + if got := sd.GetBalance(eip8246Addr); got.Sign() != 0 { + t.Errorf("balance after pre-Amsterdam self-destruct = %v, want 0", got) + } + sd.AddBalance(eip8246Addr, big.NewInt(7), tracing.BalanceChangeUnspecified) + sd.Finalise(true) + + if sd.Exist(eip8246Addr) { + t.Errorf("destructed account still exists") + } + if fresh := commitAndReopen(t, sd, db); fresh.Exist(eip8246Addr) { + t.Errorf("destructed account survived the commit") + } +} + +// TestEIP8246PreservesBalanceOfPreexistingAccount verifies the finalisation +// path for an account that existed before the block and is deployed to and +// destroyed within one transaction. Its original storage has to be wiped while +// the balance survives. +// +// The shape itself is not reachable through the EVM: evm.create rejects an +// address whose storage root is non-empty with ErrContractAddressCollision, so +// a block-start account carrying storage can never be deployed to. It is kept +// to pin the storage-wiping bookkeeping; the reachable prefunded shape, and the +// block diff it leaves behind, is covered by +// TestEIP8246PreservedAccountKeepsBlockOrigin. +// +// Unlike upstream go-ethereum #35219, which rebuilds the account from its origin +// and therefore keeps the origin's code and storage root, this tree rebuilds it +// from nil and clears both. The difference is confined to this unreachable +// shape. +func TestEIP8246PreservesBalanceOfPreexistingAccount(t *testing.T) { + db := NewDatabaseForTesting() + + // Seed the account and commit it, so the object has a non-nil origin. + sd, err := New(types.EmptyRootHash, db) + if err != nil { + t.Fatalf("failed to create statedb: %v", err) + } + sd.AddBalance(eip8246Addr, big.NewInt(1234), tracing.BalanceChangeUnspecified) + sd.SetCode(eip8246Addr, eip8246Code) + sd.SetState(eip8246Addr, eip8246Slot, eip8246Value) + root, err := sd.Commit(0, false) + if err != nil { + t.Fatalf("failed to commit state: %v", err) + } + if err := db.TrieDB().Commit(root, false); err != nil { + t.Fatalf("failed to commit trie: %v", err) + } + + sd, err = New(root, db) + if err != nil { + t.Fatalf("failed to reopen statedb: %v", err) + } + sd.CreateContract(eip8246Addr) // deployed to in this transaction + sd.SelfDestruct6780(eip8246Addr, true) + sd.Finalise(true) + + assertBalanceOnlyAccount(t, sd, big.NewInt(1234)) + assertBalanceOnlyAccount(t, commitAndReopen(t, sd, db), big.NewInt(1234)) +} + +// TestEIP8246PreservedAccountKeepsBlockOrigin verifies that the reborn +// balance-only account keeps the origin of the account it replaced: the +// block-level diff has to record the account as existing at block start, +// otherwise a consumer reading the diff before the commit sees a nil origin. +// The shape is the reachable one - a prefunded, codeless account is not reset +// by evm.create (CreateAccount is only called when the address does not exist) +// and is marked as a new contract instead, which makes it destructible. +func TestEIP8246PreservedAccountKeepsBlockOrigin(t *testing.T) { + db := NewDatabaseForTesting() + + // Seed a balance-only account, the way a plain transfer would. + balance := big.NewInt(1234) + sd, err := New(types.EmptyRootHash, db) + if err != nil { + t.Fatalf("failed to create statedb: %v", err) + } + sd.AddBalance(eip8246Addr, balance, tracing.BalanceChangeUnspecified) + root, err := sd.Commit(0, false) + if err != nil { + t.Fatalf("failed to commit state: %v", err) + } + if err := db.TrieDB().Commit(root, false); err != nil { + t.Fatalf("failed to commit trie: %v", err) + } + sd, err = New(root, db) + if err != nil { + t.Fatalf("failed to reopen statedb: %v", err) + } + seeded := sd.getStateObject(eip8246Addr) + if seeded == nil || seeded.origin == nil { + t.Fatalf("seeded account has no origin") + } + wantOrigin := types.SlimAccountRLP(*seeded.origin) + + // Mirror evm.create: reuse the existing object, mark it as a contract of + // this transaction, then let it self-destruct to itself. + sd.CreateContract(eip8246Addr) + sd.SelfDestruct6780(eip8246Addr, true) + sd.Finalise(true) + sd.IntermediateRoot(true) + + got, ok := sd.accountsOrigin[eip8246Addr] + if !ok { + t.Errorf("block diff does not track the preserved account") + } else if !bytes.Equal(got, wantOrigin) { + t.Errorf("block diff origin = %x, want %x", got, wantOrigin) + } + assertBalanceOnlyAccount(t, sd, balance) + assertBalanceOnlyAccount(t, commitAndReopen(t, sd, db), balance) +} + +// TestEIP8246HookedFinaliseSkipsBurn verifies that a tracer is not told about a +// burn that EIP-8246 removed. +func TestEIP8246HookedFinaliseSkipsBurn(t *testing.T) { + sd, _ := newContractInTx(t, big.NewInt(1234)) + + var ( + decreases int + burns int + ) + hooked := NewHookedState(sd, &tracing.Hooks{ + OnBalanceChange: func(addr common.Address, prev, new *big.Int, reason tracing.BalanceChangeReason) { + switch reason { + case tracing.BalanceDecreaseSelfdestruct: + decreases++ + case tracing.BalanceDecreaseSelfdestructBurn: + burns++ + } + }, + }) + hooked.SelfDestruct6780(eip8246Addr, true) + hooked.AddBalance(eip8246Addr, big.NewInt(7), tracing.BalanceChangeUnspecified) + hooked.Finalise(true) + + if decreases != 0 || burns != 0 { + t.Errorf("preserved self-destruct reported %d decreases and %d burns, want none", decreases, burns) + } + if got := sd.GetBalance(eip8246Addr); got.Cmp(big.NewInt(1241)) != 0 { + t.Errorf("balance after hooked finalise = %v, want 1241", got) + } +} + +// TestSelfDestructHookedBurnWithoutEIP8246 pins the pre-Amsterdam hooks: the +// destruct reports the balance decrease, and ether arriving afterwards is +// reported as burnt at finalisation. +func TestSelfDestructHookedBurnWithoutEIP8246(t *testing.T) { + sd, _ := newContractInTx(t, big.NewInt(1234)) + + var ( + decreases int + burns int + burned = new(big.Int) + ) + hooked := NewHookedState(sd, &tracing.Hooks{ + OnBalanceChange: func(addr common.Address, prev, new *big.Int, reason tracing.BalanceChangeReason) { + switch reason { + case tracing.BalanceDecreaseSelfdestruct: + decreases++ + case tracing.BalanceDecreaseSelfdestructBurn: + burns++ + burned.Add(burned, prev) + } + }, + }) + hooked.SelfDestruct6780(eip8246Addr, false) + hooked.AddBalance(eip8246Addr, big.NewInt(7), tracing.BalanceChangeUnspecified) + hooked.Finalise(true) + + if decreases != 1 { + t.Errorf("destruct reported %d decreases, want 1", decreases) + } + if burns != 1 || burned.Cmp(big.NewInt(7)) != 0 { + t.Errorf("finalise reported %d burns (total %v), want 1 burn of 7", burns, burned) + } +} + +// assertBalanceOnlyAccount checks that addr holds exactly want and carries no +// nonce, code or storage, as EIP-8246 requires of a preserved account. +func assertBalanceOnlyAccount(t *testing.T, sd *StateDB, want *big.Int) { + t.Helper() + + if !sd.Exist(eip8246Addr) { + t.Fatalf("balance-only account does not exist") + } + if got := sd.GetBalance(eip8246Addr); got.Cmp(want) != 0 { + t.Errorf("balance = %v, want %v", got, want) + } + if got := sd.GetNonce(eip8246Addr); got != 0 { + t.Errorf("nonce = %d, want 0", got) + } + if code := sd.GetCode(eip8246Addr); len(code) != 0 { + t.Errorf("code = %x, want empty", code) + } + if got := sd.GetState(eip8246Addr, eip8246Slot); got != (common.Hash{}) { + t.Errorf("storage = %x, want empty", got) + } + if got := sd.GetStorageRoot(eip8246Addr); got != types.EmptyRootHash { + t.Errorf("storage root = %x, want %x", got, types.EmptyRootHash) + } + if got := sd.GetCodeHash(eip8246Addr); got != types.EmptyCodeHash { + t.Errorf("code hash = %x, want %x", got, types.EmptyCodeHash) + } + if sd.HasSelfDestructed(eip8246Addr) { + t.Errorf("preserved account is still marked as destructed") + } +} diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go index b2762545732b..256c83a970e7 100644 --- a/core/state/statedb_hooked.go +++ b/core/state/statedb_hooked.go @@ -221,9 +221,11 @@ func (s *hookedStateDB) SelfDestruct(address common.Address) *big.Int { return prev } -func (s *hookedStateDB) SelfDestruct6780(address common.Address) (*big.Int, bool) { - prev, changed := s.inner.SelfDestruct6780(address) - if changed { +func (s *hookedStateDB) SelfDestruct6780(address common.Address, preserveBalance bool) (*big.Int, bool) { + prev, changed := s.inner.SelfDestruct6780(address, preserveBalance) + // EIP-8246 (Amsterdam) leaves the balance untouched, so there is no + // decrease to report. + if changed && !preserveBalance { if s.hooks.OnBalanceChange != nil { s.hooks.OnBalanceChange(address, prev, new(big.Int), tracing.BalanceDecreaseSelfdestruct) } @@ -267,8 +269,14 @@ func (s *hookedStateDB) Finalise(deleteEmptyObjects bool) { // Bingo: state object was self-destructed, call relevant hooks. // If ether was sent to account post-selfdestruct, record as burnt. - if bal := obj.Balance(); bal.Sign() != 0 { - s.hooks.OnBalanceChange(addr, bal, new(big.Int), tracing.BalanceDecreaseSelfdestructBurn) + // EIP-8246 (Amsterdam) preserves that balance instead, so there is no + // burn to report. Only the burn hook is skipped: a preserved account + // still loses its nonce and code, so any other state-change hook added + // to this loop later must keep firing for it. + if !obj.selfDestructedPreserveBalance { + if bal := obj.Balance(); bal.Sign() != 0 { + s.hooks.OnBalanceChange(addr, bal, new(big.Int), tracing.BalanceDecreaseSelfdestructBurn) + } } } } diff --git a/core/vm/eip8246_test.go b/core/vm/eip8246_test.go new file mode 100644 index 000000000000..f16273adf4ca --- /dev/null +++ b/core/vm/eip8246_test.go @@ -0,0 +1,130 @@ +// 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 vm + +import ( + "math/big" + "testing" + + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/core/state" + "github.com/XinFinOrg/XDPoSChain/core/tracing" + "github.com/XinFinOrg/XDPoSChain/core/types" + "github.com/XinFinOrg/XDPoSChain/params" + "github.com/holiman/uint256" +) + +// TestSelfdestruct6780EventForkGate pins the balance-change events emitted by a +// SELFDESTRUCT with the executing account as beneficiary, across the EIP-8246 +// fork boundary. +// +// The fork gate in opSelfdestruct6780 only shapes the hook layer: the final +// state is decided by the preserveBalance flag handed to the state layer, so a +// state-level test cannot tell whether the gate is still there. Pre-Amsterdam +// the instruction has to keep the historical transfer pair (a decrease and an +// increase of the full balance on top of the destruct decrease), exactly as +// the pre-EIP-8246 code did with its unconditional SubBalance/AddBalance. +// Amsterdam drops the pair and leaves the balance in place. +func TestSelfdestruct6780EventForkGate(t *testing.T) { + var ( + addr = common.HexToAddress("0x8246824682468246824682468246824682468246") + code = []byte{0x30, 0xff} // ADDRESS; SELFDESTRUCT + initialBalance = int64(1000) + ) + run := func(t *testing.T, amsterdam, newContract bool) (decrease, increase, burn int, balance int64, exists bool) { + t.Helper() + + config := *params.AllEthashProtocolChanges + if !amsterdam { + config.AmsterdamBlock = nil + } + statedb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting()) + if err != nil { + t.Fatalf("failed to create state: %v", err) + } + statedb.CreateAccount(addr) + if newContract { + statedb.CreateContract(addr) + } else { + statedb.SetNonce(addr, 1, tracing.NonceChangeUnspecified) + } + statedb.SetCode(addr, code) + statedb.AddBalance(addr, big.NewInt(initialBalance), tracing.BalanceChangeUnspecified) + + hooked := state.NewHookedState(statedb, &tracing.Hooks{ + OnBalanceChange: func(_ common.Address, _, _ *big.Int, reason tracing.BalanceChangeReason) { + switch reason { + case tracing.BalanceDecreaseSelfdestruct: + decrease++ + case tracing.BalanceIncreaseSelfdestruct: + increase++ + case tracing.BalanceDecreaseSelfdestructBurn: + burn++ + } + }, + }) + evm := NewEVM(BlockContext{ + BlockNumber: big.NewInt(0), + CanTransfer: func(db StateDB, addr common.Address, amount *uint256.Int) bool { + return db.GetBalance(addr).Cmp(amount.ToBig()) >= 0 + }, + Transfer: func(db StateDB, from, to common.Address, amount *uint256.Int) { + if amount.Sign() == 0 { + return + } + db.SubBalance(from, amount.ToBig(), tracing.BalanceChangeUnspecified) + db.AddBalance(to, amount.ToBig(), tracing.BalanceChangeUnspecified) + }, + }, hooked, nil, &config, Config{}) + + if _, _, err := evm.Call(common.Address{}, addr, nil, 100_000, new(uint256.Int)); err != nil { + t.Fatalf("call failed: %v", err) + } + hooked.Finalise(true) + return decrease, increase, burn, hooked.GetBalance(addr).Int64(), hooked.Exist(addr) + } + + for _, tc := range []struct { + name string + amsterdam bool + newContract bool + wantDecrease int + wantIncrease int + wantBurn int + wantBalance int64 + wantExists bool + }{ + {"pre-amsterdam/new-contract", false, true, 2, 1, 0, 0, false}, + {"amsterdam/new-contract", true, true, 0, 0, 0, initialBalance, true}, + {"pre-amsterdam/existing-contract", false, false, 1, 1, 0, initialBalance, true}, + {"amsterdam/existing-contract", true, false, 0, 0, 0, initialBalance, true}, + } { + t.Run(tc.name, func(t *testing.T) { + decrease, increase, burn, balance, exists := run(t, tc.amsterdam, tc.newContract) + if decrease != tc.wantDecrease || increase != tc.wantIncrease || burn != tc.wantBurn { + t.Errorf("balance events = %d decreases, %d increases, %d burns, want %d, %d, %d", + decrease, increase, burn, tc.wantDecrease, tc.wantIncrease, tc.wantBurn) + } + if balance != tc.wantBalance { + t.Errorf("post-finalise balance = %d, want %d", balance, tc.wantBalance) + } + if exists != tc.wantExists { + t.Errorf("post-finalise existence = %v, want %v", exists, tc.wantExists) + } + }) + } +} diff --git a/core/vm/instructions.go b/core/vm/instructions.go index b9a31e310722..4d6ebbac4ae4 100644 --- a/core/vm/instructions.go +++ b/core/vm/instructions.go @@ -913,14 +913,30 @@ func opSelfdestruct6780(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, erro if evm.readOnly { return nil, ErrWriteProtection } - beneficiary := scope.Stack.pop() - balance := evm.StateDB.GetBalance(scope.Contract.Address()) - evm.StateDB.SubBalance(scope.Contract.Address(), balance, tracing.BalanceDecreaseSelfdestruct) - evm.StateDB.AddBalance(beneficiary.Bytes20(), balance, tracing.BalanceIncreaseSelfdestruct) - evm.StateDB.SelfDestruct6780(scope.Contract.Address()) + var ( + this = scope.Contract.Address() + top = scope.Stack.pop() + beneficiary = top.Bytes20() + balance = evm.StateDB.GetBalance(this) + preserveBalance = evm.chainRules.IsAmsterdam + ) + // Move the balance to the beneficiary. Prior to EIP-8246 a contract created + // in this same transaction that self-destructs to itself still burns the + // balance, which this tree's StateDB performs by clearing it eagerly in + // SelfDestruct6780, so the historical transfer pair is kept there. From + // Amsterdam onwards EIP-8246 removes that burn: the balance is left + // untouched and the account survives finalisation as a balance-only + // account (see StateDB.Finalise). A pre-existing contract is not destructed + // by EIP-6780 at all, so a self-destruct to self leaves its balance + // untouched in every fork. + if this != beneficiary || !preserveBalance { + evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct) + evm.StateDB.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct) + } + evm.StateDB.SelfDestruct6780(this, preserveBalance) if tracer := evm.Config.Tracer; tracer != nil { if tracer.OnEnter != nil { - tracer.OnEnter(evm.depth, byte(SELFDESTRUCT), scope.Contract.Address(), beneficiary.Bytes20(), []byte{}, 0, balance) + tracer.OnEnter(evm.depth, byte(SELFDESTRUCT), this, beneficiary, []byte{}, 0, balance) } if tracer.OnExit != nil { tracer.OnExit(evm.depth, []byte{}, 0, nil, false) diff --git a/core/vm/interface.go b/core/vm/interface.go index 3c274d73a515..48cf1a417a2a 100644 --- a/core/vm/interface.go +++ b/core/vm/interface.go @@ -57,6 +57,10 @@ type StateDB interface { GetTransientState(addr common.Address, key common.Hash) common.Hash SetTransientState(addr common.Address, key, value common.Hash) + // SelfDestruct is the pre-EIP-6780 self-destruct: it always burns the + // balance and carries no fork rules, so it does not implement EIP-8246. + // The Amsterdam instruction set is built on Cancun and reaches + // SelfDestruct6780 instead. SelfDestruct(common.Address) *big.Int HasSelfDestructed(common.Address) bool @@ -72,7 +76,11 @@ type StateDB interface { // transaction, in which case it will be destructed. // This method returns the prior balance, along with a boolean which is // true iff the object was indeed destructed. - SelfDestruct6780(common.Address) (*big.Int, bool) + // + // preserveBalance selects the EIP-8246 (Amsterdam) removal: the balance of + // the destructed contract is kept and the account is left in place as a + // balance-only account at transaction finalisation. + SelfDestruct6780(common.Address, bool) (*big.Int, bool) // Exist reports whether the given account exists in state. // Notably this also returns true for self-destructed accounts within the current transaction. From fe4cf58c865016ddf77640626e3c669e373d0c37 Mon Sep 17 00:00:00 2001 From: Daniel Liu <139250065@qq.com> Date: Tue, 27 Oct 2026 16:23:56 +0800 Subject: [PATCH 2/2] test(core/vm): cover the self-destruct to a distinct beneficiary across the fork The event-level test only drove a self-destruct to the executing account, so nothing pinned that a distinct beneficiary keeps the SubBalance/AddBalance pair on both sides of the EIP-8246 fork: the interpreter gate has to drop only the self-beneficiary part, and dropping the transfer pair for a foreign beneficiary would move no funds at all. The helper now reports the beneficiary balance as well, and four table rows cover an existing and a new contract on each side of the fork. The extra decrease of pre-amsterdam/new-contract-to-other is the 0 -> 0 destruct decrease hookedStateDB.SelfDestruct6780 raises after the instruction already moved the balance to the beneficiary, the same quirk the self-beneficiary rows pin. --- core/vm/eip8246_test.go | 60 ++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/core/vm/eip8246_test.go b/core/vm/eip8246_test.go index f16273adf4ca..9835e1b59a74 100644 --- a/core/vm/eip8246_test.go +++ b/core/vm/eip8246_test.go @@ -29,8 +29,8 @@ import ( ) // TestSelfdestruct6780EventForkGate pins the balance-change events emitted by a -// SELFDESTRUCT with the executing account as beneficiary, across the EIP-8246 -// fork boundary. +// SELFDESTRUCT with the executing account or a distinct address as beneficiary, +// across the EIP-8246 fork boundary. // // The fork gate in opSelfdestruct6780 only shapes the hook layer: the final // state is decided by the preserveBalance flag handed to the state layer, so a @@ -39,13 +39,22 @@ import ( // increase of the full balance on top of the destruct decrease), exactly as // the pre-EIP-8246 code did with its unconditional SubBalance/AddBalance. // Amsterdam drops the pair and leaves the balance in place. +// +// The to-other rows pin the other half of the gate: a distinct beneficiary keeps +// the transfer pair on both sides of the fork, and only the self-beneficiary +// part is dropped. The extra decrease of pre-amsterdam/new-contract-to-other is +// the 0 -> 0 destruct decrease that hookedStateDB.SelfDestruct6780 raises after +// the instruction already moved the balance to the beneficiary; the +// self-beneficiary rows pin the same quirk. func TestSelfdestruct6780EventForkGate(t *testing.T) { var ( addr = common.HexToAddress("0x8246824682468246824682468246824682468246") - code = []byte{0x30, 0xff} // ADDRESS; SELFDESTRUCT + beneficiary = common.HexToAddress("0xbe") + code = []byte{0x30, 0xff} // ADDRESS; SELFDESTRUCT + toOtherCode = []byte{0x60, 0xbe, 0xff} // PUSH1 0xbe; SELFDESTRUCT initialBalance = int64(1000) ) - run := func(t *testing.T, amsterdam, newContract bool) (decrease, increase, burn int, balance int64, exists bool) { + run := func(t *testing.T, amsterdam, newContract, toOther bool) (decrease, increase, burn int, balance, beneficiaryBalance int64, exists bool) { t.Helper() config := *params.AllEthashProtocolChanges @@ -62,7 +71,11 @@ func TestSelfdestruct6780EventForkGate(t *testing.T) { } else { statedb.SetNonce(addr, 1, tracing.NonceChangeUnspecified) } - statedb.SetCode(addr, code) + evmCode := code + if toOther { + evmCode = toOtherCode + } + statedb.SetCode(addr, evmCode) statedb.AddBalance(addr, big.NewInt(initialBalance), tracing.BalanceChangeUnspecified) hooked := state.NewHookedState(statedb, &tracing.Hooks{ @@ -95,26 +108,32 @@ func TestSelfdestruct6780EventForkGate(t *testing.T) { t.Fatalf("call failed: %v", err) } hooked.Finalise(true) - return decrease, increase, burn, hooked.GetBalance(addr).Int64(), hooked.Exist(addr) + return decrease, increase, burn, hooked.GetBalance(addr).Int64(), hooked.GetBalance(beneficiary).Int64(), hooked.Exist(addr) } for _, tc := range []struct { - name string - amsterdam bool - newContract bool - wantDecrease int - wantIncrease int - wantBurn int - wantBalance int64 - wantExists bool + name string + amsterdam bool + newContract bool + toOther bool + wantDecrease int + wantIncrease int + wantBurn int + wantBalance int64 + wantExists bool + wantBeneficiary int64 }{ - {"pre-amsterdam/new-contract", false, true, 2, 1, 0, 0, false}, - {"amsterdam/new-contract", true, true, 0, 0, 0, initialBalance, true}, - {"pre-amsterdam/existing-contract", false, false, 1, 1, 0, initialBalance, true}, - {"amsterdam/existing-contract", true, false, 0, 0, 0, initialBalance, true}, + {"pre-amsterdam/new-contract", false, true, false, 2, 1, 0, 0, false, 0}, + {"amsterdam/new-contract", true, true, false, 0, 0, 0, initialBalance, true, 0}, + {"pre-amsterdam/existing-contract", false, false, false, 1, 1, 0, initialBalance, true, 0}, + {"amsterdam/existing-contract", true, false, false, 0, 0, 0, initialBalance, true, 0}, + {"pre-amsterdam/existing-contract-to-other", false, false, true, 1, 1, 0, 0, true, initialBalance}, + {"amsterdam/existing-contract-to-other", true, false, true, 1, 1, 0, 0, true, initialBalance}, + {"pre-amsterdam/new-contract-to-other", false, true, true, 2, 1, 0, 0, false, initialBalance}, + {"amsterdam/new-contract-to-other", true, true, true, 1, 1, 0, 0, false, initialBalance}, } { t.Run(tc.name, func(t *testing.T) { - decrease, increase, burn, balance, exists := run(t, tc.amsterdam, tc.newContract) + decrease, increase, burn, balance, beneficiaryBalance, exists := run(t, tc.amsterdam, tc.newContract, tc.toOther) if decrease != tc.wantDecrease || increase != tc.wantIncrease || burn != tc.wantBurn { t.Errorf("balance events = %d decreases, %d increases, %d burns, want %d, %d, %d", decrease, increase, burn, tc.wantDecrease, tc.wantIncrease, tc.wantBurn) @@ -122,6 +141,9 @@ func TestSelfdestruct6780EventForkGate(t *testing.T) { if balance != tc.wantBalance { t.Errorf("post-finalise balance = %d, want %d", balance, tc.wantBalance) } + if beneficiaryBalance != tc.wantBeneficiary { + t.Errorf("post-finalise beneficiary balance = %d, want %d", beneficiaryBalance, tc.wantBeneficiary) + } if exists != tc.wantExists { t.Errorf("post-finalise existence = %v, want %v", exists, tc.wantExists) }