Skip to content

feat(evm-rpc): validate debug traces and state diffs against receipts - #597

Merged
abernatskiy merged 6 commits into
masterfrom
feat/evm-rpc-consensus-anchor
Oct 12, 2026
Merged

abernatskiy merged 6 commits into
masterfrom
feat/evm-rpc-consensus-anchor

Conversation

@abernatskiy

@abernatskiy abernatskiy commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What

Debug call frames (callTracer) and state diffs carry no consensus commitment, so a trace from an execution that differs from the chain passes every existing check. Every transaction, though, leaves a footprint in data the block does commit to. This adds per-transaction checks against it:

Rule Check
status the receipt status agrees with the root frame's error
gas the root frame's gasUsed equals the receipt's (regular transactions)
system-gas a system transaction (sent by the block miner at zero gas price) whose root frame executed nothing (gasUsed ≤ intrinsic gas) while the receipt says gas was used
system-fee the balance change of 0xff…fe in the state diff equals gasUsed × effectiveGasPrice + blobGasUsed × blobGasPrice plus the value frames with no failed ancestor moved to or from it (chains that credit fees to the system address during each transaction, e.g. BSC)

System transactions are deliberately not compared exactly: their receipt gasUsed is not an exact anchor for the execution gas (e.g. BSC slash()).

Both state-diff shapes are supported: trace_replayBlockTransactions stateDiff and prestateTracer diff mode.

Flags (evm-dump, evm-data-service)

  • --consensus-anchor-validation off|observe|reject (default off)
    • observe logs one bounded summary per block (consensus anchor violations observed in N transactions; block accepted, with rule, transaction, expected and actual values) and accepts the block
    • reject marks the block invalid (consensus anchor violation (<rule>) for transaction <hash>: expected …, got …) so the existing retry fetches it again
  • --consensus-anchor-rules <list> comma-separated subset of status,gas,system-gas,system-fee (default status,gas)

reject requires the anchors to be verified: --verify-receipts-root, the cumulative gas check (or --use-gas-used-for-receipts-root), --verify-tx-root and --verify-tx-sender. In reject mode a data request without receipts or call frames is an error, a missing call frame or root STOP is a violation of the first selected rule that applies, and so is a requested state diff that is missing or labelled with another transaction.

The checks run after receipts, call frames and state diffs are attached, and only on the debug-trace path (not with --use-trace-api). They need receipts; system-fee also needs state diffs. Full transaction objects are fetched internally when the checks are on, as for --call-frame-validation.

Tests

  • Real BSC mainnet data that must pass (with both state-diff shapes): a system transaction with empty calldata, system deposits, slash(), a blob transaction, direct payments to the system address, both system-transaction gas conventions, regular transactions.
  • Mutations that must fail: reverted root in a successful transaction, successful root in a failed one, changed root gasUsed, a system transaction stripped of its calls, a state diff whose system-address credit is not the fee or is missing.
  • Rpc-level tests on a whole block for off (default), observe, reject, rule selection, checks without state diffs, and configuration errors.

rushx test in evm-rpc: 431 passed, 29 skipped. Biome reports no new findings in the touched files.

🤖 Generated with Claude Code

abernatskiy and others added 2 commits October 12, 2026 14:23
Debug call frames and state diffs carry no consensus commitment, but a
transaction leaves a footprint in data the block does commit to. Check the
traces and state diffs of every transaction against it:

- status: the receipt status agrees with the root frame's error
- gas: the root frame's gasUsed equals the receipt's (regular transactions)
- system-gas: a system transaction (sent by the miner at zero gas price)
  whose root frame executed nothing while the receipt says gas was used
- system-fee: the system address balance change in the state diff equals the
  fee plus the value frames moved to or from it, on chains that credit fees
  to the system address during each transaction (BSC)

A trace or state diff produced by an execution that differs from the chain
fails them. `--consensus-anchor-validation off|observe|reject` (default off)
and `--consensus-anchor-rules` (default status,gas) are added to evm-dump and
evm-data-service. Reject marks the block invalid so it is fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Several validation bypasses and false-positive cases undermine reject-mode reliability.

4 open findings
What changed in this PR

Adds configurable validation that cross-checks EVM debug traces and state diffs against transaction receipts.

Changes:

  • Implements status, gas, system-gas, and system-fee validation rules.
  • Exposes observe/reject modes through evm-dump and evm-data-service.
  • Adds BSC fixtures, unit/integration tests, and release records.
File Description
evm/​evm-rpc/​src/​verification.ts Implements consensus-anchor rules.
evm/​evm-rpc/​src/​rpc.ts Integrates validation into block fetching.
evm/​evm-rpc/​test/​consensus-anchor.test.ts Tests individual rules and helpers.
evm/​evm-rpc/​test/​rpc.consensus-anchor.test.ts Tests RPC-level modes and configuration.
evm/​evm-rpc/​test/​fixtures/​bsc/​consensus-anchor-transactions.json Provides transaction-level BSC fixtures.
evm/​evm-rpc/​test/​fixtures/​bsc/​block-363.json Provides full-block integration data.
evm/​evm-dump/​src/​dumper.ts Adds validation CLI options.
evm/​evm-data-service/​src/​main.ts Adds service CLI options.
evm/​evm-data-service/​src/​data-source/​setup.ts Passes options into RPC setup.
common/​changes/​@subsquid/​evm-rpc/​feat-evm-rpc-consensus-anchor_2026-10-12-05-23.json Records the evm-rpc release change.
common/​changes/​@subsquid/​evm-dump/​feat-evm-rpc-consensus-anchor_2026-10-12-05-23.json Records the evm-dump release change.
common/​changes/​@subsquid/​evm-data-service/​feat-evm-rpc-consensus-anchor_2026-10-12-05-23.json Records the data-service release change.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread evm/evm-rpc/src/rpc.ts
Comment thread evm/evm-rpc/src/verification.ts Outdated
Comment thread evm/evm-rpc/src/verification.ts Outdated
Comment thread evm/evm-rpc/src/verification.ts
- reject mode requires receipts root verification: a receipt is only an
  anchor once it is checked against the root (CLI error in evm-data-service)
- reject mode fails fast when receipts or debug call frames are missing
  from the data request instead of passing blocks as checked
- a missing call frame or a root STOP is a violation of the first selected
  trace rule, as is a root frame without gasUsed
- a transaction without a gas price is not taken for a system transaction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comment thread evm/evm-rpc/src/rpc.ts Outdated
Comment thread evm/evm-rpc/src/rpc.ts
Comment thread evm/evm-rpc/src/verification.ts Outdated
Comment thread evm/evm-rpc/src/verification.ts Outdated
Comment thread evm/evm-rpc/src/verification.ts
- reject mode also requires checkCumulativeGasUsed (or
  useGasUsedForReceiptsRoot), verifyTxRoot and verifyTxSender: per-transaction
  gasUsed is committed through cumulativeGasUsed, and the system transaction
  classification reads the sender and gas price
- a state diff that was requested but is missing for a transaction is a
  system-fee violation
- a missing call frame is reported under the first selected rule that applies
  to the transaction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Empty rule sets can disable rejection, and labeled prestate diffs are not verified against their transactions.

0 open findings

5 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Reject empty consensus anchor rules in RpcOptions

evm/​evm-rpc/​src/​rpc.ts:188

The public RpcOptions path accepts consensusAnchorRules: [], although the CLI parser rejects an empty list. The resulting empty set makes checkConsensusAnchors return no violations for every transaction, so even consensusAnchorValidation: 'reject' silently becomes a no-op. Reject an empty array in the constructor as well.

Medium severity Match prestate tracer results by transaction hash

evm/​evm-rpc/​src/​rpc.ts:1217

When the prestate tracer returns one result per transaction, addDebugStateDiffs keeps the array positionally and does not match it by txHash. This branch then ignores the optional label, so a shuffled/mislabeled response compares each transaction with another transaction's balance delta, causing false rejection or allowing the wrong diff to satisfy system-fee. Validate the label before consuming the result (as validateDebugFrames already does for call frames).

🧠 Review effort: Balanced

…diffs

An empty rule list would let enabled validation accept every block, and a
prestate diff labelled with another transaction is not this transaction's
diff: the system-fee rule now treats it as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abernatskiy

Copy link
Copy Markdown
Contributor Author

(With Claude): The round-3 review summary (on 31b7a0c) raised two points without inline threads. Both are addressed in 096e2a5:

  • Empty rule sets: Rpc now refuses an enabled consensus-anchor mode with an empty rule list, so enabled validation can no longer accept every block.
  • Mislabelled prestate diffs: a prestate diff whose label names another transaction is treated as missing for that transaction, so system-fee fails instead of checking the wrong diff.

Both have tests in evm/evm-rpc/test/rpc.consensus-anchor.test.ts. Requesting a fresh review on the current head.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Reject mode can silently skip validation without trace requests, and system-gas uses an inaccurate intrinsic-gas threshold.

1 open finding
Previously missed (1)

In code that hasn't changed since last review

Medium severity Incorrect intrinsic gas calculation for system transactions

evm/​evm-rpc/​src/​verification.ts:1400

This is not the transaction's intrinsic gas: it charges zero calldata bytes at 16 gas and omits contract-creation, initcode, and access-list costs. Because every miner-sent zero-price transaction is classified as a system transaction, the threshold can falsely reject valid zero-heavy calls whose execution fits below this overestimate, and it can miss an empty create/access-list trace whose real intrinsic gas exceeds this estimate. Compute intrinsic gas from the full transaction under the applicable chain rules, or restrict system-gas to transaction shapes for which the threshold is valid.

🧠 Review effort: Balanced

Comment thread evm/evm-rpc/src/rpc.ts Outdated
…c gas

- A request without traces no longer returns before the missing-input
  handling: reject refuses it and observe warns, as for any other missing
  input. evm-dump and evm-data-service refuse to start with consensus anchor
  validation enabled but without --with-traces and --with-receipts, or with
  --use-trace-api.
- system-gas measures the root frame against the call's exact intrinsic gas
  (4 gas per zero calldata byte, 16 per other, access lists, authorizations)
  instead of an all-16 ceiling, which could take a short execution with
  zero-heavy calldata for none. Contract creations are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abernatskiy

Copy link
Copy Markdown
Contributor Author

(With Claude): On the round-4 summary finding "Incorrect intrinsic gas calculation for system transactions" (verification.ts, no inline thread): agreed, fixed in 78dec18. system-gas now measures the root frame against the exact intrinsic gas of the call (intrinsicGasOfCall: 21000, 4 gas per zero calldata byte and 16 per other byte, 2400 per access-list address plus 1900 per storage key, 25000 per authorization) instead of an all-16 ceiling. Contract creations are excluded, since their intrinsic gas depends on fork-specific initcode rules and system transactions are calls. New tests: zero-heavy calldata whose short execution sits between the exact intrinsic gas and the old ceiling is no longer flagged; a creation is not checked; unit tests for intrinsicGasOfCall. The fixtures now carry each transaction's to.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The system-gas rule can reject valid post-EIP-7623 BSC system transactions.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread evm/evm-rpc/src/verification.ts
@abernatskiy
abernatskiy merged commit 6cfdefd into master Oct 12, 2026
3 checks passed
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