feat(fast-inbox): consume inbox buckets once their L1 block has a canonical descendant - #25351
Open
spalladino wants to merge 6 commits into
Open
Conversation
spalladino
force-pushed
the
spl/inbox-descendant-confirmed-eligibility
branch
from
August 28, 2026 20:49
02a0824 to
92872ee
Compare
This was referenced Aug 28, 2026
…onical descendant The proposer used to wait until an Inbox bucket was one Ethereum slot old before consuming it, and validators rejected anything younger. That rule buys no safety: L1 block timestamps and the proposer's sub-slot ticks sit on the same 12s grid, so the `T+12` tick at which the bucket became consumable always precedes the `T+13..15` window in which a one-block reorg replaces the bucket's opening block. Replace it with evidence. A bucket opened in L1 block `N` (number `h`, hash `H`, timestamp `T`) is consumable once block `h+1` is visible with `parentHash == H`, or once `now >= T + 2E` (slot `S+1` fully elapsed, covering a missed slot) and block `h` still hashes to `H`. A child with a different parent, or a different block at height `h`, means `N` is already orphaned and the bucket is skipped. The selector now takes an eligibility function instead of a minimum age and walks back from the archiver's head bucket to the first eligible one. `CheckpointProposalJob` owns one `InboxBucketConfirmationTracker` per slot, which is the only place the sequencer reads L1 blocks for this; it makes no call before a child could exist, caches confirmations for the slot, caches rejections per sub-slot, and decides each branch from a single response. Automine and the node's next-block prediction pass `immediateEligibility` instead. Validators drop the bucket-age check entirely: L1 has no age rule, so a validator that refused a young bucket would refuse to attest to a checkpoint `propose` accepts. When a bucket becomes consumable is now purely the proposer's policy.
…uckets A busy L1 block can roll the Inbox over several times, so a bucket-counted walk let one unconfirmed block hide every confirmed bucket behind it and the block then consumed nothing. Key the confirmation caches by opening L1 block identity so sibling buckets share one L1 read, count the walk in distinct L1 blocks, and when the bound is hit fall back to the newest bucket old enough to decide without waiting for a descendant.
…heir failures These reads sit on the block-building path, where viem's 10s default plus retries would eat a sub-slot. Cap each read (2s by default, configurable), treat a timeout or RPC failure as pending rather than as an orphaned block, cache it like any other rejection so one flaky endpoint costs a single read per second, and log it at debug instead of warning once per bucket. Also reword the rejection cache docs, which said 'per sub-slot' where the key is the second the answer was computed at.
…ead of a viem slice Pick<ViemPublicClient, 'getBlock'> carries viem's overloads, so every test fake had to go through 'as unknown as L1BlockReader'. Declaring the one read the confirmation tracker makes lets the fakes be plain typed objects, and a viem public client still satisfies it.
…ser's eligibility rule The node simulated a transaction against every synced bucket while the proposer waits for the bucket's L1 block to gain a descendant, so a tx consuming a message from an unconfirmed bucket passed simulation, entered the pool, and then failed at proposal. The node now runs one confirmation tracker of its own over the L1 client it already has. Automine nodes, and nodes with no L1 client at all, keep predicting against every synced bucket: automine never waits for a confirmation its own chain only produces on demand.
…he bridge tutorials The local sandbox mines on demand and consumes an L1-to-L2 message as soon as it sees it; only on a live network does the sequencer wait for the message's L1 block to gain a child.
spalladino
force-pushed
the
spl/inbox-descendant-confirmed-eligibility
branch
from
August 29, 2026 11:09
bc54e0b to
0f57b2d
Compare
Collaborator
Flakey Tests🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry. |
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 #25350
Replaces the fixed "bucket must be one Ethereum slot old" rule with one that waits for actual evidence that the bucket's opening L1 block is on the canonical chain.
Why the age rule buys nothing
An L1 block
N(numberh, hashH, timestampT) that gets reorged out is replaced by a block built onN-1, and that replacement lands at roughlyT+13..15— theS+1proposer must decide within a second or two ofT+12and publish, and the head only flips once the replacement is imported. Aztec sub-slot ticks and L1 block timestamps sit on the same 12s grid, so the proposer'sT+12tick — the first moment the age rule made the bucket consumable — always precedes the window in which it would have learned the block was gone. The rule delayed consumption by a full Ethereum slot and caught nothing.The rule
A bucket opened in L1 block
Nis eligible once eitherh+1is visible and itsparentHashisH—Nthen survives even if that child is itself reorged, since the replacement builds on the same parent (usually aboutT+14); ornow >= T + 2E(slotS+1fully elapsed) and blockhstill hashes toH, which covers a missed slotS+1. Past that point the honest fork-choice mechanism can no longer displaceN: it only ever reorgs the head's immediate successor slot.A child with a different parent, or a different block at height
h, meansNis already orphaned — the bucket is skipped rather than waited on, and the archiver rolls it back shortly.No L1 change is needed.
proposehas no age rule, and a bucket opened exactly at the censorship cutoff (toTimestamp(S-1) - E) gets its child about 2s into the build frame, so the mandatory consumption floor stays satisfiable; a unit test pins this. The cutoff override is unchanged: a mandatory bucket is consumed whether or not it is confirmed.Caching and RPC budget
InboxBucketConfirmationTracker(new,sequencer-client/src/sequencer/inbox_bucket_eligibility.ts) is the only place the sequencer reads L1 blocks for this. One tracker perCheckpointProposalJob, i.e. per slot. ItT + E, since no child can exist yet;${l1BlockNumber}:${l1BlockHash}), so the several buckets a busy L1 block opens all resolve from one read;nowSecondsthey were computed at, so repeated selector calls within the same second cost nothing;debug. A bucket at or below the cutoff is consumed by the last block regardless, so a flaky endpoint costs latency, not liveness.The selector walks newest-first from the archiver's head bucket and stops at the first eligible one. The walk is bounded by 8 distinct opening L1 blocks rather than by buckets: a single L1 block can roll the Inbox over many times, and a bucket-counted bound would let one unconfirmed block hide every confirmed bucket behind it, leaving the block consuming nothing. When the bound is hit the selector jumps to the newest bucket opened at or before
now - 2E, which theT+2Ebranch decides outright, before giving up. On a healthy chain only the newest bucket or two can be unconfirmed, so the steady-state cost is oneeth_getBlockByNumberper sub-slot.The selector now takes an eligibility function rather than a minimum age, and ships two: the tracker's, and
immediateEligibility. Automine passesimmediateEligibilityunconditionally — anvil mines on demand, so a bucket's opening block gains a descendant only when the next transaction is sent, which may be long after the block that consumes it.The node predicts with the same rule
When the node simulates a transaction's public calls it appends the message bundle the next block is expected to consume. That prediction has to use the proposer's eligibility rule: a transaction simulated against an unconfirmed bucket passes simulation, enters the pool, and then fails when the block that includes it consumes less. The node therefore keeps one
InboxBucketConfirmationTrackerof its own, over the L1 client it already holds — a node-lifetime cache, which is sound because confirmations are permanent facts about L1. Automine nodes (useAutomineSequencer), and nodes with no L1 client at all (TXE), keep predicting against every synced bucket.Validators stop checking bucket age
The validator's
bucket_too_newcheck is removed along with the reason string. L1 has no age rule:proposeaccepts any bucket the censorship cutoff and the caps allow, whenever it is proposed. A validator that rejected a young bucket would refuse to attest to a checkpoint L1 would accept, and could be griefed into missing attestations by a proposer that is simply faster than its own clock. When a bucket becomes consumable is now purely proposer policy; validators check what L1 checks (hash matches their own archiver view, cutoff floor, caps) plus local-view consistency.Configuration
No configuration is removed or added: the old minimum age was derived from
ethereumSlotDurationat each call site and was never an environment variable.Tests
inbox_bucket_eligibility.test.ts(new): the six algorithm branches plus explicit RPC-count assertions — no call beforeT+E, exactly one call per decision, a confirmation served from cache, a rejection reused within the same second and re-checked in the next one. Also the clock-tolerance boundary, the genesis sentinel bucket (eligible with no RPC), a failing L1 read (not retried within the second), and a read that never answers (timed out, treated as pending).inbox_bucket_selector.test.ts: unconfirmed head bucket falls back to the previous confirmed one; nothing eligible consumes nothing; the walk bound and its settled-bucket fallback (both when the fallback is eligible and when it is not); a rollover case where one L1 block opened ten buckets and the confirmed bucket behind them is still selected, for one read per distinct L1 block; immediate eligibility; and a cutoff-compatibility case showing a bucket opened exactly atcutoff(S)is confirmed by the first sub-slot of the build frame.node_public_calls_simulator.test.ts: the node predicts nothing from a bucket the proposer would still be waiting on, predicts the bundle once the opening block has a canonical child, and never reads L1 under automine or without an L1 client.streaming_inbox_checks.test.ts: age cases deleted, replaced by one asserting a bucket opened a second ago is accepted.Gates: full
yarn build;yarn formatandyarn lintclean on sequencer-client, aztec-node, end-to-end. Suites: sequencer-clientsrc/sequencer190 passed / 1 skipped across 8 files (l1_publisher.integration.test.tsneeds a local anvil and was not run), validator-client 273 passed / 3 skipped across 10 suites, aztec-nodenode_public_calls_simulator.test.tsandserver.test.ts100/100. No e2e run;streaming_inbox.test.tswas updated to wait on the descendant rule instead of the age rule and compiles.#25341 was closed as superseded: with the validator age check gone there is nothing left to apply a clock tolerance to.
Fixes A-1879