feat(validator): verify a checkpoint's final Inbox endpoint against L1 before accepting a proposal - #25437
Draft
spalladino wants to merge 5 commits into
Draft
Conversation
spalladino
added this pull request to stack #25417
September 9, 2026 21:01
spalladino
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 9, 2026 21:15
991642b to
ad7e80e
Compare
spalladino
removed this pull request from stack #25417
September 9, 2026 21:16
spalladino
added this pull request to stack #25441
September 9, 2026 21:16
spalladino
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 10, 2026 02:46
ad7e80e to
20ee82c
Compare
spalladino
requested review from
IlyasRidhuan,
LeilaWang,
iAmMichaelConnor,
iakovenkos and
ledwards2225
as code owners
September 10, 2026 04:04
spalladino
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 10, 2026 04:04
20ee82c to
47f2764
Compare
spalladino
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 10, 2026 05:11
47f2764 to
ed18fbe
Compare
spalladino
removed this pull request from stack #25441
September 10, 2026 11:49
spalladino
added this pull request to stack #25448
September 10, 2026 11:49
spalladino
removed this pull request from stack #25448
September 10, 2026 12:09
spalladino
changed the base branch from
spl/fi2-f16-rescue-tail-blob
to
spl/fi2-review-fixes
September 10, 2026 12:10
spalladino
changed the base branch from
spl/fi2-review-fixes
to
spl/fi2-f16-rescue-tail-blob
September 10, 2026 12:10
spalladino
added this pull request to stack #25449
September 10, 2026 12:11
spalladino
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 10, 2026 12:50
ed18fbe to
7dd69f7
Compare
spalladino
removed this pull request from stack #25449
September 10, 2026 13:04
spalladino
added this pull request to stack #25451
September 10, 2026 13:05
spalladino
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 10, 2026 13:51
7dd69f7 to
abbbc32
Compare
spalladino
removed this pull request from stack #25451
September 10, 2026 13:53
spalladino
changed the base branch from
spl/fi2-f16-rescue-tail-blob
to
spl/fi2-review-fixes
September 10, 2026 13:54
spalladino
changed the base branch from
spl/fi2-review-fixes
to
spl/fi2-f16-rescue-tail-blob
September 10, 2026 13:54
spalladino
added this pull request to stack #25452
September 10, 2026 13:54
spalladino
removed request for
IlyasRidhuan,
LeilaWang,
iAmMichaelConnor,
iakovenkos and
ledwards2225
September 10, 2026 14:22
spalladino
marked this pull request as draft
September 10, 2026 14:22
…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
force-pushed
the
spl/fi2-f14-l1-endpoint-check
branch
from
September 10, 2026 14:28
abbbc32 to
b5f0c77
Compare
spalladino
removed this pull request from stack #25452
September 10, 2026 14:31
spalladino
changed the base branch from
spl/fi2-f16-rescue-tail-blob
to
spl/fi2-review-fixes
September 10, 2026 14:31
spalladino
changed the base branch from
spl/fi2-review-fixes
to
spl/fi2-f16-rescue-tail-blob
September 10, 2026 14:31
spalladino
added this pull request to stack #25453
September 10, 2026 14:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
H200.H400.H200is publishable with respect to this rule.200 != 256, total 256 is an interior position between the endpoints at 200 and 400, not a bucket boundary.H200also fails: the count is a boundary, but itcommits 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:
getBucketAtOrBeforeTotalto that block number.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
unverifiableoutcome introduced below this PR (in #25440), which the sentinel surfaces ascheckpoint-unverifiableand counts in neither half of the missed-proposal rate. Recording nothing would have beenworse: 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
validthis noderecorded for the same checkpoint, and — since a proposer may equivocate — it cannot erase an
invalidrecorded for theslot 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 alsoinvalidate 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 theattestation 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.