Skip to content

feat(archiver): decode robustness against out-of-range checkpoint header/archive fields#24204

Open
spalladino wants to merge 5 commits into
v5-nextfrom
spl/a-1254-archiver-robustness
Open

feat(archiver): decode robustness against out-of-range checkpoint header/archive fields#24204
spalladino wants to merge 5 commits into
v5-nextfrom
spl/a-1254-archiver-robustness

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Part of A-1254.

A malicious proposer can post a checkpoint whose header (or archive root) carries a uint256 value above the BN254 scalar field modulus. Before this change the archiver eagerly converted those L1-read values into Fr while decoding, which throws for an out-of-range value; the throw was uncaught and the node's L1 sync point stalled permanently (a brick). This PR is the archiver-side defense-in-depth (Fix 1) that complements the merged L1 range checks (#24199): an out-of-range checkpoint is now treated as an invalid checkpoint that is skipped/invalidated, exactly like one with bad attestations, instead of throwing and stalling the sync point.

What Fix 1 does:

  • Introduces a single raw-header type L1CheckpointHeader (plain type + free helpers in stdlib) that fully replaces CheckpointHeader.fromViem/toViem. It keeps the viem wire shape, can hold out-of-range values without throwing, and hashes to the same value as CheckpointHeader for in-range inputs (pinned by a test).
  • Carries the archive root as raw Buffer32 across the whole L1-read sync path (RollupContract.status, getCheckpointProposedEvents, archiveAt), converting to Fr only at the single checkpoint-ingestion boundary.
  • CalldataRetriever now returns a raw header + raw archive and performs no field-range validation; it verifies the propose candidate using a raw EIP-712 digest helper, so decode no longer falls through to the trace-fallback "hash mismatch" throw for a malicious header.
  • Always runs validateCheckpointAttestations (now consuming the raw header) before building a published checkpoint; rejects via the existing insufficient/invalid-attestation reasons and advances the sync point. Fails loudly only in the catastrophic case where a header is out of range yet a committee quorum signed it — which Fix 2 makes unreachable on a patched chain.
  • Widens CheckpointInfo.archive, RejectedCheckpoint.archiveRoot, and the affected L2BlockSource events to Fr | Buffer32. The on-disk storage format is unchanged.

Tests:

  • Unit tests pinning the L1CheckpointHeader hash byte layout against CheckpointHeader.hash, and verifying out-of-range detection for each exploitable field at exactly MODULUS and 2^256 - 1.
  • An archiver integration test that injects an out-of-range header field into the propose calldata and asserts the checkpoint is skipped, recorded as rejected, and the L1 sync point advances rather than stalling.
  • A setStorageAt-based e2e (epochs_out_of_range_header.test.ts) that overwrites a checkpoint's archive root in L1 storage with an out-of-range value and asserts the honest node keeps syncing. This test was implemented and compiles but was not run to completion locally due to e2e runtime.

Fix 2 (the L1 range checks, #24199) is already merged into the base branch and prevents new out-of-range fields from landing via propose; Fix 1 here is the archiver's defense for pre-upgrade chains and future-added fields.

…der/archive fields

Part of A-1254. Archiver-side defense-in-depth (Fix 1) complementing the merged L1 range
checks (#24199): a checkpoint with an out-of-range field is treated as an invalid checkpoint
that is skipped/invalidated, instead of throwing during decode and permanently stalling the
L1 sync point.

- Add a single raw-header type `L1CheckpointHeader` (plain type + free helpers in stdlib)
  that replaces `CheckpointHeader.fromViem`/`toViem`. It holds the viem wire shape, can carry
  out-of-range values without throwing, and hashes to the same value as a `CheckpointHeader`
  for in-range inputs (pinned by a test).
- Carry the archive root as raw `Buffer32` on the whole L1-read sync path (`status`,
  `getCheckpointProposedEvents`, `archiveAt`), converting to `Fr` only at the single
  checkpoint ingestion boundary.
- `CalldataRetriever` decodes a raw header + raw archive and does no validation; it verifies
  the propose candidate via a raw EIP-712 digest helper so decode never falls through to the
  trace-fallback "hash mismatch" throw for a malicious header.
- Always run `validateCheckpointAttestations` (now consuming the raw header) before building
  a published checkpoint; reject via the existing insufficient/invalid-attestation reasons,
  and fail loudly only in the catastrophic case where a header is out of range yet a quorum
  signed it (which Fix 2 makes unreachable on a patched chain).
- Widen `CheckpointInfo.archive`, `RejectedCheckpoint.archiveRoot`, and the affected events
  to `Fr | Buffer32`; on-disk format is unchanged.
- Tests: unit tests pinning the header-hash layout and out-of-range detection per field; an
  archiver integration test asserting an out-of-range header is skipped and the sync point
  advances; and a `setStorageAt`-based e2e exercising the out-of-range archive-root read path.

Co-Authored-By: Santiago Palladino <santiago@aztec-labs.com>
Base automatically changed from mitch/gk-722-protect-fees-against-unsound-verifier-v5 to v5-next June 19, 2026 21:33
…ue ids

Rebuild the out-of-range checkpoint-header e2e to exercise full recovery:
a lone proposer lands an under-attested checkpoint, its stored archive root
is corrupted to a value above the BN254 modulus via setStorageAt, and the
honest validators plus an archiver-only observer keep syncing through the
hardened status()/archiveAt() reads, invalidate the under-attested
checkpoint, and build past it. The corruption is injected post-propose
because the L1 propose path now reverts on out-of-range fields (#24199).

Also strip Linear issue-id mentions from the decode-robustness comments
across archiver, ethereum, stdlib, and sequencer-client, keeping the
technical rationale and the #24199 PR cross-reference.
…-of-range root

RollupContract.getCheckpoint eagerly converted the on-chain archive root to Fr, which
throws when the stored value is outside the BN254 field. A malicious proposer can land
such a value, bricking any honest node that reads the checkpoint on a sync/startup path
(e.g. the tx-pool fee provider booting against the pending tip). Carry the archive as
Buffer32 on the read path and compare archive roots as bytes in the prover-node publisher
so a mismatching out-of-range value reports a mismatch instead of throwing.

Refs #24199
@AztecBot

Copy link
Copy Markdown
Collaborator

Flakey Tests

🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry.

\033FLAKED\033 (8;;http://ci.aztec-labs.com/54102f4885c54d45�54102f4885c54d458;;�):  yarn-project/end-to-end/scripts/run_test.sh simple src/e2e_epochs/epochs_out_of_range_header.test.ts (177s) (code: 0) group:e2e-p2p-epoch-flakes

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