Skip to content

feat(validator): verify a checkpoint's final Inbox endpoint against L1 before accepting a proposal - #25437

Draft
spalladino wants to merge 5 commits into
spl/fi2-f16-rescue-tail-blobfrom
spl/fi2-f14-l1-endpoint-check
Draft

feat(validator): verify a checkpoint's final Inbox endpoint against L1 before accepting a proposal#25437
spalladino wants to merge 5 commits into
spl/fi2-f16-rescue-tail-blobfrom
spl/fi2-f14-l1-endpoint-check

Conversation

@spalladino

@spalladino spalladino commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Background

The L1 Inbox is an ordered log of messages sent from Ethereum to Aztec. It groups messages into buckets held in a
bounded on-chain ring; a bucket endpoint is the cumulative message count at the end of one such bucket, and its
rolling hash commits to every message through that count. A checkpoint may end its intermediate blocks at arbitrary
message prefixes, but the completed checkpoint must end at a live bucket endpoint. Once accepted locally, a
checkpoint can become the optimistic parent on which a node prepares the next slot.

The problem

Nothing is known to be broken under the existing design: proposers already resolve an endpoint while building and
run a publication preflight, while L1 ultimately enforces the rule. This PR deliberately adds an independent
acceptance policy to every receiving node.

The endpoint unit test uses a live ring containing these entries:

  1. Bucket sequence 7 ends at message total 200 and commits to rolling hash H200.
  2. Bucket sequence 8 ends at message total 400 and commits to rolling hash H400.
  3. A checkpoint ending at 200 and signing H200 is publishable with respect to this rule.
  4. For an upper bound of 256, the Inbox resolver returns the closest endpoint at or below it: 200. Because
    200 != 256, total 256 is an interior position between the endpoints at 200 and 400, not a bucket boundary.
  5. A checkpoint ending at 200 but signing a hash other than H200 also fails: the count is a boundary, but it
    commits to different message content.

Previously, a proposal ending at 256 could pass checkpoint-content validation if its blocks and locally available
messages were internally consistent. A node could then accept it as its optimistic parent, and a committee member
could attest to it, without independently confirming the endpoint against L1. Such a checkpoint would still fail
the L1 boundary rule at publication. The new policy avoids building or attesting on that unconfirmed endpoint.

What this changes

After deterministic checkpoint validation succeeds, every receiving node reads the final consumed message count
from the authenticated last block and pairs it with the rolling hash in the signed checkpoint header. It does not
trust an unsigned bucket hint from the proposer.

The node then:

  1. Reads the L1 head's number and hash.
  2. Pins getBucketAtOrBeforeTotal to that block number.
  3. Re-reads the block at the same height and requires the hash to be unchanged.
  4. Accepts only an exact message-total match with the signed rolling hash.

The step is retried for up to two seconds, capped by the remaining per-slot duty budget. Content validation remains
cached, but this L1 check is not: the attestation path rechecks a fresh view before reusing a cached valid result.
The all-nodes callback completes this validation before the proposal can become the local optimistic parent.

An unreadable or replaced view is reported separately from a readable non-match. Both refuse validation but cause
no slashing, invalid-proposal marker, or peer penalty.

A refusal must also stay off the proposer's record. It is this node's failure to check, not the proposer's to answer
for: the bucket ring, the local provider and L1 itself all move independently of the moment the checkpoint was signed.
So a refusal records the unverifiable outcome introduced below this PR (in #25440), which the sentinel surfaces as
checkpoint-unverifiable and counts in neither half of the missed-proposal rate. Recording nothing would have been
worse: with no record and no checkpoint on L1, the sentinel reads the slot as one the proposer never proposed in, and
counts that against them.

Uncertainty also never revises a determination that already exists. A refusal cannot overwrite a valid this node
recorded for the same checkpoint, and — since a proposer may equivocate — it cannot erase an invalid recorded for the
slot from a different proposal either.

What this does not do

This is not evidence of a confirmed security defect in the previous policy, and it does not prove that an accepted
checkpoint will land on L1. A uniformly lagging provider can return a self-consistent stale view that this check
cannot detect. A checkpoint ending at the open current bucket can pass this endpoint check and still fail L1
publication with Rollup__InboxBucketStillMutable; settlement remains outside this gate. A later reorg can also
invalidate the publication assumptions.

The check is not applied to historical archiver ingestion or proving, because the bounded ring may already have
evicted old endpoints. The node that actually built a checkpoint retains its proposer fast path, relying on the
endpoint resolution performed during construction and the later publication preflight. The testing-only skip flag
still permits attestation without validation and records nothing as valid.

Each check costs two block reads and one pinned eth_call. Validators normally incur another set when the
attestation path reuses the cached content verdict.

Testing

Unit tests cover exact endpoints at 200 and 400, the interior position at 256, rolling-hash mismatch, eviction,
genesis at total zero, unreadable responses, unidentified blocks, and replacement of the captured L1 view.
Proposal-handler tests cover arbitrary intermediate prefixes, checkpoints consuming no new messages, bounded
retries, a permanently stalled RPC, cached-content reuse, fresh endpoint checks before attestation, optimistic
parent rejection, and preservation of an earlier valid outcome. Integration fixtures verify dependency wiring.

The L1 behavior is exercised through fake Inbox and client implementations; the diff does not add an end-to-end
test against a live Ethereum RPC or a real reorg.

@spalladino
spalladino added this pull request to stack #25417 September 9, 2026 21:01
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from 991642b to ad7e80e Compare September 9, 2026 21:15
@spalladino
spalladino removed this pull request from stack #25417 September 9, 2026 21:16
@spalladino
spalladino added this pull request to stack #25441 September 9, 2026 21:16
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from ad7e80e to 20ee82c Compare September 10, 2026 02:46
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from 20ee82c to 47f2764 Compare September 10, 2026 04:04
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from 47f2764 to ed18fbe Compare September 10, 2026 05:11
@spalladino
spalladino removed this pull request from stack #25441 September 10, 2026 11:49
@spalladino
spalladino added this pull request to stack #25448 September 10, 2026 11:49
@spalladino
spalladino removed this pull request from stack #25448 September 10, 2026 12:09
@spalladino
spalladino changed the base branch from spl/fi2-f16-rescue-tail-blob to spl/fi2-review-fixes September 10, 2026 12:10
@spalladino
spalladino changed the base branch from spl/fi2-review-fixes to spl/fi2-f16-rescue-tail-blob September 10, 2026 12:10
@spalladino
spalladino added this pull request to stack #25449 September 10, 2026 12:11
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from ed18fbe to 7dd69f7 Compare September 10, 2026 12:50
@spalladino
spalladino removed this pull request from stack #25449 September 10, 2026 13:04
@spalladino
spalladino added this pull request to stack #25451 September 10, 2026 13:05
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from 7dd69f7 to abbbc32 Compare September 10, 2026 13:51
@spalladino
spalladino removed this pull request from stack #25451 September 10, 2026 13:53
@spalladino
spalladino changed the base branch from spl/fi2-f16-rescue-tail-blob to spl/fi2-review-fixes September 10, 2026 13:54
@spalladino
spalladino changed the base branch from spl/fi2-review-fixes to spl/fi2-f16-rescue-tail-blob September 10, 2026 13:54
@spalladino
spalladino added this pull request to stack #25452 September 10, 2026 13:54
@spalladino
spalladino marked this pull request as draft September 10, 2026 14:22
spalladino and others added 5 commits September 10, 2026 11:28
…cepting a proposal

Before a node returns or records a checkpoint proposal as valid, it now confirms through an L1 read that the
position the checkpoint finishes at closes a live Inbox bucket committing to the rolling hash the proposal
signed. This strengthens a previously content-only acceptance policy; it is not a fix for a confirmed defect.

The check runs in all-nodes validation, not just committee attestation, so a proposal without endpoint evidence
cannot become this node's accepted optimistic checkpoint parent. The count comes from the authenticated last
block of the coherent slot snapshot validation already reads, paired with the signed rolling hash — no unsigned
proposer hint. The resolver answers with the closest boundary at or below the bound, so only an exact total
counts: with buckets ending at 200 and 400, a checkpoint ending at 200 with the matching hash passes, and one
ending at 256, or at 200 with a different hash, does not. Intermediate blocks may still end anywhere.

The gate never fails open, and never treats a local uncertainty as misconduct. An unreadable view is separated
in diagnostics from a view that answers without a live boundary there; both refuse to validate now, reach
neither slashing nor the invalid-slot marker nor a peer penalty, and are not remembered as the proposal's
verdict, so a changing head cannot poison it. Reads are retried briefly inside the slot's existing duty budget,
so a provider that catches up in time still yields a valid verdict and a stalled read cannot hold the
acceptance path open.

Cached-valid reuse re-confirms the endpoint against a fresh view. The skip flag records nothing as valid and is
documented as testing-only. The proposer's own fast path is taken only by the node that built the checkpoint,
which resolved that bucket end against the live Inbox while building its final block. Historical ingestion and
proof processing stay outside the gate, since old checkpoints may reference evicted endpoints.

Cost is one head read plus one eth_call per checkpoint proposal validated.
An endpoint refusal used to be withheld from the validation cache, so an
RPC hiccup during the all-nodes callback threw away a content verdict that
cost a full checkpoint rebuild and block re-execution, and the attestation
callback moments later rebuilt the whole checkpoint again inside the same
duty budget. The cached-valid branch also made a second, separate endpoint
call.

The gate is now one step in handleCheckpointProposal: the content verdict
is computed once (cached or fresh) and cached unconditionally, and only
then is the endpoint confirmed, against the last block read for the same
proposal. Refusals stay non-slashable, set no invalid-slot marker and no
peer penalty, record an unvalidated outcome, and are never remembered as
the proposal's verdict, so a recovered L1 view still yields a valid verdict
on the next call — now without rebuilding. Blob upload moves next to the
content verdict so it still fires once per proposal.

Also: the README no longer claims the check "closes" a live bucket, and
names settlement as the L1-only check it does not replace; the internal
endpoint-check module is no longer re-exported; and the three fake Inboxes
in the tests collapse into one helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… valid outcome

Four fixes from a review of the endpoint gate.

An endpoint refusal no longer overwrites a `valid` outcome this node already recorded for the same checkpoint.
The gate runs on both calls p2p makes for one proposal, so a second look failing on a local RPC problem used to
downgrade the slot to `unvalidated` — which the sentinel counts as a missed proposal for that slot's proposer,
feeding epoch performance and the inactivity signal. Only that exact checkpoint is protected; another archive at
the slot still records normally. The test helper asserting refusals is renamed to say what it checks: the refusal
is not slashable and marks no invalid slot, but it does record `unvalidated`, which is not a neutral outcome.

The verdict is bound to the identity of the block it was read at, not to a height. The head is read for its number
and hash, the resolution is pinned to that number, and the block is read again afterwards: a provider serving a
stale fork, or one the chain reorged under, answers a call at a height as readily as the canonical chain, so an
answer whose block is no longer the one at that height is refused as unverifiable rather than passed. A provider
that lags uniformly is still invisible from here, and the README says so.

The advertised two-second ceiling is now a real bound. It was only handed to `retryUntil`, which checks its
deadline after an attempt returns, so one stalled RPC consumed the whole remaining duty; it is now a race, via a
new `DutyBudget.runWithin`, and the abandoned loop checks the signal instead of starting another read.

Tracker pruning no longer runs in the acceptance path. It reads L1 tips, and the restructure had put it on the
cached path too, where the validator calls this method directly without an outer timeout — a hanging tips read
stalled the attestation. It is bookkeeping, so it runs detached, and the pipelining parent is not recorded at all
once the duty has been stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not against the proposer

An endpoint refusal recorded `unvalidated`, which the sentinel reports as `checkpoint-unvalidated` and counts as
a missed proposal for the slot's proposer. So a node whose own L1 RPC was down through the retry window charged
someone else's validator for it, on the first evaluation of an otherwise content-valid proposal — inactivity
accounting this gate was never meant to feed.

Both endpoint reasons now record `unverifiable`, the outcome for a proposal this observer could not check against
anything outside itself, which is counted against nobody. The monotonic protection covers it too, so a later
endpoint failure still cannot retract a `valid` this node recorded for the same checkpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…termined invalid

The tracker keys its per-slot entry by slot alone, and the protection against a local-inability outcome replacing
a determination only covered `valid`, and only for the very checkpoint that produced it. An equivocating proposer
whose second proposal for the slot reached the endpoint gate and could not be checked therefore erased what the
first one established. Uncertainty now never overwrites a verdict in either direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@spalladino
spalladino force-pushed the spl/fi2-f14-l1-endpoint-check branch from abbbc32 to b5f0c77 Compare September 10, 2026 14:28
@spalladino
spalladino removed this pull request from stack #25452 September 10, 2026 14:31
@spalladino
spalladino changed the base branch from spl/fi2-f16-rescue-tail-blob to spl/fi2-review-fixes September 10, 2026 14:31
@spalladino
spalladino changed the base branch from spl/fi2-review-fixes to spl/fi2-f16-rescue-tail-blob September 10, 2026 14:31
@spalladino
spalladino added this pull request to stack #25453 September 10, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on draft PRs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant