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.