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..9835e1b59a74 --- /dev/null +++ b/core/vm/eip8246_test.go @@ -0,0 +1,152 @@ +// 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 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 +// 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. +// +// 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") + 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, toOther bool) (decrease, increase, burn int, balance, beneficiaryBalance 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) + } + evmCode := code + if toOther { + evmCode = toOtherCode + } + statedb.SetCode(addr, evmCode) + 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.GetBalance(beneficiary).Int64(), hooked.Exist(addr) + } + + for _, tc := range []struct { + 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, 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, 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) + } + 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) + } + }) + } +} 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.