Skip to content

feat(validator): resolve a checkpoint's final message position as a live Inbox endpoint - #183

Draft
spalladino wants to merge 3 commits into
spl/fi-n5-rescue-tail-blobsfrom
spl/fi-n6-endpoint-gate
Draft

spalladino wants to merge 3 commits into
spl/fi-n5-rescue-tail-blobsfrom
spl/fi-n6-endpoint-gate

Conversation

@spalladino

@spalladino spalladino commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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 head ed18fbeb7c) were read as
evidence 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 InboxContract wrapper performs another eth_blockNumber call inside checkBlockTag, as recorded below. Only
an 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_replaced is gone
from 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) sit
elsewhere.

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 / retryUntil helpers and races the whole
stage 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 DutyBudget is 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) and inbox_endpoint_mismatch
(interior position, evicted boundary, wrong rolling hash), both map to the existing unvalidated outcome. They raise
no invalid-proposal offense, set no invalid-slot marker and trigger no peer penalty. The local-proposer fast path,
historical replay and the skipCheckpointProposalValidation testing switch keep their existing behaviour; the two
config 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 b5f0c774c4 exists for: 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; 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. 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 InboxContract handle from aztec-node/src/factory.ts, validator or not, since the
all-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 fake
    Inbox ring the tests share. No callers.
  • 3649ddad56 — the gate itself: the proposal handler's confirmInboxEndpoint, the single-use payload-keyed
    handoff, 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 --check and yarn lint — clean.
  • yarn workspace @aztec-labs/validator-client test — base at spl/fi-n5-rescue-tail-blobs measured at
    10 suites, 291 passed, 3 skipped; here 11 suites, 319 passed, 3 skipped (+1 suite, +28 tests).
  • yarn workspace @aztec-labs/sequencer-client test12 suites, 305 passed, 1 skipped, at base parity.
  • yarn workspace @aztec-labs/aztec-node test7 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.

  • Deleting the gate (short-circuiting the call) reddens all 17 new proposal-handler cases: exact success,
    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.
  • Removing the abort race (plain retryUntil with no signal) makes the hung-provider case hang until jest's
    120-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.
  • Disabling the handoff reuse reddens "makes no second Inbox query for the attestation callback that
    immediately follows"; making the handoff persistent instead of single-use reddens "checks for itself on a
    later independent dispatch".
  • Making the two reasons punitive (SLASHABLE_... true and the outcome invalid) reddens 7 cases, including
    the one asserting that a refused checkpoint never becomes the accepted proposed checkpoint and never sets the
    invalid-slot marker the peer-penalty path reads.
  • Dropping the precedence guard reddens exactly the two guard tests.
  • Keeping the handoff across a local prune reddens the pruning test.
  • Two pre-existing integration tests (validator.integration.test.ts, validator.test.ts) went red the moment the
    gate was wired in and needed their live rings set up, which is end-to-end evidence that the gate reaches the real
    attestation path.
  • "The gate does not re-read the L1 block hash after the resolver call" is pinned two ways: a unit test counts
    exactly one height read and one pinned resolver call per attempt, and the InboxEndpointReader type exposes only
    getBlockNumber, so a block-hash read would not compile.

Deliberately not carried from #25437

  • DutyBudget and duty_budget.ts (deferred whole-duty timeout work). #25437 runs the whole gate inside a shared whole-duty
    budget. Here the stage owns a small ceiling of its own, built from existing timeout and abort helpers.
  • unverifiable / checkpoint-unverifiable and sentinel encoding 9 (deferred observer and inactivity-accounting work). #25437 records endpoint
    refusals under a new neutral status. This uses the existing unvalidated outcome. The consequence is explicit and
    not glossed: the baseline sentinel still counts an unvalidated proposer duty against inactivity. Whether any new
    exemption is justified is precisely the deferred decision.
  • The post-resolver block-hash re-read and the view_replaced result. Removed from this gate only, per the
    approved policy. Archiver-side rollback safeguards are untouched.
  • The fresh re-check on the second callback. #25437 re-reads L1 before reusing a valid verdict; the approved
    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.
  • Making the re-execution tracker prune fire-and-forget, and the budget.signal.aborted guard before setting the
    proposed checkpoint. Both belong to #25437's duty-bounding work rather than to the endpoint policy, so the prune
    stays exactly as it was.
  • Nothing from #25440's mixed duty/accounting work, no R − P gossip send cutoff (the deferred gossip-deadline work), no Solidity, Noir,
    spartan or docs changes. validator-client/README.md changes from the source were not carried; that file does not
    exist here.

Notes for the reviewer

  • getBucketAtOrBeforeTotal calls checkBlockTag, which itself reads the block number, so a real
    InboxContract issues two eth_blockNumber calls per attempt rather than one. Both are height reads, not the
    block-hash read the policy removes, and the extra one is inside the shared ethereum helper rather than in this
    gate.
  • The retry window's one-second floor is a small addition beyond the source's shape, which solved the same problem
    with DutyBudget's grace allowance. Without it, a proposal validated after its attestation deadline — all blocks
    local, 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.
  • The gate reads the checkpoint's last block once more (getBlockData({ archive })) to learn the final message
    total. 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.

…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
spalladino added this pull request to stack #188 September 12, 2026 04:52
@spalladino spalladino changed the title spl/fi n6 endpoint gate feat(validator): resolve a checkpoint's final message position as a live Inbox endpoint Sep 12, 2026
@spalladino spalladino added the ci-draft Run CI on this draft PR label Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on this draft PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant