feat(fast-inbox): wait for the local archiver before rejecting a block proposal for an unsynced inbox bucket - #25342
Open
spalladino wants to merge 1 commit into
Conversation
spalladino
force-pushed
the
spl/a-1393-validator-wait-for-inbox-bucket-sync
branch
from
August 28, 2026 19:28
a5869b8 to
982ef51
Compare
spalladino
changed the base branch from
spl/a-1516-inbox-bucket-age-clock-tolerance
to
spl/checkpoint-builder-insert-messages-before-txs
August 28, 2026 19:29
spalladino
marked this pull request as ready for review
August 28, 2026 19:29
…k proposal for an unsynced inbox bucket A proposer only consumes buckets at least one Ethereum slot old, so a bucket a validator cannot resolve is almost always its own archiver trailing L1, not a divergence. Rejecting on the spot with `bucket_unknown` lost an attestation for a pure race. The handler now forces an archiver sync and re-runs the whole metadata check every half second until it resolves or the attestation deadline passes. A hash mismatch on a known bucket gets one forced sync and one re-check (this node may be the stale side of an L1 reorg); every other reason still rejects immediately. The deadline-bounded sync waits in the handler now go through a shared `awaitLocalSync` helper, which the new wait reuses; the checkpoint last-block wait keeps its own copy because it must still attempt one lookup after the deadline has passed.
spalladino
force-pushed
the
spl/a-1393-validator-wait-for-inbox-bucket-sync
branch
from
August 29, 2026 11:07
982ef51 to
f113a25
Compare
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.
Stacked on #25323.
The race
A validator receiving a block proposal first runs the streaming-Inbox metadata checks, which look the proposal's bucket up in the validator's own archiver. If the archiver has not yet synced the L1 block that opened that bucket, the lookup returns
undefinedand the proposal was rejected on the spot withbucket_unknown.That is a pure race, not a divergence. The proposer only consumes buckets at least one Ethereum slot old, so the bucket is on L1; the validator's archiver polls L1 on an interval, and a proposal arriving inside that window lost this validator's attestation. With buckets opening in nearly every L1 block under load this is a steady drip of lost attestations, not an edge case.
What waits and what does not
bucket_unknown→ bounded wait. Force an archiver sync, re-run the whole metadata check, repeat every 0.5s until it resolves or the slot's attestation deadline (target_slot_start + S − 2E) passes — the same bound the handler's other sync waits use. On timeout the rejection keeps thebucket_unknownreason (no new reason string to teach the slashing/invalidclassification maps) plus awarncarryingreason: 'bucket_sync_timeout'andwaitedMs.bucket_hash_mismatch→ one forced sync and one re-check. A known bucket seq with a different rolling hash means the two nodes saw different L1 blocks at that height, but it does not say which side is stale: after an L1 reorg the proposer may already be on the canonical replacement while this validator still holds the orphaned bucket. One forced sync performs the rollback and re-sync if we were the stale side. If the hashes still differ, our view is as good as L1's and the proposal is on the wrong fork — reject, with abucket_hash_mismatch_after_syncwarn. No loop: a persistent mismatch will not resolve by waiting.bucket_too_new,bucket_moves_backwards, the caps,parent_bucket_unresolved, a proposal with nobucketRef, and any arrival after the deadline has already passed — in that last case the archiver is not poked at all).checkStreamingBlockMetadatarather than just the bucket lookup, so it also covers the case where "the block before the checkpoint's first block has not synced" maps tobucket_unknown— the same kind of local lag — and guarantees the accepted result was computed against the synced view.Shared wait helper
The handler already had three near-identical
retryUntil(syncImmediate + lookup, { deadline, dateProvider }, 0.5)blocks. Rather than adding a fourth, they now share a privateawaitLocalSync(slot, what, resolve)that owns the deadline computation, the past-deadline short-circuit, the forced sync per attempt, and theTimeoutError→undefinedconversion. Callers keep their own log level and their own timeout fallback value.Migrated (behavior preserved exactly,
proposal_handler.test.tsgreen with no test changes before any of the bucket work landed — 45/45):getParentBlock(parent block not yet synced). Keeps its own past-deadline check so the "timed out" debug log still fires only for real timeouts, not for an arrival with no budget left.resolveExistingBlockAtNumber(stale fork at this block number during a reorg). Already had the past-deadline guard ahead of itswarn, so the mapping is exact.Not migrated:
validateCheckpointProposal. It has no past-deadline guard, andretryUntilrunsfnonce before checking the timeout — so today, when a proposal arrives past the deadline, it still performs one sync + lookup and accepts an already-synced block (there is a test pinning exactly that). The shared helper short-circuits toundefinedin that case, which the new bucket wait needs, so migrating this site would flip that case tolast_block_not_found. Left as is.DoS bound
Confirmed the p2p retention bound rather than assuming it. The attestation pool caps distinct payload hashes per position at
MAX_BLOCK_PROPOSALS_PER_POSITION = 2per(slot, indexWithinCheckpoint), and gossip validation rejectsindexWithinCheckpoint >= MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT(=MAX_BLOCKS_PER_CHECKPOINT= 72) at ingress, after checking that the proposal is signed by the slot's expected proposer. So the worst case is ≤ 144 concurrently waiting handlers per slot, all from the elected proposer for that slot, each polling aRunningPromise.triggerthat coalesces concurrent requests into a single sync run (one L1 head query when nothing changed). That is small, so the cheapindexWithinCheckpointvalidation was not hoisted ahead of the wait and the handler's order is unchanged. The cost of a bucket that never appears is the proposer's own slot, which it could waste anyway by not proposing.Tests
Six new unit cases in
proposal_handler.test.ts(bucket sync wait), all asserting on the result rather than on call counts, except where "did we poke the archiver at all" is the behavior.retryUntilconverts the deadline to a duration once and then runs on a real timer, so the deadline cases hold the fake clock 2s short of the slot-1 attestation deadline (40s) and assert "within one interval after", never "exactly at"; the whole block runs in ~2.6s.Red/green on case 1 and the mismatch cases, before the handler change:
After:
Full gate, all from
yarn-project:yarn build— cleanyarn format validator-client,yarn lint validator-client— cleanyarn workspace @aztec/validator-client test— 274 passed, 3 skipped, 277 total (10 suites)The existing
streaming_inbox_checks.test.tscase "rejects promptly when the referenced bucket is unknown (no waiting)" is unchanged: the pure check is still immediate, the wait lives in the handler. Stale comments instreaming_inbox_checks.tsandcheckStreamingBlockMetadatathat said there was no bounded wait yet are updated.No e2e was added: the behavior is a handler-local retry over an already-tested archiver API, and covering it end to end would need a test-only archiver pause hook (
Archiver.stop()cannot stallRunningPromise.trigger()) plus a committee-member selection dance for minutes of wall clock per case.