Repository navigation
fix(core/state,eth/tracers): report the selfdestruct burn from the state layer - #2870
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
f8e7829 to
a40c61a
Compare
2503a10 to
e9fe64f
Compare
1e1f99c to
2157397
Compare
There was a problem hiding this comment.
🟡 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
SELFDESTRUCTbalance 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
…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.
2157397 to
d4ccc6d
Compare
…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
…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
…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

Summary
The supply live tracer (
--vmtrace supply) inferred a selfdestruct burn from theSELFDESTRUCTenter 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 supplyenabled.The state knows how much a destruction really burns, so it reports that:
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 theBalanceDecreaseSelfdestructBurnevent 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-CancunSELFDESTRUCTcredited 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.BalanceDecreaseSelfdestructBurnnow carries the whole burnt balance instead of only the ether that arrived in an already destructed account; its documentation incore/tracing/hooks.gois extended accordingly. The only production consumer of that event is the supply tracer.Evidence
TestSelfdestructBurnHookruns the fork matrix through block processing with a hooked finalisation:The pre-existing rows are the same under Amsterdam, where EIP-6780 already leaves the contract alone. Before the
core/vmchange the four legacy rows reported2000000 / 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(aStateDBcopied after a destruct still reports it) andTestSelfdestructBurnSecondDestruct(ether arriving between two destructs is burnt by the second one; a revert leaves only the first destruct's burn),TestDeleteAddressRevertKeepsDestructedBalance(aDeleteAddressreverted again keeps the pending burn) andTestCopyIsolatesDestructedBalance(a copy keeps its own pending burn when the original destructs again) andTestSelfdestructBurnIntermediateRoot(the pre-ByzantiumIntermediateRootpath 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/goimportsclean,go mod tidy -diffreports no untidy module files,make generatereports 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 thechainConfigplumbing behind it and itsTestSupplySelfdestructForkGate; 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 itsopSelfdestruct6780leaves 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, emittingBalanceDecreaseSelfdestructBurnat finalisation), so the burnt balance can be reported from there as well. A port would have to adapt to itsuint256balances and to the fork gate inFinalise; 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.