Skip to content

fix(traces): root-frame gas parity — Portal store byte-identical to the RPC path on traces.gas (INV-21)#153

Merged
dzhelezov merged 3 commits into
mainfrom
fix/trace-root-frame-gas-parity
Jul 11, 2026
Merged

fix(traces): root-frame gas parity — Portal store byte-identical to the RPC path on traces.gas (INV-21)#153
dzhelezov merged 3 commits into
mainfrom
fix/trace-root-frame-gas-parity

Conversation

@dzhelezov

Copy link
Copy Markdown
Collaborator

Summary

The fork's Portal-backfill trace store wrote a different traces.gas on the root frame (traceAddress [], the top-level call of each tx) than stock ponder's RPC store. This is the unresolved second half of the T-eth traces divergence arc — the error/revert_reason half was fixed in #151 (INV-20). It resolves to a code fix under the same intra-deployment determinism precedent.

Grounding (real captured Portal-vs-RPC store diff, eth mainnet)

A per-window diff of the two ponder_sync.traces stores (both @subsquid/ponder@0.16.8-sqd.1) surfaced exactly two signatures:

signature count shape
gas only 133 all at the root frame (traceAddress []); gas_used byte-identical; RPC.gas − Portal.gas == the tx's EIP-2028 intrinsic on every row
error+revert_reason 1 the ancestor-error cascade — already fixed by #151

The deciding fact: RPC.gas == the transaction's full gasLimit on every root frame (164/164, zero exceptions) — the RPC path's top-frame gas is the tx gasLimit unconditionally, with no intrinsic arithmetic. Portal's root action.gas is gasLimit − intrinsic. Non-root frames' gas already matched both paths (0 divergences).

Mechanism

Portal serves Parity-style traces. On the root frame, Parity's action.gas = the gas available to the call = tx.gasLimit − intrinsic (base 21000 + 16/nonzero + 4/zero calldata byte + access-list + creation costs). geth's callTracer — which ponder's RPC path stores — puts the full tx.gasLimit on the top frame. The fork mapped gas = a.gas ?? t.callGas (portal-transform.ts), so it carried Parity's reduced value.

Determinism rationale

A real fork deployment does historical backfill via Portal and realtime tip-sync via stock ponder's untouched RPC path into the same database. A path-dependent root-frame gas would leave an invisible seam at the backfill/realtime cutover — unacceptable for a byte-identity guarantee. Same precedent as the error cascade (#151, INV-20) and access_list (#110): match the RPC path in the Portal store.

The fix

buildTraces (portal-assemble.ts) overrides the root frame's gas with the parent tx's gasLimit (frame.gas = hx(rawTx.gas)) when traceAddress.length === 0 and the parent tx is present:

  • Keyed on traceAddress, not the DFS rank — a chunk can legitimately hold only deep frames (verified: 48 of the window's tx-groups have no root frame present), so a rank-0 heuristic would corrupt a partial tree.
  • No intrinsic math — geth's top-frame gas equals the raw gasLimit exactly, so the override is the raw value (avoids all EIP-2028/3860/access-list recomputation risk).
  • Applied at the store-write site only — the shared trace matcher (buildRawTraceMatcher / parityToCallFrame) is untouched, and trace matching never reads gas, so the kept-set is unchanged.
  • RankedTrace now carries the source traceAddress so buildTraces can identify the root cleanly.
  • Skipped when the parent tx is absent — never poisons the store with an undefined gas.

Blast radius

After INV-21 (root gas) + #151 (error cascade), the traces table is fully byte-identical across the two store paths in the grounded window — zero residual column divergences. These were the only two trace signatures the diff-harness ever surfaced. Root-frame gas is the last derived-normalization gap the Portal assembly skipped vs the RPC path.

Tests

Oracle regression test built from real captured data (eth block 19068568 tx 7, a Uniswap V2 router swap):

  • pre-fix Portal action.gas 0x74700 (= gasLimit − intrinsic) vs RPC/gasLimit 0x7a120
  • non-root child 0x3616b, already byte-identical (asserted untouched)

Plus sub-cases: (a) an already-matching root (Parity action.gas == gasLimit) is unchanged — idempotent; (b) a rootless chunk (only a deep frame) is NOT rewritten.

Mutation-verified: reverting the override (guard never fires ⇒ Parity action.gas at root) fails the oracle with expected '0x74700' to be '0x7a120'; sub-cases (a)/(b) still pass (they are idempotence/scoping guards, not the sentinel).

Gates

  • scripts/sync-upstream.sh 0.16.6 --test336 passed, EXIT 0
  • scripts/sync-upstream.sh 0.16.8 --test336 passed, EXIT 0
  • portal-assemble.test.ts: 16 → 19 tests
  • npx @biomejs/biome@2.5.2 check portal/ --diagnostic-level=error → clean

INV-21 catalog row added to portal/INVARIANTS.md (doc ⟷ code ⟷ test IDs exact).

🤖 Generated with Claude Code

dzhelezov and others added 3 commits July 11, 2026 07:11
…he RPC path on traces.gas (INV-21)

The fork's Portal-backfill trace store wrote a different `traces.gas` on the ROOT frame
(traceAddress [], the top-level call of each tx) than stock ponder's RPC store: Portal serves
Parity-style traces whose root `action.gas` is the tx gasLimit MINUS the EIP-2028 intrinsic,
while geth's callTracer — which the RPC path stores — puts the FULL tx.gasLimit on the top frame.
Grounded on a real captured Portal-vs-RPC store diff (eth mainnet): 133/133 divergences were
gas-only, at the root frame, with gas_used byte-identical, and RPC.gas == the tx's full gasLimit
on every root frame (no intrinsic math needed). Non-root frames' gas already matched both paths.

A real fork deployment does historical backfill via Portal AND realtime tip-sync via stock ponder's
untouched RPC path into the SAME database, so a path-dependent root-frame gas would leave an invisible
seam at the backfill/realtime cutover. Same determinism precedent as the ancestor-error cascade (#151)
and access_list #110: match the RPC path in the Portal store.

buildTraces now overrides the root frame's gas with the parent tx's gasLimit
(frame.gas = hx(rawTx.gas)) when traceAddress.length === 0 and the parent tx is present. Keyed on
traceAddress, NOT the DFS rank — a chunk can hold only deep frames. Applied at the store-write site;
the shared trace matcher (buildRawTraceMatcher/parityToCallFrame) is untouched (trace matching never
reads gas). RankedTrace now carries the source traceAddress so buildTraces can identify the root.

Oracle regression test from real captured data (eth block 19068568 tx 7, a Uniswap V2 swap):
pre-fix Portal action.gas 0x74700 (= gasLimit − intrinsic) vs RPC/gasLimit 0x7a120; the non-root
child 0x3616b already byte-identical. Mutation-verified: reverting the override fails the oracle
(expected '0x74700' to be '0x7a120'). Idempotence and rootless-chunk sub-cases included.

Both-version gates green (scripts/sync-upstream.sh 0.16.6 + 0.16.8 --test, 336 tests each).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the INV-21 row (root-frame gas := tx gasLimit, byte-identical to the RPC store) to the Portal
invariant catalog and extend the stable-id range to INV-1…INV-21. Doc ⟷ code ⟷ test IDs kept exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d (committee review)

Blind 3-seat review (reviewer/opus SHIP; codex + glm REWORK) converged on one
must-fix: the INV-21 comments/catalog stated geth seeds the top-frame gas with the
full tx.gasLimit "UNCONDITIONALLY (call/create/any tx type)", but the grounding
corpus (2196 root frames across a captured Portal-vs-RPC store diff) contained no
contract-creation and no access-list root frames. Soften all three sites to state
the identity is confirmed on legacy + EIP-1559 (intrinsic-independent) and that
create/access-list rest on the geth callTracer mechanism, not a direct measurement.

Also harden the root-frame guard `rawTx?.gas !== undefined` → `!= null`: skip the
override rather than write a fabricated gas if a tx ever lacked a gas value (unreachable
on real Portal data — gas is a required field; `hx(null)` would return '0x0' — but the
looser guard states the intent precisely). No change to the override itself or any test
outcome. Both-version graft test suite 336/336; biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dzhelezov dzhelezov marked this pull request as ready for review July 11, 2026 07:47
@dzhelezov dzhelezov merged commit 3bfc074 into main Jul 11, 2026
14 checks passed
@dzhelezov dzhelezov deleted the fix/trace-root-frame-gas-parity branch July 11, 2026 07:47
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