feat(validator): resolve a checkpoint's final message position as a live Inbox endpoint - #183
Draft
spalladino wants to merge 3 commits into
Draft
spalladino wants to merge 3 commits into
spalladino wants to merge 3 commits into
Conversation
…ive Inbox endpoint Adds the read a receiving node needs to tell whether the position a checkpoint finishes at is one L1 will accept: the newest live bucket ending at or below that total, resolved at a height read once so a retry sequence cannot mix answers from a moving `latest`. Only an exact total with the signed rolling hash is a match. No caller yet.
A checkpoint's content checks authenticate what it consumed against this node's own message log; they cannot say whether the position it finishes at is one L1 will accept, and only a checkpoint's final position has to close a live bucket. After the deterministic content verdict, confirm that position and the signed rolling hash against the exact live endpoint, within a bounded retry window that races the whole stage so a provider that accepts a call and never answers cannot keep the local operation alive. The all-nodes callback hands its decision to the attestation callback p2p runs straight after it for the same signed payload, so the pair costs one endpoint sequence. That handoff is single-use and payload-keyed: a direct attestation, a different payload, a later independent dispatch, or a local prune that invalidated the verdict each ask L1 for themselves. Content-validation caching stays separate, so a transient endpoint failure can be retried later without rebuilding an identical checkpoint. A refusal describes the L1 view of the moment, never the proposer: it declines acceptance and attestation with the existing non-slashable `unvalidated` outcome and raises no invalid-proposal offense or peer penalty. Local-proposer, historical-replay and the testing skip switch are unchanged.
…dy determined p2p evaluates one checkpoint proposal twice, and the second look can fail on something purely local — an unreadable L1 view, a trailing archiver — after the first one reached a verdict. Recording that inability over the verdict loses a determination that will not come back. An `unvalidated` outcome no longer overwrites a recorded verdict: `valid` is protected for the very checkpoint that produced it, so a different archive at the same slot still records, while `invalid` is protected for the slot outright, because the tracker keys its slot entry by slot alone and an equivocating proposer whose second proposal cannot be checked would otherwise erase what the first established.
spalladino
added this pull request to stack #188
September 12, 2026 04:52
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.
Validators now compare a checkpoint's signed final message count and rolling hash with one live L1 Inbox endpoint
decision before accepting or attesting to it. This is a rebuild against an approved simplified policy, not a port
of #25437. The five commits of #25437
(
076df84ab9,d567d9f529,9d5f425043,d6d8a74f21,b5f0c774c4, reviewed headed18fbeb7c) were read asevidence of the problem and of the shape of a solution. The behaviour implemented here is the one specified by the
Fast Inbox simplification plan of 2026-09-10, which deliberately differs from #25437 in several places; those
differences are listed at the end.
What the gate does
A checkpoint's deterministic content checks authenticate what it consumed against this node's own message log. They
cannot say whether the position the checkpoint finishes at is one L1 will accept, and only a checkpoint's final
position has to close a live Inbox bucket — its blocks may consume an arbitrary prefix and end anywhere. That last
question is now asked before the checkpoint becomes this node's pipelining parent and before anything is signed.
Content validation runs first, and its verdict is cached as before. The gate itself reads the L1 block number once and
resolves the newest live bucket ending at or below the checkpoint's final message total, pinned to that height. The
real
InboxContractwrapper performs anothereth_blockNumbercall insidecheckBlockTag, as recorded below. Onlyan exact total is a match — a lower boundary means the position sits inside a bucket — and the bucket's own rolling
hash then has to be the one the checkpoint signed. The block is never read back afterwards:
view_replacedis gonefrom this gate, since an answer from a provider on a stale fork is indistinguishable from a canonical one either way,
and the checks that actually protect against that (the proposer's publication preflight, and L1's own
propose) sitelsewhere.
The stage keeps a small bounded retry, because a provider trailing the head by a block or two catches up within a read
or two. The window is two seconds, narrowed by whatever is left of the slot's attestation deadline and floored at one
second so a proposal whose blocks are all local still gets its one attempt after the deadline has passed. It is
implemented with the existing
execWithSignal/AbortSignal.timeout/retryUntilhelpers and races the wholestage rather than spacing its attempts, so a provider that accepts a call and never answers is abandoned at the
ceiling instead of holding the local operation open. No whole-duty
DutyBudgetis introduced.p2p evaluates one proposal twice — the all-nodes callback, then the attestation callback right after it. The first
hands its confirmation to the second for the same signed payload, so the pair costs one endpoint sequence and the
attestation makes no Inbox query of its own. That handoff is single-use and keyed by the full signed payload hash: a
direct attestation entry, a different payload, a later independent dispatch, and a local prune that invalidated the
verdict each establish the endpoint for themselves. It is not a cache keyed by slot or archive, and it has no
lifetime beyond the adjacent pair. Content-validation caching stays separate, so a transient endpoint failure can be
retried later without rebuilding an identical checkpoint.
A refusal describes the L1 view of the moment, never the proposer. Two new non-slashable reasons,
inbox_endpoint_unavailable(unreadable view, hung provider, missing last block) andinbox_endpoint_mismatch(interior position, evicted boundary, wrong rolling hash), both map to the existing
unvalidatedoutcome. They raiseno invalid-proposal offense, set no invalid-slot marker and trigger no peer penalty. The local-proposer fast path,
historical replay and the
skipCheckpointProposalValidationtesting switch keep their existing behaviour; the twoconfig descriptions now say plainly that the switch also skips the live endpoint check and is for testing only.
The third commit adds the precedence guard
b5f0c774c4exists for: anunvalidatedoutcome no longer overwrites arecorded verdict.
validis protected for the very checkpoint that produced it, so a different archive at the sameslot still records;
invalidis protected for the slot outright, because the tracker keys its slot entry by slotalone and an equivocating proposer whose second proposal cannot be checked would otherwise erase what the first
established. These guards did not exist in this repo — the plan describes them as "existing"; they were part of
#25437 and land here.
Every node gets a read-only
InboxContracthandle fromaztec-node/src/factory.ts, validator or not, since theall-nodes callback runs the gate on non-validator nodes too.
Stack position
This is rung 20 of 25 in the Fast Inbox node stack, stacked on
spl/fi-n5-rescue-tail-blobs.Commits
6d5431c3b7— the standalone endpoint resolution (checkpoint_endpoint_check.ts), its unit test, and the fakeInbox ring the tests share. No callers.
3649ddad56— the gate itself: the proposal handler'sconfirmInboxEndpoint, the single-use payload-keyedhandoff, the two new reasons and their non-slashable mapping, dependency plumbing through the validator client,
both factories and the node factory, the two config descriptions and the libp2p dispatch comment, plus the
regression coverage.
328786752e— the precedence guard and its two tests.Each builds standalone.
Tests actually run
From
yarn-project, on this branch:yarn build— green.yarn format --checkandyarn lint— clean.yarn workspace @aztec-labs/validator-client test— base atspl/fi-n5-rescue-tail-blobsmeasured at10 suites, 291 passed, 3 skipped; here 11 suites, 319 passed, 3 skipped (+1 suite, +28 tests).
yarn workspace @aztec-labs/sequencer-client test— 12 suites, 305 passed, 1 skipped, at base parity.yarn workspace @aztec-labs/aztec-node test— 7 suites, 190 passed.yarn workspace @aztec-labs/p2p test src/config.test.ts— 11 passed.e2e suites were not run; they are left to CI.
Red/green
The main gate behavior is pinned by tests that fail without it.
consumed-nothing success, interior position, rolling-hash mismatch, evicted boundary, RPC error, lagging provider
recovery, hung RPC, pipelining refusal, pipelining acceptance, callback reuse, later dispatch, direct entry,
changed payload, local pruning, content reuse with endpoint retry, and both precedence guards.
retryUntilwith no signal) makes the hung-provider case hang until jest's120-second test timeout — direct proof that the stuck provider would otherwise keep the local operation alive.
The hang is a real never-settling promise from the fake provider, not a rejection.
immediately follows"; making the handoff persistent instead of single-use reddens "checks for itself on a
later independent dispatch".
SLASHABLE_...true and the outcomeinvalid) reddens 7 cases, includingthe one asserting that a refused checkpoint never becomes the accepted proposed checkpoint and never sets the
invalid-slot marker the peer-penalty path reads.
validator.integration.test.ts,validator.test.ts) went red the moment thegate was wired in and needed their live rings set up, which is end-to-end evidence that the gate reaches the real
attestation path.
exactly one height read and one pinned resolver call per attempt, and the
InboxEndpointReadertype exposes onlygetBlockNumber, so a block-hash read would not compile.Deliberately not carried from #25437
DutyBudgetandduty_budget.ts(deferred whole-duty timeout work). #25437 runs the whole gate inside a shared whole-dutybudget. Here the stage owns a small ceiling of its own, built from existing timeout and abort helpers.
unverifiable/checkpoint-unverifiableand sentinel encoding 9 (deferred observer and inactivity-accounting work). #25437 records endpointrefusals under a new neutral status. This uses the existing
unvalidatedoutcome. The consequence is explicit andnot glossed: the baseline sentinel still counts an unvalidated proposer duty against inactivity. Whether any new
exemption is justified is precisely the deferred decision.
view_replacedresult. Removed from this gate only, per theapproved policy. Archiver-side rollback safeguards are untouched.
policy is one endpoint sequence per adjacent callback pair, so the source's "re-checks the endpoint on L1 before
reusing a cached valid verdict" test has no equivalent here and its inverse is asserted instead.
budget.signal.abortedguard before setting theproposed checkpoint. Both belong to #25437's duty-bounding work rather than to the endpoint policy, so the prune
stays exactly as it was.
R − Pgossip send cutoff (the deferred gossip-deadline work), no Solidity, Noir,spartan or docs changes.
validator-client/README.mdchanges from the source were not carried; that file does notexist here.
Notes for the reviewer
getBucketAtOrBeforeTotalcallscheckBlockTag, which itself reads the block number, so a realInboxContractissues twoeth_blockNumbercalls per attempt rather than one. Both are height reads, not theblock-hash read the policy removes, and the extra one is inside the shared ethereum helper rather than in this
gate.
with
DutyBudget's grace allowance. Without it, a proposal validated after its attestation deadline — all blockslocal, nothing to wait for — would be refused by a gate with zero budget, which would be a regression at the
margin. This floor was invented during the port and is not authorized by #25437 or the simplification plan. The
report gives no isolated red/green evidence for choosing one second rather than zero. It needs human review because
it carries part of the deferred duty-grace behavior without the surrounding whole-duty policy.
getBlockData({ archive })) to learn the final messagetotal. That read is also what the reuse path already needed to confirm the blocks are still local, so it is taken
on both paths rather than added to one.