Skip to content

feat(fast-inbox): consume inbox buckets once their L1 block has a canonical descendant - #25351

Open
spalladino wants to merge 6 commits into
spl/inbox-bucket-l1-block-reffrom
spl/inbox-descendant-confirmed-eligibility
Open

feat(fast-inbox): consume inbox buckets once their L1 block has a canonical descendant#25351
spalladino wants to merge 6 commits into
spl/inbox-bucket-l1-block-reffrom
spl/inbox-descendant-confirmed-eligibility

Conversation

@spalladino

@spalladino spalladino commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 (number h, hash H, timestamp T) that gets reorged out is replaced by a block built on N-1, and that replacement lands at roughly T+13..15 — the S+1 proposer must decide within a second or two of T+12 and 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's T+12 tick — 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.

T+1..3    N becomes latest
T+4       attestation deadline; a late/weak N gets no boost
T+12      slot S+1 starts. Age rule releases the bucket here.
T+13..15  N' imported, head flips to a different block at height h
T+16      S+1 attestation deadline; N (if still canonical) is now heavily voted
T+24      slot S+1 over; an honest reorg of N is no longer possible

The rule

A bucket opened in L1 block N is eligible once either

  • block h+1 is visible and its parentHash is HN then survives even if that child is itself reorged, since the replacement builds on the same parent (usually about T+14); or
  • now >= T + 2E (slot S+1 fully elapsed) and block h still hashes to H, which covers a missed slot S+1. Past that point the honest fork-choice mechanism can no longer displace N: it only ever reorgs the head's immediate successor slot.

A child with a different parent, or a different block at height h, means N is already orphaned — the bucket is skipped rather than waited on, and the archiver rolls it back shortly.

No L1 change is needed. propose has 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 per CheckpointProposalJob, i.e. per slot. It

  • makes no call at all before T + E, since no child can exist yet;
  • keys both caches by opening L1 block identity (${l1BlockNumber}:${l1BlockHash}), so the several buckets a busy L1 block opens all resolve from one read;
  • caches confirmations for the tracker's life (confirmed never becomes unconfirmed);
  • caches rejections against the nowSeconds they were computed at, so repeated selector calls within the same second cost nothing;
  • decides each branch from a single response — behind a load-balanced RPC two calls may see different heads, so no branch compares two of them;
  • time-boxes every read (2s by default, configurable), since these sit on the block-building path where viem's 10s default plus retries would eat the sub-slot;
  • treats a timed-out or failed L1 read as "not eligible yet" rather than as an orphaned block, and caches it like any other rejection so a flaky endpoint costs one read per second, logged at 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 the T+2E branch decides outright, before giving up. On a healthy chain only the newest bucket or two can be unconfirmed, so the steady-state cost is one eth_getBlockByNumber per sub-slot.

The selector now takes an eligibility function rather than a minimum age, and ships two: the tracker's, and immediateEligibility. Automine passes immediateEligibility unconditionally — 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 InboxBucketConfirmationTracker of 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_new check is removed along with the reason string. L1 has no age rule: propose accepts 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 ethereumSlotDuration at 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 before T+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 at cutoff(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 format and yarn lint clean on sequencer-client, aztec-node, end-to-end. Suites: sequencer-client src/sequencer 190 passed / 1 skipped across 8 files (l1_publisher.integration.test.ts needs a local anvil and was not run), validator-client 273 passed / 3 skipped across 10 suites, aztec-node node_public_calls_simulator.test.ts and server.test.ts 100/100. No e2e run; streaming_inbox.test.ts was 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

…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.
@spalladino
spalladino requested a review from just-mitch as a code owner August 29, 2026 11:07
…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
spalladino force-pushed the spl/inbox-descendant-confirmed-eligibility branch from bc54e0b to 0f57b2d Compare August 29, 2026 11:09
@AztecBot

Copy link
Copy Markdown
Collaborator

Flakey Tests

🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry.

\033FLAKED\033 (8;;http://ci.aztec-labs.com/6e8278a6e8d2187b�6e8278a6e8d2187b8;;�): yarn-project/kv-store/scripts/run_test.sh src/bench/sqlite-opfs-encrypted/map_bench.test.ts (1s) (code: 0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants