Skip to content

fix(evm-normalization): tolerate SELFDESTRUCT trace frames without to - #546

Open
elina-chertova wants to merge 1 commit into
masterfrom
alert-fix/4mtxPN-selfdestruct-to
Open

fix(evm-normalization): tolerate SELFDESTRUCT trace frames without to#546
elina-chertova wants to merge 1 commit into
masterfrom
alert-fix/4mtxPN-selfdestruct-to

Conversation

@elina-chertova

Copy link
Copy Markdown
Contributor

Cause (proven)

The ethereum-sepolia EVM archive ingest crash-loops deterministically (every ~8s) on block 11319411 (0xacb873), freezing the block writer → ethereum-sepolia_Writer_Short_Stall. The ingest container throws:

AssertionError: (val != null, msg)
  at assertNotNull (util-internal/lib/misc.js)
  at mapDebugFrame (evm-normalization/lib/mapping.js:103:74)   // src/mapping.ts:142
  at mapRawBlock (.../mapping.js)
blockNumber: "0xacb873", blockHash: "0xb3cd8c88…"

The offending record is the single SELFDESTRUCT frame in tx index 17 (0xf6c6e39b…): a self-referential, zero-value self-destruct of 0xe22a1e72…5​3e0. geth's callTracer omits the to (beneficiary) field for this case, but mapDebugFrame guarded it with assertNotNull(frame.to), turning an expected-optional field into a fatal crash.

This is a code contradiction: the RPC-level DebugFrame validator declares to as option(BYTES) (optional), yet normalization asserted it non-null.

Not a chain / provider-data problem to "accept"

Cross-checked block 11319411's trace against two independent debug-capable archival providers (drpc, Tatum): both return to == from on this frame and no null fields anywhere. So the canonical value is well-defined — the fix reconstructs it rather than fabricating data. (The configured ingest provider returns the malformed frame; swapping it is a separate operator mitigation and is not part of this PR — the durable fix is to stop the crash, per the "fix the fatality" convention.)

Fix

refundAddress: (frame.to ?? frame.from).toLowerCase() — when to is omitted, the beneficiary of a self-referential SELFDESTRUCT is the account itself (frame.from). Keeps the process alive on the field the validator already allows to be absent, without changing the public data model.

Test (red→green)

evm/evm-normalization/src/mapping.test.ts builds a raw block with a SELFDESTRUCT frame missing to and drives it through the production mapRawBlock path.

  • Pre-fix: vitest --run fails — throws at mapping.ts:142 assertNotNull(frame.to).
  • Post-fix: passes (refundAddress === frame.from); the explicit-to case is also asserted.
  • rush build --to @subsquid/evm-normalization compiles green.

Falsification

If a provider ever omits to on a SELFDESTRUCT whose beneficiary is not the destroyed account, ?? frame.from would record the wrong refund address — but no such frame has been observed, the value moved is zero, and the alternative (assert) crash-loops the whole dataset. Independent-provider disagreement on refundAddress for such a block would falsify the self-referential assumption.

geth's callTracer omits the `to` field on a self-referential SELFDESTRUCT
(beneficiary == the destroyed account). `mapDebugFrame` guarded it with
`assertNotNull(frame.to)`, so such a frame threw and crash-looped the ingest,
freezing the block writer.

The RPC-level `DebugFrame` validator already declares `to` optional, so
normalization must not assert it. Fall back to the account's own address
(`frame.from`) — the beneficiary in the omitted case, cross-checked against
independent providers.

Adds a regression test covering the missing-`to` and explicit-`to` cases.
@elina-chertova
elina-chertova requested a review from tmcgroul July 23, 2026 13:17
@elina-chertova

Copy link
Copy Markdown
Contributor Author

Confirming this same root cause is still live: ethereum-sepolia_Writer_Short_Stall re-fired 2026-07-25 07:43 PDT. The ingest-ethereum-sepolia-0 ingest container is crash-looping (~8s) on block 11319411 / tx17 at mapping.ts:142, writer frozen at 11319410 (0 sqd_last_block_total changes over 6h). So this PR is the correct fix — it just needs a merge + image bump to actually clear the alert.

One correctness caveat on the reconstruction, from probing the real data this run:

The configured provider (Alchemy, the eRPC upstream) returns the malformed frame with from zeroed too, not just to omitted — verified via direct debug_traceBlockByNumber(0xacb873):

{"from":"0x0000000000000000000000000000000000000000","gas":"0x0","gasUsed":"0x0","input":"0x","type":"SELFDESTRUCT"}

So on the actual production frame refundAddress: (frame.to ?? frame.from) resolves to the zero address, and address: frame.from is zero as well — the written trace is address=0x0, refundAddress=0x0, not the canonical 0xe22a1e72591acb61ec32a9a1d2a1d0818c2f53e0. The regression test's first fixture uses a non-zero from, so it asserts refundAddress === 0xe22a… and passes — but that doesn't match what the provider actually returns here, so the "reconstructs the canonical value" claim doesn't hold for this block.

The crash fix is still valid and unblocks the writer (that's the priority). If preserving the canonical address matters, the destroyed account is recoverable from the parent CALL frame's to (0xe22a…, which is exactly what drpc returns as from == to on this frame). Suggest at minimum changing the test fixture to from = 0x0 to reflect the real malformed frame.

Separately, the underlying data defect is an Alchemy callTracer bug (drpc returns the frame correctly) — recommending it be escalated to the provider; a provider swap is an operator mitigation, not part of this PR.

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.

2 participants