Skip to content

fix(core/state,eth/tracers): report the selfdestruct burn from the state layer - #2870

Merged
gzliudan merged 3 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-supply-tracer-selfdestruct-burn-hook
Oct 28, 2026
Merged

gzliudan merged 3 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-supply-tracer-selfdestruct-burn-hook

Conversation

@gzliudan

@gzliudan gzliudan commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The supply live tracer (--vmtrace supply) inferred a selfdestruct burn from the SELFDESTRUCT enter event: a self-destruct to self was counted as burnt whenever that event carried a non-zero balance.

That inference is wrong from EIP-6780 (Cancun) onwards - a pre-existing contract is not destructed at all and keeps its balance - and stays wrong from EIP-8246 (Amsterdam) onwards for a contract created in the same transaction, whose balance is preserved as well. The affected set is every chain that reached Cancun with --vmtrace supply enabled.

The state knows how much a destruction really burns, so it reports that:

 SELFDESTRUCT
   move the balance to the beneficiary
   destroy the account
-    tracer: from == to && value > 0   =>   burn.misc += value
+    state:  remember what the destruct cleared       (stateObject.destructedBalance)
+    state:  at finalisation, burn = cleared + ether that arrived afterwards
+    tracer: burn.misc += BalanceDecreaseSelfdestructBurn
  • core/state: the balance a destruct clears is remembered on the account. Each destruct journals the value it is about to extend, so a revert drops exactly that destruct's contribution again - also for a second destruct of the same account, whose clearing is not covered by the journalled flags.
  • core/state/statedb_hooked.go: the finalisation reports the burnt balance, which is what the destruct cleared plus the ether that arrives in the destructed account afterwards. The EIP-8246 case keeps its balance and therefore reports no burn, as before.
  • eth/tracers/live/supply.go: the tracer drops its own inference and accounts the burn from the BalanceDecreaseSelfdestructBurn event alone; the callstack plumbing that only fed the removed inference goes with it.

For that accounting to hold, what a destruct clears has to be what it destroys - which only held for the EIP-6780 path so far:

  • core/vm: the pre-Cancun SELFDESTRUCT credited the beneficiary first and cleared the account afterwards, so the state saw the same clearing for a transfer and for a burn, and cleared the full balance even when it had just been handed to the beneficiary (to itself, the credit doubled it). It now moves the balance with an explicit decrease/increase pair before the account is destroyed, exactly as the EIP-6780 variant does: a self-destruct to another account has nothing left to clear, one to itself clears exactly the balance. The final balances are unchanged.

BalanceDecreaseSelfdestructBurn now carries the whole burnt balance instead of only the ether that arrived in an already destructed account; its documentation in core/tracing/hooks.go is extended accordingly. The only production consumer of that event is the supply tracer.

Evidence

TestSelfdestructBurnHook runs the fork matrix through block processing with a hooked finalisation:

self-destruct legacy (pre-Cancun) Cancun Amsterdam
created in the tx, to self 1,000,000 1,000,000 0
created in the tx, to another account 0 0 0
pre-existing, to self 1,000 0 not destructed
pre-existing, to another account 0 0 not destructed

The pre-existing rows are the same under Amsterdam, where EIP-6780 already leaves the contract alone. Before the core/vm change the four legacy rows reported 2000000 / 1000000 / 2000 / 1000; removing the decrease/increase pair again (canary) makes exactly those four cases fail. The same test also asserts the final contract and other account balances of every row, so the burn accounting cannot drift into moving the ether elsewhere.

  • core/state: TestSelfdestructBurnReverted, TestSelfdestructBurnPreservedBalance, TestSelfdestructBurnSurvivesCopy (a StateDB copied after a destruct still reports it) and TestSelfdestructBurnSecondDestruct (ether arriving between two destructs is burnt by the second one; a revert leaves only the first destruct's burn), TestDeleteAddressRevertKeepsDestructedBalance (a DeleteAddress reverted again keeps the pending burn) and TestCopyIsolatesDestructedBalance (a copy keeps its own pending burn when the original destructs again) and TestSelfdestructBurnIntermediateRoot (the pre-Byzantium IntermediateRoot path reports the burn and returns the unchanged root).
  • eth/tracers/live: the enter event alone reports no burn, the balance hook reports the whole burn.
  • make all, gofmt/goimports clean, go mod tidy -diff reports no untidy module files, make generate reports no stale files, golangci-lint run --new-from-rev=6920f07082 (v2.15.0) reports 0 issues.
  • go test ./core/ ./core/state/... ./core/vm/... ./core/tracing/... ./eth/tracers/... passes, as do the direct downstream packages (./eth/... ./internal/... ./miner/... ./tests/).

Merge Danger

Door: two-way - the change is tracing-only. A follow-up commit can restore the previous accounting; no state, block or consensus output depends on it.

Blast Radius: the supply tracer's burn.misc. For pre-Cancun blocks (XDC mainnet before 98,802,000, testnet before 71,551,800) the number changes from "every balance a destruct cleared" to "what is really destroyed"; from Cancun onwards the tracer stops reporting burns that never happened. Chains that never reached Cancun also see an explicit balance decrease for the destructed account in the hook stream, which has no consumer besides this tracer.

Relation to #2833

#2833 (merged as c2725ee4de) gated the tracer's inference on the Amsterdam fork and pulled the chain config in to do so. With the burn reported by the state layer that gate has nothing left to decide, so it goes together with the chainConfig plumbing behind it and its TestSupplySelfdestructForkGate; the enter-only assertion moves into the balance-change test.

Upstream

go-ethereum has the same latent gap: its supply tracer still infers the burn from the enter event (eth/tracers/live/supply.go, from == to), and its opSelfdestruct6780 leaves the balance of a pre-existing contract untouched while still reporting that balance to the tracer. Its EIP-8246 implementation (ethereum#35219) only changes when the balance hook fires (today !rules.IsAmsterdam); it does not touch the tracer's inference. There is no upstream fix to port.

The approach is not fork-specific: upstream has the same hooked state database (core/state/statedb_hooked.go, emitting BalanceDecreaseSelfdestructBurn at finalisation), so the burnt balance can be reported from there as well. A port would have to adapt to its uint256 balances and to the fork gate in Finalise; that is outside the scope of this PR.

Known limitation

Re-creating a destructed address within the same transaction would replace its state object and drop the pending burn. That does not happen: a destructed account still exists in the state until finalisation, and the EVM refuses to create on an existing account (address collision); the field comment records it.

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1419b77f-dbff-4324-94ac-545b7d83c513

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gzliudan
gzliudan force-pushed the fix-supply-tracer-selfdestruct-burn-hook branch 2 times, most recently from 2503a10 to e9fe64f Compare October 26, 2026 05:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Removing the call hooks causes pre-Byzantium selfdestruct-to-self burns to report zero burn.misc because IntermediateRoot bypasses hooked finalisation, requiring a fix and regression coverage.

1 open issue
What changed in this PR

Moves selfdestruct burn accounting into the state layer so the supply tracer reports destroyed balances rather than inferring burns from execution events, replacing the fork-specific gate introduced in #2833.

Changes:

  • Tracks cleared balances through reverts and state copies, reporting burns at finalisation.
  • Adjusts legacy SELFDESTRUCT balance transfers and removes the supply tracer’s call-stack inference.
  • Adds fork-matrix and state-lifecycle regression coverage.
File Description
eth/​tracers/​live/​supply.go Uses balance-change events as the sole selfdestruct burn source.
eth/​tracers/​live/​supply_test.go Tests burn accumulation and ignores non-burn events.
core/​vm/​instructions.go Debits the legacy selfdestruct balance before crediting the beneficiary.
core/​vm/​eip8246_test.go Updates expected finalisation burn events.
core/​tracing/​hooks.go Documents expanded burn-event semantics.
core/​state/​statedb.go Accumulates and journals balances cleared by destruction.
core/​state/​statedb_hooked.go Reports cleared and subsequently received balances as burns.
core/​state/​statedb_hooked_test.go Covers reverted, repeated, preserved, and copied destruction state.
core/​state/​statedb_eip8246_test.go Updates expected pre-Amsterdam burn totals.
core/​state/​statedb_deleteaddress_test.go Tests pending-burn restoration after reverted deletion.
core/​state/​state_object.go Stores and deep-copies pending burn balances.
core/​state/​journal.go Preserves pending burns across rollback and journal copying.
core/​selfdestruct_burn_test.go Adds block-processing tests for burns and final balances across forks.

Review effort: Balanced

Comment thread eth/tracers/live/supply.go Resolved
…ate layer

The supply live tracer inferred a selfdestruct burn from the SELFDESTRUCT
enter event: a self-destruct to self was counted as burnt whenever that event
carried a non-zero balance. The inference is wrong from EIP-6780 (Cancun)
onwards, where a pre-existing contract is not destructed at all and keeps its
balance, and from EIP-8246 (Amsterdam) onwards, where the balance is preserved
even for a contract created in the same transaction. The tracer therefore
over-reported burn.misc on every chain that reached Cancun.

The state knows how much a destruction really burns, so let it report that:
the balance a destruct clears is remembered on the account and reported at
finalisation, together with the ether that arrives in the destructed account
afterwards. A reverted destruct drops that memory again, so nothing is
reported for it. The supply tracer drops its own inference and accounts the
burn from the BalanceDecreaseSelfdestructBurn event alone.

For that accounting to hold, the balance a destruct clears has to be the
balance it destroys. The EIP-6780 SELFDESTRUCT already moves the balance with
an explicit decrease/increase pair before the account is destroyed, so it has
nothing left to clear when the balance went to another account, and clears
exactly the balance when the account appointed itself. The pre-Cancun
implementation credited the beneficiary first and cleared the account
afterwards instead, which left the state unable to tell a transfer from a
burn; it now moves the balance the same way. The final balances are unchanged,
only the tracing picture becomes truthful.

That event now carries the whole burnt balance instead of only the ether that
arrived in an already destructed account, so its documented meaning is
extended accordingly. The only production consumer of the event is the supply
tracer.

Tests: core/selfdestruct_burn_test.go drives the cases through block
processing for every fork: up to Cancun a self-destruct to itself burns the
balance and one to another account burns nothing, a pre-existing contract
burns nothing from Cancun onwards and EIP-8246 keeps the balance.
The same test asserts the final balances of the contract and of the other
account, so the burn accounting cannot drift into moving the ether elsewhere.
core/state covers the reverted destruct, the preserved balance, the copied
state and successive destructs of one account. eth/tracers/live covers the
tracer side: the balance hook alone reports the burn, every other balance
reason is ignored.

dev-upgrade already carried the tracer-side fork gate from XinFinOrg#2833, which kept
that inference on IsAmsterdam(block) and pulled in the chain config to do so.
With the burn reported by the state layer the gate has nothing left to decide,
so it goes together with the inference and the chain-config plumbing behind
it, along with the callstack wiring that only fed the removed inference.
@gzliudan
gzliudan force-pushed the fix-supply-tracer-selfdestruct-burn-hook branch from 2157397 to d4ccc6d Compare October 28, 2026 04:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the state-layer SELFDESTRUCT burn-accounting change.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@gzliudan
gzliudan merged commit 203d2e3 into XinFinOrg:dev-upgrade Oct 28, 2026
17 checks passed
@gzliudan
gzliudan deleted the fix-supply-tracer-selfdestruct-burn-hook branch October 28, 2026 04:14
gzliudan added a commit to gzliudan/XDPoSChain that referenced this pull request Oct 30, 2026
…9347, close XinFinOrg#2904

The supply live tracer only had hook-level coverage, so nothing pinned what it
writes when a real block is imported: the genesis allocation and the block
reward as issuance, the EIP-1559 fee burn, and the misc burn the state layer
reports for a selfdestruct (XinFinOrg#2870).

Port go-ethereum's eth/tracers/internal/tracetest/supply_test.go (ethereum#29347) and
adapt it to this tree:

- no beacon merge: blocks are generated and imported with the ethash faker,
  which also pays the block reward, so the selfdestruct cases expect issuance
  as well;
- only the EIP-6780 selfdestruct exists, so the upstream pre-Cancun
  expectations are dropped and the ported cases pin the 6780 outcome;
- two new cases cover the destruct that still burns, a contract created and
  destroyed in one transaction, behind a config with Amsterdam left
  unscheduled (EIP-8246 keeps that balance), and its reverted variant;
- no beacon withdrawals, so TestSupplyWithdrawals is not ported;
- the chain is stopped from t.Cleanup instead of the helper, because Stop
  closes the state database the callers still read.

Ref: ethereum#29347
Close XinFinOrg#2904
gzliudan added a commit to gzliudan/XDPoSChain that referenced this pull request Oct 30, 2026
…9347, close XinFinOrg#2904

The supply live tracer only had hook-level coverage, so nothing pinned what it
writes when a real block is imported: the genesis allocation and the block
reward as issuance, the EIP-1559 fee burn, and the misc burn the state layer
reports for a selfdestruct (XinFinOrg#2870).

Port go-ethereum's eth/tracers/internal/tracetest/supply_test.go (ethereum#29347) and
adapt it to this tree:

- no beacon merge: blocks are generated and imported with the ethash faker,
  which also pays the block reward, so the selfdestruct cases expect issuance
  as well;
- only the EIP-6780 selfdestruct exists, so the upstream pre-Cancun
  expectations are dropped and the ported cases pin the 6780 outcome;
- two new cases cover the destruct that still burns, a contract created and
  destroyed in one transaction, behind a config with Amsterdam left
  unscheduled (EIP-8246 keeps that balance), and its reverted variant;
- no beacon withdrawals, so TestSupplyWithdrawals is not ported;
- the chain is stopped from t.Cleanup instead of the helper, because Stop
  closes the state database the callers still read.

Ref: ethereum#29347
Close XinFinOrg#2904
gzliudan added a commit to gzliudan/XDPoSChain that referenced this pull request Oct 30, 2026
…9347, close XinFinOrg#2904

The supply live tracer only had hook-level coverage, so nothing pinned what it
writes when a real block is imported: the genesis allocation and the block
reward as issuance, the EIP-1559 fee burn, and the misc burn the state layer
reports for a selfdestruct (XinFinOrg#2870).

Port go-ethereum's eth/tracers/internal/tracetest/supply_test.go (ethereum#29347) and
adapt it to this tree:

- no beacon merge: blocks are generated and imported with the ethash faker,
  which also pays the block reward, so the selfdestruct cases expect issuance
  as well;
- only the EIP-6780 selfdestruct exists, so the upstream pre-Cancun
  expectations are dropped and the ported cases pin the 6780 outcome;
- two new cases cover the destruct that still burns, a contract created and
  destroyed in one transaction, behind a config with Amsterdam left
  unscheduled (EIP-8246 keeps that balance), and its reverted variant;
- no beacon withdrawals, so TestSupplyWithdrawals is not ported;
- the chain is stopped from t.Cleanup instead of the helper, because Stop
  closes the state database the callers still read.

Ref: ethereum#29347
Close XinFinOrg#2904
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants