Skip to content

fix(traces): ancestor-error cascade — Portal store byte-identical to the RPC path on traces.error/revert_reason#151

Merged
dzhelezov merged 2 commits into
mainfrom
fix/trace-error-ancestor-cascade
Jul 11, 2026
Merged

fix(traces): ancestor-error cascade — Portal store byte-identical to the RPC path on traces.error/revert_reason#151
dzhelezov merged 2 commits into
mainfrom
fix/trace-error-ancestor-cascade

Conversation

@dzhelezov

Copy link
Copy Markdown
Collaborator

What & why

Stock ponder derives trace error/revertReason in its debug_traceBlockByNumber / debug_traceBlockByHash DFS (src/rpc/actions.ts, shipped as dist/esm/rpc/actions.js:100-118): within one transaction's trace tree, a reverted ancestor's error/revertReason is smeared onto every descendant that does not have its own error, transitively — a frame that inherits becomes a source for its own children. A frame with its own distinct error keeps it (own-error branch wins).

The Portal historical path builds its trace frames itself via parityToCallFrame from Portal's per-frame errors and never invokes that DFS, so a succeeded child of a reverted parent was stored with error = null, whereas the RPC path stores the inherited parent error. That is a traces.error / traces.revert_reason divergence between the two store paths of this same package — realtime and the realtime→getLogs fallback already flow through the untouched stock RPC cascade, so the store must be uniformly ponder-shaped regardless of which path wrote a row.

Candor framing: Portal's raw trace data was verified byte-identical to geth callTracer ground truth; the RPC-path store's differing values come from stock ponder's derived ancestor-error post-processing (src/rpc/actions.ts), which this change replicates for drop-in parity. Both readings are defensible — the RPC/store value reflects effects-reverted semantics (a call inside a reverted tx has no committed effect), the raw geth value reflects frame-local execution. This PR does not take a side on which is "correct"; it makes the Portal store match the stock store so the package is drop-in deterministic across paths.

The mechanism replicated (code-cited)

dist/esm/rpc/actions.js (and src/rpc/actions.ts), per frame in DFS order:

  • own error !== undefined → frame is a source, keep own error/revertReason;
  • else if the parent is a source → inherit its error/revertReason and become a source (transitive);
  • otherwise unchanged.

Where it lands

portal/portal-assemble.ts rankTraces already sorts each tx's traces into DFS pre-order (cmpTraceAddr, parent-before-child). The cascade runs over that sorted list, keyed by traceAddress prefix (parent of [a,b,c] is [a,b]) instead of upstream's frame-identity parentFrame threading — the frames within a tx are exactly one tree and DFS order resolves each parent before its children. Applied only in the Portal historical assembly; the realtime path (which uses the stock RPC actions) is untouched and cannot double-apply.

Blast-radius check (STEP 0)

The upstream DFS derives exactly three things per frame: (1) the error/revertReason cascade, (2) frame.index (per-tx DFS pre-order, reset to 0 per top-level trace), and (3) frame.subcalls. The fork already reproduces (2) via rankTraces (cmpTraceAddr sort = DFS pre-order, per-tx, 0-based) and (3) via parityToCallFrame's subcalls: subtraces ?? 0 (Parity subtraces = immediate-child count = geth calls.length). standardizeTrace only validates and defaults gas/gasUsed. The error/revertReason cascade is therefore the only derived normalization the Portal assembly was skipping — this PR closes exactly that gap and nothing else.

Tests (oracle-based) + mutation verification

portal/portal-assemble.test.ts, new INV-19 group, over a co-located fixture derived from a real reverted transaction (eth mainnet block 20351061, tx index 26 — a reverted swap-router call), reshaped into Portal's per-frame trace shape with each frame's own error preserved verbatim:

  • oracle: the [0,1] frame (V2 router 0x7a250d…, input 0x38ed1739) has no own error but its ancestor reverted, so after the cascade it carries error="execution reverted" / revertReason="Too little received" — and, the root having reverted, every frame in the tx ends up smeared (matching the RPC store);
  • (a) a child with its own distinct error survives (not overwritten by the parent's), and its own children inherit its error, not the root's;
  • (b) ≥3-deep nesting inherits transitively;
  • (c) a non-reverted tx keeps every frame's error/revertReason null (control).

Mutation-verified: with the cascade disabled in the built tree, the oracle + (a) + (b) tests fail (e.g. the oracle asserts swap.error and gets AssertionError: expected undefined to be 'execution reverted'); (c) passes both ways as expected for an all-null control.

Gates

  • scripts/sync-upstream.sh 0.16.6 --test332 passed (18 files), build OK.
  • scripts/sync-upstream.sh 0.16.8 --test332 passed (18 files), build OK.
  • @biomejs/biome@2.5.2 check portal/ → clean at ERROR level.

The shim carries a <UPSTREAM_ISSUE_URL> placeholder to be filled with the upstream issue link.

dzhelezov and others added 2 commits July 11, 2026 04:22
…istorical assembly

Stock ponder's debug_traceBlockByNumber/ByHash DFS (src/rpc/actions.ts,
dist/esm/rpc/actions.js:100-118) smears a reverted ancestor's error/revertReason
onto every descendant lacking its OWN error, transitively (an inheriting frame
becomes a source for its own children); a frame with its own error keeps it.

The Portal historical path builds trace frames itself (parityToCallFrame) from
Portal's geth-faithful per-frame errors and never invokes that DFS, so a succeeded
child of a reverted parent was stored with error=null where the RPC path inherits
the parent's error — a traces.error/revert_reason divergence between the two store
paths. Realtime/fallback already flow through the untouched RPC cascade, so the
fork DB must be uniformly ponder-shaped.

rankTraces now applies the same cascade over the DFS-sorted (cmpTraceAddr) frames
of each tx, keyed by traceAddress prefix (parent of [a,b,c] is [a,b]). Applied only
here; the realtime path is untouched and cannot double-apply.

Oracle regression test over a real reverted tx (eth block 20351061, tx 26) plus
synthetic own-error-survives / 3-deep-transitive / non-reverted-stays-null cases;
mutation-verified to fail on the pre-shim code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… — INVARIANTS row, INV-20(d), byAddr robustify, candor doc

Scoped follow-up polish on the ancestor-error trace cascade (core algorithm unchanged;
already committee-proven byte-identical to stock ponder's rpc/actions.ts DFS).

- Reword the shipped placeholder: the JSDoc's `<UPSTREAM_ISSUE_URL> (to be filled by the
  reviewer)` now points to "Upstream divergence documented in VALIDATION.md §5" (no invented URL).
- Renumber the cascade invariant INV-19 → INV-20 across code/test/doc. INV-19 was ALREADY
  taken by the code-enforced low-edge chunk-coverage guard from #50 (portal.ts
  invariant("INV-19", …), tested in portal.test.ts). Squatting on INV-19 broke the
  doc⟷code⟷test grep-ability rule (two unrelated invariants under one id); INV-20 is the
  next free id.
- Add the INV-20 catalog row to portal/INVARIANTS.md (matches the table's column format;
  cites cascadeTraceErrors/rankTraces + the INV-20 tests + VALIDATION.md §5).
- Robustify the byAddr test helper: assert ranked.length === sorted.length so the
  positional zip stays a valid oracle instead of silently misaligning if a future fixture
  ever introduces a parityToCallFrame-droppable trace type (safe today: all type:'call').
- Add a non-prefix-closed candor doc-comment above cascadeTraceErrors: an orphan (missing
  intermediate ancestor) inherits nothing — a deliberate conservative choice (never fabricate
  a false smear; no worse than the pre-shim null), unreachable on geth-faithful prefix-closed
  Portal trees.
- Add test INV-20 (d): an inheriting child of an ancestor with an error but NO revertReason
  gets error set AND revertReason === undefined — pinning the UNCONDITIONAL revertReason
  assignment that keeps the Portal store byte-exact with upstream rpc/actions.ts. No guard
  added on frame.revertReason (a guard would diverge the Portal store from the RPC store).

Gates: sync-upstream.sh --test 0.16.6 + 0.16.8 both 333 passed / 18 files, build OK; biome
2.5.2 clean at error level. Mutation-verified: neutering the applied cascade fails INV-20 /
(a) / (b) / (d) ("expected undefined to be 'execution reverted'") while (c) stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant