core: invoke selfdestruct tracer hooks during finalisation (#32919)
Some checks are pending
/ Linux Build (push) Waiting to run
/ Linux Build (arm) (push) Waiting to run
/ Keeper Build (push) Waiting to run
/ Windows Build (push) Waiting to run
/ Docker Image (push) Waiting to run

The core part of this PR that we need to adopt is to move the code and
nonce change hook invocations to occur at tx finalization, instead of
when the selfdestruct opcode is called.

Additionally:
* remove `SelfDestruct6780` now that it is essentially the same as
`SelfDestruct` just gated by `is new contract`
* don't duplicate `BalanceIncreaseSelfdestruct` (transfer to recipient
of selfdestruct) in the hooked statedb and in the opcode handler for the
selfdestruct opcode.
* balance is burned immediately when the beneficiary of the selfdestruct
is the sender, and the contract was created in the same transaction.
Previously we emit two balance increases to the recipient (see above
point), and a balance decrease from the sender.

---------

Co-authored-by: Sina Mahmoodi <itz.s1na@gmail.com>
Co-authored-by: Gary Rong <garyrong0905@gmail.com>
Co-authored-by: lightclient <lightclient@protonmail.com>
This commit is contained in:
jwasinger 2026-01-17 07:10:08 +09:00 committed by GitHub
parent b6fb79cdf9
commit 715bf8e81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 918 additions and 99 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}
}
}

View file

@ -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())
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}