diff --git a/core/state/statedb.go b/core/state/statedb.go
index fbfb02e8e4..39160aa1c7 100644
--- a/core/state/statedb.go
+++ b/core/state/statedb.go
@@ -509,21 +509,13 @@ func (s *StateDB) SetStorage(addr common.Address, storage map[common.Hash]common
}
// SelfDestruct marks the given account as selfdestructed.
-// This clears the account balance.
//
// 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) uint256.Int {
+func (s *StateDB) SelfDestruct(addr common.Address) {
stateObject := s.getStateObject(addr)
- var prevBalance uint256.Int
if stateObject == nil {
- return prevBalance
- }
- prevBalance = *(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 !stateObject.Balance().IsZero() {
- stateObject.SetBalance(new(uint256.Int))
+ return
}
// If it is already marked as self-destructed, we do not need to add it
// for journalling a second time.
@@ -531,18 +523,6 @@ func (s *StateDB) SelfDestruct(addr common.Address) uint256.Int {
s.journal.destruct(addr)
stateObject.markSelfdestructed()
}
- return prevBalance
-}
-
-func (s *StateDB) SelfDestruct6780(addr common.Address) (uint256.Int, bool) {
- stateObject := s.getStateObject(addr)
- if stateObject == nil {
- return uint256.Int{}, false
- }
- if stateObject.newContract {
- return s.SelfDestruct(addr), true
- }
- return *(stateObject.Balance()), false
}
// SetTransientState sets transient storage for a given account. It
@@ -670,6 +650,16 @@ func (s *StateDB) CreateContract(addr common.Address) {
}
}
+// IsNewContract reports whether the contract at the given address was deployed
+// during the current transaction.
+func (s *StateDB) IsNewContract(addr common.Address) bool {
+ obj := s.getStateObject(addr)
+ if obj == nil {
+ return false
+ }
+ return obj.newContract
+}
+
// Copy creates a deep, independent copy of the state.
// Snapshots of the copied state cannot be applied to the copy.
func (s *StateDB) Copy() *StateDB {
diff --git a/core/state/statedb_hooked.go b/core/state/statedb_hooked.go
index 33a2016784..4ffa69b419 100644
--- a/core/state/statedb_hooked.go
+++ b/core/state/statedb_hooked.go
@@ -52,6 +52,10 @@ func (s *hookedStateDB) CreateContract(addr common.Address) {
s.inner.CreateContract(addr)
}
+func (s *hookedStateDB) IsNewContract(addr common.Address) bool {
+ return s.inner.IsNewContract(addr)
+}
+
func (s *hookedStateDB) GetBalance(addr common.Address) *uint256.Int {
return s.inner.GetBalance(addr)
}
@@ -211,56 +215,8 @@ func (s *hookedStateDB) SetState(address common.Address, key common.Hash, value
return prev
}
-func (s *hookedStateDB) SelfDestruct(address common.Address) uint256.Int {
- var prevCode []byte
- var prevCodeHash common.Hash
-
- if s.hooks.OnCodeChange != nil || s.hooks.OnCodeChangeV2 != nil {
- prevCode = s.inner.GetCode(address)
- prevCodeHash = s.inner.GetCodeHash(address)
- }
-
- prev := s.inner.SelfDestruct(address)
-
- if s.hooks.OnBalanceChange != nil && !prev.IsZero() {
- s.hooks.OnBalanceChange(address, prev.ToBig(), new(big.Int), tracing.BalanceDecreaseSelfdestruct)
- }
-
- if len(prevCode) > 0 {
- if s.hooks.OnCodeChangeV2 != nil {
- s.hooks.OnCodeChangeV2(address, prevCodeHash, prevCode, types.EmptyCodeHash, nil, tracing.CodeChangeSelfDestruct)
- } else if s.hooks.OnCodeChange != nil {
- s.hooks.OnCodeChange(address, prevCodeHash, prevCode, types.EmptyCodeHash, nil)
- }
- }
-
- return prev
-}
-
-func (s *hookedStateDB) SelfDestruct6780(address common.Address) (uint256.Int, bool) {
- var prevCode []byte
- var prevCodeHash common.Hash
-
- if s.hooks.OnCodeChange != nil || s.hooks.OnCodeChangeV2 != nil {
- prevCodeHash = s.inner.GetCodeHash(address)
- prevCode = s.inner.GetCode(address)
- }
-
- prev, changed := s.inner.SelfDestruct6780(address)
-
- if s.hooks.OnBalanceChange != nil && !prev.IsZero() {
- s.hooks.OnBalanceChange(address, prev.ToBig(), new(big.Int), tracing.BalanceDecreaseSelfdestruct)
- }
-
- if changed && len(prevCode) > 0 {
- if s.hooks.OnCodeChangeV2 != nil {
- s.hooks.OnCodeChangeV2(address, prevCodeHash, prevCode, types.EmptyCodeHash, nil, tracing.CodeChangeSelfDestruct)
- } else if s.hooks.OnCodeChange != nil {
- s.hooks.OnCodeChange(address, prevCodeHash, prevCode, types.EmptyCodeHash, nil)
- }
- }
-
- return prev, changed
+func (s *hookedStateDB) SelfDestruct(address common.Address) {
+ s.inner.SelfDestruct(address)
}
func (s *hookedStateDB) AddLog(log *types.Log) {
@@ -272,17 +228,47 @@ func (s *hookedStateDB) AddLog(log *types.Log) {
}
func (s *hookedStateDB) Finalise(deleteEmptyObjects bool) {
- defer s.inner.Finalise(deleteEmptyObjects)
- if s.hooks.OnBalanceChange == nil {
+ if s.hooks.OnBalanceChange == nil && s.hooks.OnNonceChangeV2 == nil && s.hooks.OnNonceChange == nil && s.hooks.OnCodeChangeV2 == nil && s.hooks.OnCodeChange == nil {
+ // Short circuit if no relevant hooks are set.
+ s.inner.Finalise(deleteEmptyObjects)
return
}
+
+ // Iterate all dirty addresses and record self-destructs.
for addr := range s.inner.journal.dirties {
obj := s.inner.stateObjects[addr]
- if obj != nil && obj.selfDestructed {
- // If ether was sent to account post-selfdestruct it is burnt.
+ if obj == nil || !obj.selfDestructed {
+ // Not self-destructed, keep searching.
+ continue
+ }
+ // Bingo: state object was self-destructed, call relevant hooks.
+
+ // If ether was sent to account post-selfdestruct, record as burnt.
+ if s.hooks.OnBalanceChange != nil {
if bal := obj.Balance(); bal.Sign() != 0 {
s.hooks.OnBalanceChange(addr, bal.ToBig(), new(big.Int), tracing.BalanceDecreaseSelfdestructBurn)
}
}
+
+ // Nonce is set to reset on self-destruct.
+ if s.hooks.OnNonceChangeV2 != nil {
+ s.hooks.OnNonceChangeV2(addr, obj.Nonce(), 0, tracing.NonceChangeSelfdestruct)
+ } else if s.hooks.OnNonceChange != nil {
+ s.hooks.OnNonceChange(addr, obj.Nonce(), 0)
+ }
+
+ // If an initcode invokes selfdestruct, do not emit a code change.
+ prevCodeHash := s.inner.GetCodeHash(addr)
+ if prevCodeHash == types.EmptyCodeHash {
+ continue
+ }
+ // Otherwise, trace the change.
+ if s.hooks.OnCodeChangeV2 != nil {
+ s.hooks.OnCodeChangeV2(addr, prevCodeHash, s.inner.GetCode(addr), types.EmptyCodeHash, nil, tracing.CodeChangeSelfDestruct)
+ } else if s.hooks.OnCodeChange != nil {
+ s.hooks.OnCodeChange(addr, prevCodeHash, s.inner.GetCode(addr), types.EmptyCodeHash, nil)
+ }
}
+
+ s.inner.Finalise(deleteEmptyObjects)
}
diff --git a/core/state/statedb_hooked_test.go b/core/state/statedb_hooked_test.go
index 4d85e61679..6fe17ec1b4 100644
--- a/core/state/statedb_hooked_test.go
+++ b/core/state/statedb_hooked_test.go
@@ -49,6 +49,8 @@ func TestBurn(t *testing.T) {
createAndDestroy := func(addr common.Address) {
hooked.AddBalance(addr, uint256.NewInt(100), tracing.BalanceChangeUnspecified)
hooked.CreateContract(addr)
+ // Simulate what the opcode handler does: clear balance before selfdestruct
+ hooked.SubBalance(addr, hooked.GetBalance(addr), tracing.BalanceDecreaseSelfdestruct)
hooked.SelfDestruct(addr)
// sanity-check that balance is now 0
if have, want := hooked.GetBalance(addr), new(uint256.Int); !have.Eq(want) {
@@ -140,8 +142,8 @@ func TestHooks_OnCodeChangeV2(t *testing.T) {
var result []string
var wants = []string{
"0xaa00000000000000000000000000000000000000.code: (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) ->0x1325 (0xa12ae05590de0c93a00bc7ac773c2fdb621e44f814985e72194f921c0050f728) ContractCreation",
- "0xaa00000000000000000000000000000000000000.code: 0x1325 (0xa12ae05590de0c93a00bc7ac773c2fdb621e44f814985e72194f921c0050f728) -> (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) SelfDestruct",
"0xbb00000000000000000000000000000000000000.code: (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) ->0x1326 (0x3c54516221d604e623f358bc95996ca3242aaa109bddabcebda13db9b3f90dcb) ContractCreation",
+ "0xaa00000000000000000000000000000000000000.code: 0x1325 (0xa12ae05590de0c93a00bc7ac773c2fdb621e44f814985e72194f921c0050f728) -> (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) SelfDestruct",
"0xbb00000000000000000000000000000000000000.code: 0x1326 (0x3c54516221d604e623f358bc95996ca3242aaa109bddabcebda13db9b3f90dcb) -> (0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) SelfDestruct",
}
emitF := func(format string, a ...any) {
@@ -157,7 +159,8 @@ func TestHooks_OnCodeChangeV2(t *testing.T) {
sdb.SetCode(common.Address{0xbb}, []byte{0x13, 38}, tracing.CodeChangeContractCreation)
sdb.CreateContract(common.Address{0xbb})
- sdb.SelfDestruct6780(common.Address{0xbb})
+ sdb.SelfDestruct(common.Address{0xbb})
+ sdb.Finalise(true)
if len(result) != len(wants) {
t.Fatalf("number of tracing events wrong, have %d want %d", len(result), len(wants))
diff --git a/core/tracing/gen_nonce_change_reason_stringer.go b/core/tracing/gen_nonce_change_reason_stringer.go
index f775c1f3a6..cd19200db8 100644
--- a/core/tracing/gen_nonce_change_reason_stringer.go
+++ b/core/tracing/gen_nonce_change_reason_stringer.go
@@ -15,11 +15,12 @@ func _() {
_ = x[NonceChangeNewContract-4]
_ = x[NonceChangeAuthorization-5]
_ = x[NonceChangeRevert-6]
+ _ = x[NonceChangeSelfdestruct-7]
}
-const _NonceChangeReason_name = "UnspecifiedGenesisEoACallContractCreatorNewContractAuthorizationRevert"
+const _NonceChangeReason_name = "UnspecifiedGenesisEoACallContractCreatorNewContractAuthorizationRevertSelfdestruct"
-var _NonceChangeReason_index = [...]uint8{0, 11, 18, 25, 40, 51, 64, 70}
+var _NonceChangeReason_index = [...]uint8{0, 11, 18, 25, 40, 51, 64, 70, 82}
func (i NonceChangeReason) String() string {
if i >= NonceChangeReason(len(_NonceChangeReason_index)-1) {
diff --git a/core/tracing/hooks.go b/core/tracing/hooks.go
index d17b94cf9c..c85abe6482 100644
--- a/core/tracing/hooks.go
+++ b/core/tracing/hooks.go
@@ -432,6 +432,9 @@ const (
// NonceChangeRevert is emitted when the nonce is reverted back to a previous value due to call failure.
// It is only emitted when the tracer has opted in to use the journaling wrapper (WrapWithJournal).
NonceChangeRevert NonceChangeReason = 6
+
+ // NonceChangeSelfdestruct is emitted when the nonce is reset to zero due to a self-destruct
+ NonceChangeSelfdestruct NonceChangeReason = 7
)
// CodeChangeReason is used to indicate the reason for a code change.
diff --git a/core/vm/instructions.go b/core/vm/instructions.go
index 91886d939d..958cf9dedc 100644
--- a/core/vm/instructions.go
+++ b/core/vm/instructions.go
@@ -876,13 +876,25 @@ func opStop(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
}
func opSelfdestruct(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
- beneficiary := scope.Stack.pop()
- balance := evm.StateDB.GetBalance(scope.Contract.Address())
- evm.StateDB.AddBalance(beneficiary.Bytes20(), balance, tracing.BalanceIncreaseSelfdestruct)
- evm.StateDB.SelfDestruct(scope.Contract.Address())
+ var (
+ this = scope.Contract.Address()
+ balance = evm.StateDB.GetBalance(this)
+ top = scope.Stack.pop()
+ beneficiary = common.Address(top.Bytes20())
+ )
+
+ // The funds are burned immediately if the beneficiary is the caller itself,
+ // in this case, the beneficiary's balance is not increased.
+ if this != beneficiary {
+ evm.StateDB.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct)
+ }
+ // Clear any leftover funds for the account being destructed.
+ evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct)
+ evm.StateDB.SelfDestruct(this)
+
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.ToBig())
+ tracer.OnEnter(evm.depth, byte(SELFDESTRUCT), this, beneficiary, []byte{}, 0, balance.ToBig())
}
if tracer.OnExit != nil {
tracer.OnExit(evm.depth, []byte{}, 0, nil, false)
@@ -892,14 +904,33 @@ func opSelfdestruct(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
}
func opSelfdestruct6780(pc *uint64, evm *EVM, scope *ScopeContext) ([]byte, error) {
- 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()
+ balance = evm.StateDB.GetBalance(this)
+ top = scope.Stack.pop()
+ beneficiary = common.Address(top.Bytes20())
+
+ newContract = evm.StateDB.IsNewContract(this)
+ )
+
+ // Contract is new and will actually be deleted.
+ if newContract {
+ if this != beneficiary { // Skip no-op transfer when self-destructing to self.
+ evm.StateDB.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct)
+ }
+ evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct)
+ evm.StateDB.SelfDestruct(this)
+ }
+
+ // Contract already exists, only do transfer if beneficiary is not self.
+ if !newContract && this != beneficiary {
+ evm.StateDB.SubBalance(this, balance, tracing.BalanceDecreaseSelfdestruct)
+ evm.StateDB.AddBalance(beneficiary, balance, tracing.BalanceIncreaseSelfdestruct)
+ }
+
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.ToBig())
+ tracer.OnEnter(evm.depth, byte(SELFDESTRUCT), this, beneficiary, []byte{}, 0, balance.ToBig())
}
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 e2f6a65189..e285b18b0f 100644
--- a/core/vm/interface.go
+++ b/core/vm/interface.go
@@ -57,19 +57,17 @@ type StateDB interface {
GetTransientState(addr common.Address, key common.Hash) common.Hash
SetTransientState(addr common.Address, key, value common.Hash)
- SelfDestruct(common.Address) uint256.Int
+ SelfDestruct(common.Address)
HasSelfDestructed(common.Address) bool
- // SelfDestruct6780 is post-EIP6780 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.
- // This method returns the prior balance, along with a boolean which is
- // true iff the object was indeed destructed.
- SelfDestruct6780(common.Address) (uint256.Int, bool)
-
// Exist reports whether the given account exists in state.
// Notably this also returns true for self-destructed accounts within the current transaction.
Exist(common.Address) bool
+
+ // IsNewContract reports whether the contract at the given address was deployed
+ // during the current transaction.
+ IsNewContract(addr common.Address) bool
+
// Empty returns whether the given account is empty. Empty
// is defined according to EIP161 (balance = nonce = code = 0).
Empty(common.Address) bool
diff --git a/eth/tracers/internal/tracetest/selfdestruct_state_test.go b/eth/tracers/internal/tracetest/selfdestruct_state_test.go
new file mode 100644
index 0000000000..2c714b6dce
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_state_test.go
@@ -0,0 +1,653 @@
+// Copyright 2025 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 tracetest
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/beacon"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/tracing"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// accountState represents the expected final state of an account
+type accountState struct {
+ Balance *big.Int
+ Nonce uint64
+ Code []byte
+ Exists bool
+}
+
+// selfdestructStateTracer tracks state changes during selfdestruct operations
+type selfdestructStateTracer struct {
+ env *tracing.VMContext
+ accounts map[common.Address]*accountState
+}
+
+func newSelfdestructStateTracer() *selfdestructStateTracer {
+ return &selfdestructStateTracer{
+ accounts: make(map[common.Address]*accountState),
+ }
+}
+
+func (t *selfdestructStateTracer) OnTxStart(env *tracing.VMContext, tx *types.Transaction, from common.Address) {
+ t.env = env
+}
+
+func (t *selfdestructStateTracer) OnTxEnd(receipt *types.Receipt, err error) {
+ // Nothing to do
+}
+
+func (t *selfdestructStateTracer) getOrCreateAccount(addr common.Address) *accountState {
+ if acc, ok := t.accounts[addr]; ok {
+ return acc
+ }
+
+ // Initialize with current state from statedb
+ acc := &accountState{
+ Balance: t.env.StateDB.GetBalance(addr).ToBig(),
+ Nonce: t.env.StateDB.GetNonce(addr),
+ Code: t.env.StateDB.GetCode(addr),
+ Exists: t.env.StateDB.Exist(addr),
+ }
+ t.accounts[addr] = acc
+ return acc
+}
+
+func (t *selfdestructStateTracer) OnBalanceChange(addr common.Address, prev, new *big.Int, reason tracing.BalanceChangeReason) {
+ acc := t.getOrCreateAccount(addr)
+ acc.Balance = new
+}
+
+func (t *selfdestructStateTracer) OnNonceChangeV2(addr common.Address, prev, new uint64, reason tracing.NonceChangeReason) {
+ acc := t.getOrCreateAccount(addr)
+ acc.Nonce = new
+
+ // If this is a selfdestruct nonce change, mark account as not existing
+ if reason == tracing.NonceChangeSelfdestruct {
+ acc.Exists = false
+ }
+}
+
+func (t *selfdestructStateTracer) OnCodeChangeV2(addr common.Address, prevCodeHash common.Hash, prevCode []byte, codeHash common.Hash, code []byte, reason tracing.CodeChangeReason) {
+ acc := t.getOrCreateAccount(addr)
+ acc.Code = code
+
+ // If this is a selfdestruct code change, mark account as not existing
+ if reason == tracing.CodeChangeSelfDestruct {
+ acc.Exists = false
+ }
+}
+
+func (t *selfdestructStateTracer) Hooks() *tracing.Hooks {
+ return &tracing.Hooks{
+ OnTxStart: t.OnTxStart,
+ OnTxEnd: t.OnTxEnd,
+ OnBalanceChange: t.OnBalanceChange,
+ OnNonceChangeV2: t.OnNonceChangeV2,
+ OnCodeChangeV2: t.OnCodeChangeV2,
+ }
+}
+
+func (t *selfdestructStateTracer) Accounts() map[common.Address]*accountState {
+ return t.accounts
+}
+
+// verifyAccountState compares actual and expected account state and reports any mismatches
+func verifyAccountState(t *testing.T, addr common.Address, actual, expected *accountState) {
+ if actual.Balance.Cmp(expected.Balance) != 0 {
+ t.Errorf("address %s: balance mismatch: have %s, want %s",
+ addr.Hex(), actual.Balance, expected.Balance)
+ }
+ if actual.Nonce != expected.Nonce {
+ t.Errorf("address %s: nonce mismatch: have %d, want %d",
+ addr.Hex(), actual.Nonce, expected.Nonce)
+ }
+ if len(actual.Code) != len(expected.Code) {
+ t.Errorf("address %s: code length mismatch: have %d, want %d",
+ addr.Hex(), len(actual.Code), len(expected.Code))
+ }
+ if actual.Exists != expected.Exists {
+ t.Errorf("address %s: exists mismatch: have %v, want %v",
+ addr.Hex(), actual.Exists, expected.Exists)
+ }
+}
+
+// setupTestBlockchain creates a blockchain with the given genesis and transaction,
+// returns the blockchain, the first block, and a statedb at genesis for testing
+func setupTestBlockchain(t *testing.T, genesis *core.Genesis, tx *types.Transaction, useBeacon bool) (*core.BlockChain, *types.Block, *state.StateDB) {
+ var engine consensus.Engine
+ if useBeacon {
+ engine = beacon.New(ethash.NewFaker())
+ } else {
+ engine = ethash.NewFaker()
+ }
+
+ _, blocks, _ := core.GenerateChainWithGenesis(genesis, engine, 1, func(i int, b *core.BlockGen) {
+ b.AddTx(tx)
+ })
+ db := rawdb.NewMemoryDatabase()
+ blockchain, err := core.NewBlockChain(db, genesis, engine, nil)
+ if err != nil {
+ t.Fatalf("failed to create blockchain: %v", err)
+ }
+ if _, err := blockchain.InsertChain(blocks); err != nil {
+ t.Fatalf("failed to insert chain: %v", err)
+ }
+ genesisBlock := blockchain.GetBlockByNumber(0)
+ if genesisBlock == nil {
+ t.Fatalf("failed to get genesis block")
+ }
+ statedb, err := blockchain.StateAt(genesisBlock.Root())
+ if err != nil {
+ t.Fatalf("failed to get state: %v", err)
+ }
+
+ return blockchain, blocks[0], statedb
+}
+
+func TestSelfdestructStateTracer(t *testing.T) {
+ t.Parallel()
+
+ const (
+ // Gas limit high enough for all test scenarios (factory creation + multiple calls)
+ testGasLimit = 500000
+
+ // Common balance amounts used across tests
+ testBalanceInitial = 100 // Initial balance for contracts being tested
+ testBalanceSent = 50 // Amount sent back in sendback tests
+ testBalanceFactory = 200 // Factory needs extra balance for contract creation
+ )
+
+ // Helper to create *big.Int for wei amounts
+ wei := func(amount int64) *big.Int {
+ return big.NewInt(amount)
+ }
+
+ // Test account (transaction sender)
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ caller = crypto.PubkeyToAddress(key.PublicKey)
+ )
+
+ // Simple selfdestruct test contracts
+ var (
+ contract = common.HexToAddress("0x00000000000000000000000000000000000000bb")
+ recipient = common.HexToAddress("0x00000000000000000000000000000000000000cc")
+ )
+ // Build selfdestruct code: PUSH20 SELFDESTRUCT
+ selfdestructCode := []byte{byte(vm.PUSH20)}
+ selfdestructCode = append(selfdestructCode, recipient.Bytes()...)
+ selfdestructCode = append(selfdestructCode, byte(vm.SELFDESTRUCT))
+
+ // Factory test contracts (create-and-destroy pattern)
+ var (
+ factory = common.HexToAddress("0x00000000000000000000000000000000000000ff")
+ )
+ // Factory code: creates a contract with 100 wei and calls it to trigger selfdestruct back to factory
+ // See selfdestruct_test_contracts/factory.yul for source
+ // Runtime bytecode compiled with: solc --strict-assembly --evm-version paris factory.yul --bin
+ // (Using paris to avoid PUSH0 opcode which is not available pre-Shanghai)
+ var (
+ factoryCode = common.Hex2Bytes("6a6133ff6000526002601ef360a81b600052600080808080600b816064f05af100")
+ createdContractAddr = crypto.CreateAddress(factory, 0) // Address where factory creates the contract
+ )
+
+ // Sendback test contracts (A→B→A pattern)
+ // For the refund test: Coordinator calls A, then B
+ // A selfdestructs to B, B sends funds back to A
+ var (
+ contractA = common.HexToAddress("0x00000000000000000000000000000000000000aa")
+ contractB = common.HexToAddress("0x00000000000000000000000000000000000000bb")
+ coordinator = common.HexToAddress("0x00000000000000000000000000000000000000cc")
+ )
+ // Contract A: if msg.value > 0, accept funds; else selfdestruct to B
+ // See selfdestruct_test_contracts/contractA.yul for source
+ // Runtime bytecode compiled with: solc --strict-assembly --evm-version paris contractA.yul --bin
+ contractACode := common.Hex2Bytes("60003411600a5760bbff5b00")
+
+ // Contract B: sends 50 wei back to contract A
+ // See selfdestruct_test_contracts/contractB.yul for source
+ // Runtime bytecode compiled with: solc --strict-assembly --evm-version paris contractB.yul --bin
+ contractBCode := common.Hex2Bytes("6000808080603260aa5af100")
+
+ // Coordinator: calls A (A selfdestructs to B), then calls B (B sends funds to A)
+ // See selfdestruct_test_contracts/coordinator.yul for source
+ // Runtime bytecode compiled with: solc --strict-assembly --evm-version paris coordinator.yul --bin
+ coordinatorCode := common.Hex2Bytes("60008080808060aa818080808060bb955af1505af100")
+
+ // Factory for create-and-refund test: creates A with 100 wei, calls A, calls B
+ // See selfdestruct_test_contracts/factoryRefund.yul for source
+ // Runtime bytecode compiled with: solc --strict-assembly --evm-version paris factoryRefund.yul --bin
+ var (
+ factoryRefund = common.HexToAddress("0x00000000000000000000000000000000000000dd")
+ factoryRefundCode = common.Hex2Bytes("60008080808060bb78600c600d600039600c6000f3fe60003411600a5760bbff5b0082528180808080601960076064f05af1505af100")
+ createdContractAddrA = crypto.CreateAddress(factoryRefund, 0) // Address where factory creates contract A
+ )
+
+ // Self-destruct-to-self test contracts
+ var (
+ contractSelfDestruct = common.HexToAddress("0x00000000000000000000000000000000000000aa")
+ coordinatorSendAfter = common.HexToAddress("0x00000000000000000000000000000000000000ee")
+ )
+ // Contract that selfdestructs to self
+ // See selfdestruct_test_contracts/contractSelfDestruct.yul
+ contractSelfDestructCode := common.Hex2Bytes("30ff")
+
+ // Coordinator: calls contract (triggers selfdestruct to self), stores balance, sends 50 wei, stores balance again
+ // See selfdestruct_test_contracts/coordinatorSendAfter.yul
+ coordinatorSendAfterCode := common.Hex2Bytes("60aa600080808080855af150803160005560008080806032855af1503160015500")
+
+ // Factory with balance checking: creates contract, calls it, checks balances
+ // See selfdestruct_test_contracts/factorySelfDestructBalanceCheck.yul
+ var (
+ factorySelfDestructBalanceCheck = common.HexToAddress("0x00000000000000000000000000000000000000fd")
+ factorySelfDestructBalanceCheckCode = common.Hex2Bytes("6e6002600d60003960026000f3fe30ff600052600f60116064f0600080808080855af150803160005560008080806032855af1503160015500")
+ createdContractAddrSelfBalanceCheck = crypto.CreateAddress(factorySelfDestructBalanceCheck, 0)
+ )
+
+ tests := []struct {
+ name string
+ description string
+ targetContract common.Address
+ genesis *core.Genesis
+ useBeacon bool
+ expectedResults map[common.Address]accountState
+ expectedStorage map[common.Address]map[uint64]*big.Int
+ }{
+ {
+ name: "pre_6780_existing",
+ description: "Pre-EIP-6780: Existing contract selfdestructs to recipient. Contract should be destroyed and balance transferred.",
+ targetContract: contract,
+ genesis: &core.Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ contract: {
+ Balance: wei(testBalanceInitial),
+ Code: selfdestructCode,
+ },
+ },
+ },
+ useBeacon: false,
+ expectedResults: map[common.Address]accountState{
+ contract: {
+ Balance: wei(0),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: false,
+ },
+ recipient: {
+ Balance: wei(testBalanceInitial), // Received contract's balance
+ Nonce: 0,
+ Code: []byte{},
+ Exists: true,
+ },
+ },
+ },
+ {
+ name: "post_6780_existing",
+ description: "Post-EIP-6780: Existing contract selfdestructs to recipient. Balance transferred but contract NOT destroyed (code/storage remain).",
+ targetContract: contract,
+ genesis: &core.Genesis{
+ Config: params.AllDevChainProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ contract: {
+ Balance: wei(testBalanceInitial),
+ Code: selfdestructCode,
+ },
+ },
+ },
+ useBeacon: true,
+ expectedResults: map[common.Address]accountState{
+ contract: {
+ Balance: wei(0),
+ Nonce: 0,
+ Code: selfdestructCode,
+ Exists: true,
+ },
+ recipient: {
+ Balance: wei(testBalanceInitial),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: true,
+ },
+ },
+ },
+ {
+ name: "pre_6780_create_destroy",
+ description: "Pre-EIP-6780: Factory creates contract with 100 wei, contract selfdestructs back to factory. Contract destroyed, factory gets refund.",
+ targetContract: factory,
+ genesis: &core.Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ factory: {
+ Balance: wei(testBalanceFactory),
+ Code: factoryCode,
+ },
+ },
+ },
+ useBeacon: false,
+ expectedResults: map[common.Address]accountState{
+ factory: {
+ Balance: wei(testBalanceFactory),
+ Nonce: 1,
+ Code: factoryCode,
+ Exists: true,
+ },
+ createdContractAddr: {
+ Balance: wei(0),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: false,
+ },
+ },
+ },
+ {
+ name: "post_6780_create_destroy",
+ description: "Post-EIP-6780: Factory creates contract with 100 wei, contract selfdestructs back to factory. Contract destroyed (EIP-6780 exception for same-tx creation).",
+ targetContract: factory,
+ genesis: &core.Genesis{
+ Config: params.AllDevChainProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ factory: {
+ Balance: wei(testBalanceFactory),
+ Code: factoryCode,
+ },
+ },
+ },
+ useBeacon: true,
+ expectedResults: map[common.Address]accountState{
+ factory: {
+ Balance: wei(testBalanceFactory),
+ Nonce: 1,
+ Code: factoryCode,
+ Exists: true,
+ },
+ createdContractAddr: {
+ Balance: wei(0),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: false,
+ },
+ },
+ },
+ {
+ name: "pre_6780_sendback",
+ description: "Pre-EIP-6780: Contract A selfdestructs sending funds to B, then B sends funds back to A's address. Funds sent to destroyed address are burnt.",
+ targetContract: coordinator,
+ genesis: &core.Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ contractA: {
+ Balance: wei(testBalanceInitial),
+ Code: contractACode,
+ },
+ contractB: {
+ Balance: wei(0),
+ Code: contractBCode,
+ },
+ coordinator: {
+ Code: coordinatorCode,
+ },
+ },
+ },
+ useBeacon: false,
+ expectedResults: map[common.Address]accountState{
+ contractA: {
+ Balance: wei(0),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: false,
+ },
+ contractB: {
+ // 100 received - 50 sent back
+ Balance: wei(testBalanceSent),
+ Nonce: 0,
+ Code: contractBCode,
+ Exists: true,
+ },
+ },
+ },
+ {
+ name: "post_6780_existing_sendback",
+ description: "Post-EIP-6780: Existing contract A selfdestructs to B, then B sends funds back to A. Funds are NOT burnt (A still exists post-6780).",
+ targetContract: coordinator,
+ genesis: &core.Genesis{
+ Config: params.AllDevChainProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ contractA: {
+ Balance: wei(testBalanceInitial),
+ Code: contractACode,
+ },
+ contractB: {
+ Balance: wei(0),
+ Code: contractBCode,
+ },
+ coordinator: {
+ Code: coordinatorCode,
+ },
+ },
+ },
+ useBeacon: true,
+ expectedResults: map[common.Address]accountState{
+ contractA: {
+ Balance: wei(testBalanceSent),
+ Nonce: 0,
+ Code: contractACode,
+ Exists: true,
+ },
+ contractB: {
+ Balance: wei(testBalanceSent),
+ Nonce: 0,
+ Code: contractBCode,
+ Exists: true,
+ },
+ },
+ },
+ {
+ name: "post_6780_create_destroy_sendback",
+ description: "Post-EIP-6780: Factory creates A, A selfdestructs to B, B sends funds back to A. Funds are burnt (A was destroyed via EIP-6780 exception).",
+ targetContract: factoryRefund,
+ genesis: &core.Genesis{
+ Config: params.AllDevChainProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ contractB: {
+ Balance: wei(0),
+ Code: contractBCode,
+ },
+ factoryRefund: {
+ Balance: wei(testBalanceFactory),
+ Code: factoryRefundCode,
+ },
+ },
+ },
+ useBeacon: true,
+ expectedResults: map[common.Address]accountState{
+ createdContractAddrA: {
+ // Funds sent back are burnt!
+ Balance: wei(0),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: false,
+ },
+ contractB: {
+ Balance: wei(testBalanceSent),
+ Nonce: 0,
+ Code: contractBCode,
+ Exists: true,
+ },
+ },
+ },
+ {
+ name: "post_6780_existing_to_self",
+ description: "Post-EIP-6780: Pre-existing contract selfdestructs to itself. Balance NOT burnt (selfdestruct-to-self is no-op for existing contracts).",
+ targetContract: coordinatorSendAfter,
+ genesis: &core.Genesis{
+ Config: params.AllDevChainProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ contractSelfDestruct: {
+ Balance: wei(testBalanceInitial),
+ Code: contractSelfDestructCode,
+ },
+ coordinatorSendAfter: {
+ Balance: wei(testBalanceInitial),
+ Code: coordinatorSendAfterCode,
+ },
+ },
+ },
+ useBeacon: true,
+ expectedResults: map[common.Address]accountState{
+ contractSelfDestruct: {
+ Balance: wei(150),
+ Nonce: 0,
+ Code: contractSelfDestructCode,
+ Exists: true,
+ },
+ coordinatorSendAfter: {
+ Balance: wei(testBalanceSent),
+ Nonce: 0,
+ Code: coordinatorSendAfterCode,
+ Exists: true,
+ },
+ },
+ expectedStorage: map[common.Address]map[uint64]*big.Int{
+ coordinatorSendAfter: {
+ 0: wei(testBalanceInitial),
+ 1: wei(150),
+ },
+ },
+ },
+ {
+ name: "post_6780_create_destroy_to_self",
+ description: "Post-EIP-6780: Factory creates contract, contract selfdestructs to itself. Balance IS burnt and contract destroyed (EIP-6780 exception for same-tx creation).",
+ targetContract: factorySelfDestructBalanceCheck,
+ genesis: &core.Genesis{
+ Config: params.AllDevChainProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ caller: {Balance: big.NewInt(params.Ether)},
+ factorySelfDestructBalanceCheck: {
+ Balance: wei(testBalanceFactory),
+ Code: factorySelfDestructBalanceCheckCode,
+ },
+ },
+ },
+ useBeacon: true,
+ expectedResults: map[common.Address]accountState{
+ createdContractAddrSelfBalanceCheck: {
+ Balance: wei(0),
+ Nonce: 0,
+ Code: []byte{},
+ Exists: false,
+ },
+ factorySelfDestructBalanceCheck: {
+ Balance: wei(testBalanceSent),
+ Nonce: 1,
+ Code: factorySelfDestructBalanceCheckCode,
+ Exists: true,
+ },
+ },
+ expectedStorage: map[common.Address]map[uint64]*big.Int{
+ factorySelfDestructBalanceCheck: {
+ 0: wei(0),
+ 1: wei(0),
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ signer = types.HomesteadSigner{}
+ tx *types.Transaction
+ err error
+ )
+
+ tx, err = types.SignTx(types.NewTx(&types.LegacyTx{
+ Nonce: 0,
+ To: &tt.targetContract,
+ Value: big.NewInt(0),
+ Gas: testGasLimit,
+ GasPrice: big.NewInt(params.InitialBaseFee * 2),
+ Data: nil,
+ }), signer, key)
+ if err != nil {
+ t.Fatalf("failed to sign transaction: %v", err)
+ }
+
+ blockchain, block, statedb := setupTestBlockchain(t, tt.genesis, tx, tt.useBeacon)
+ defer blockchain.Stop()
+
+ tracer := newSelfdestructStateTracer()
+ hookedState := state.NewHookedState(statedb, tracer.Hooks())
+ msg, err := core.TransactionToMessage(tx, signer, nil)
+ if err != nil {
+ t.Fatalf("failed to prepare transaction for tracing: %v", err)
+ }
+ context := core.NewEVMBlockContext(block.Header(), blockchain, nil)
+ evm := vm.NewEVM(context, hookedState, tt.genesis.Config, vm.Config{Tracer: tracer.Hooks()})
+ usedGas := uint64(0)
+ _, err = core.ApplyTransactionWithEVM(msg, new(core.GasPool).AddGas(tx.Gas()), statedb, block.Number(), block.Hash(), block.Time(), tx, &usedGas, evm)
+ if err != nil {
+ t.Fatalf("failed to execute transaction: %v", err)
+ }
+
+ results := tracer.Accounts()
+
+ // Verify storage
+ for addr, expectedSlots := range tt.expectedStorage {
+ for slot, expectedValue := range expectedSlots {
+ actualValue := statedb.GetState(addr, common.BigToHash(big.NewInt(int64(slot))))
+ if actualValue.Big().Cmp(expectedValue) != 0 {
+ t.Errorf("address %s slot %d: storage mismatch: have %s, want %s",
+ addr.Hex(), slot, actualValue.Big(), expectedValue)
+ }
+ }
+ }
+
+ // Verify results
+ for addr, expected := range tt.expectedResults {
+ actual, ok := results[addr]
+ if !ok {
+ t.Errorf("address %s missing from results", addr.Hex())
+ continue
+ }
+ verifyAccountState(t, addr, actual, &expected)
+ }
+ })
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractA.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractA.yul
new file mode 100644
index 0000000000..109551f26e
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractA.yul
@@ -0,0 +1,18 @@
+object "ContractA" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ // If receiving funds (msg.value > 0), just accept them and return
+ if gt(callvalue(), 0) {
+ stop()
+ }
+
+ // Otherwise, selfdestruct to B (transfers balance immediately, then stops execution)
+ let contractB := 0x00000000000000000000000000000000000000bb
+ selfdestruct(contractB)
+ }
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractB.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractB.yul
new file mode 100644
index 0000000000..c737355fb6
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractB.yul
@@ -0,0 +1,14 @@
+object "ContractB" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ // Send 50 wei back to contract A
+ let contractA := 0x00000000000000000000000000000000000000aa
+ let success := call(gas(), contractA, 50, 0, 0, 0, 0)
+ stop()
+ }
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractSelfDestruct.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractSelfDestruct.yul
new file mode 100644
index 0000000000..73884c5dd4
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/contractSelfDestruct.yul
@@ -0,0 +1,12 @@
+object "ContractSelfDestruct" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ // Simply selfdestruct to self
+ selfdestruct(address())
+ }
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/coordinator.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/coordinator.yul
new file mode 100644
index 0000000000..54bd5c08f3
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/coordinator.yul
@@ -0,0 +1,20 @@
+object "Coordinator" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ let contractA := 0x00000000000000000000000000000000000000aa
+ let contractB := 0x00000000000000000000000000000000000000bb
+
+ // First, call A (A will selfdestruct to B)
+ pop(call(gas(), contractA, 0, 0, 0, 0, 0))
+
+ // Then, call B (B will send funds back to A)
+ pop(call(gas(), contractB, 0, 0, 0, 0, 0))
+
+ stop()
+ }
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/coordinatorSendAfter.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/coordinatorSendAfter.yul
new file mode 100644
index 0000000000..9473d1f3ef
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/coordinatorSendAfter.yul
@@ -0,0 +1,27 @@
+object "CoordinatorSendAfter" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ let contractAddr := 0x00000000000000000000000000000000000000aa
+
+ // Call contract (triggers selfdestruct to self, burning its balance)
+ pop(call(gas(), contractAddr, 0, 0, 0, 0, 0))
+
+ // Check contract's balance immediately after selfdestruct
+ // Store in slot 0 to verify it's 0 (proving immediate burn)
+ sstore(0, balance(contractAddr))
+
+ // Send 50 wei to the contract (after it selfdestructed)
+ pop(call(gas(), contractAddr, 50, 0, 0, 0, 0))
+
+ // Check balance again after sending funds
+ // Store in slot 1 to verify it's 50 (new funds not burnt)
+ sstore(1, balance(contractAddr))
+
+ stop()
+ }
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/factoryRefund.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/factoryRefund.yul
new file mode 100644
index 0000000000..f52a46fcc3
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/factoryRefund.yul
@@ -0,0 +1,28 @@
+object "FactoryRefund" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ let contractB := 0x00000000000000000000000000000000000000bb
+
+ // Store the deploy bytecode for contract A in memory
+ // Full deploy bytecode from: solc --strict-assembly --evm-version paris contractA.yul --bin
+ // Including the 0xfe separator: 600c600d600039600c6000f3fe60003411600a5760bbff5b00
+ // That's 25 bytes, padded to 32 bytes with 7 zero bytes at the front
+ mstore(0, 0x0000000000000000000000000000600c600d600039600c6000f3fe60003411600a5760bbff5b00)
+
+ // CREATE contract A with 100 wei, using 25 bytes starting at position 7
+ let contractA := create(100, 7, 25)
+
+ // Call contract A (triggers selfdestruct to B)
+ pop(call(gas(), contractA, 0, 0, 0, 0, 0))
+
+ // Call contract B (B sends 50 wei back to A)
+ pop(call(gas(), contractB, 0, 0, 0, 0, 0))
+
+ stop()
+ }
+ }
+}
diff --git a/eth/tracers/internal/tracetest/selfdestruct_test_contracts/factorySelfDestructBalanceCheck.yul b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/factorySelfDestructBalanceCheck.yul
new file mode 100644
index 0000000000..46f4628419
--- /dev/null
+++ b/eth/tracers/internal/tracetest/selfdestruct_test_contracts/factorySelfDestructBalanceCheck.yul
@@ -0,0 +1,35 @@
+object "FactorySelfDestructBalanceCheck" {
+ code {
+ datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
+ return(0, datasize("Runtime"))
+ }
+ object "Runtime" {
+ code {
+ // Get the full deploy bytecode for ContractSelfDestruct
+ // Compiled with: solc --strict-assembly --evm-version paris contractSelfDestruct.yul --bin
+ // Full bytecode: 6002600d60003960026000f3fe30ff
+ // That's 15 bytes total, padded to 32 bytes with 17 zero bytes at front
+ mstore(0, 0x0000000000000000000000000000000000000000006002600d60003960026000f3fe30ff)
+
+ // CREATE contract with 100 wei, using deploy bytecode
+ // The bytecode is 15 bytes, starts at position 17 in the 32-byte word
+ let contractAddr := create(100, 17, 15)
+
+ // Call the created contract (triggers selfdestruct to self)
+ pop(call(gas(), contractAddr, 0, 0, 0, 0, 0))
+
+ // Check contract's balance immediately after selfdestruct
+ // Store in slot 0 to verify it's 0 (proving immediate burn)
+ sstore(0, balance(contractAddr))
+
+ // Send 50 wei to the contract (after it selfdestructed)
+ pop(call(gas(), contractAddr, 50, 0, 0, 0, 0))
+
+ // Check balance again after sending funds
+ // Store in slot 1 to verify it's 0 (funds sent to destroyed contract are burnt)
+ sstore(1, balance(contractAddr))
+
+ stop()
+ }
+ }
+}