Skip to content

fix(fast-inbox): replay published blocks by message count - #25413

Draft
spalladino wants to merge 12 commits into
nextfrom
spl/fi2-count-addressed-replay
Draft

fix(fast-inbox): replay published blocks by message count#25413
spalladino wants to merge 12 commits into
nextfrom
spl/fi2-count-addressed-replay

Conversation

@spalladino

@spalladino spalladino commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Context

The message-only Inbox rolling hash survives an L1 reorg that re-mines the same ordered leaves under different bucket boundaries. An already-published block stays valid in that case, but a node reading its history back could not replay it: world-state and the validator's checkpoint reconstruction resolved a block's committed leaf count to a bucket of the current partition, and a count that had stopped being a boundary silently produced an empty message bundle. World-state also treated a missing parent block as "leaf count zero", which would ask for every message ever received as the next block's bundle.

This is the first PR of the bucketless rebuild: it changes how already-published history is read back and nothing else. Live bucket-based selection, proposals and validation keep working at this head.

Approach

Historical message retrieval addresses messages by compact count. MessageStore.getL1ToL2MessagesBetweenLeafCounts reads the exact [start, end) range from the indexed message log inside one store transaction together with the synced total, and fails with a typed InboxMessageRangeNotSyncedError when the range is invalid, reaches past the synced tip or has a hole, instead of returning a partial or empty result. World-state applies the range block by block, so a range the archiver cannot serve yet stops the sync without discarding the blocks already applied, and a missing non-genesis parent fails loudly. A fresh-world-state regression replays every published block after a same-message bucket merge over unchanged leaves.

The validator derives a checkpoint's consumed bundle from the parent checkpoint's count and the last block's count alone, with no bucket lookup at either end; whether the final position is a live bucket end remains L1's publication rule, still checked by the existing censorship guard. A parent block that is unavailable locally now yields block_fetch_error (unvalidated, not slashable) rather than an empty bundle that would fail the rolling-hash recomputation as a checkpoint_header_mismatch offense.

The kv-store mapRange helper dropped a 0n bound because zero is falsy; the count-addressed reads pass compact indices through it, so the fix is carried here.

As an additive extension for the next PRs of the stack (no consumer is wired yet beyond tests), the message source grows position-addressed reads, colocated with the L1ToL2MessageSource interface in stdlib and exposed on the archiver data source and RPC schema: InboxMessagePosition, InboxMessageRange, getMessagePosition(totalMessageCount), getSyncedMessagePosition() and getL1ToL2MessageRange(start, end). The range read returns the messages and both bounding positions from one store transaction, empty ranges included, and raises the same typed availability error when the range or its starting position is unavailable. The shared archiver mocks now treat indexed leaves as the primary fixture, derive positions from them, and enforce the archiver's range contract (typed availability error, one version of the log per read).

No store schema change (ARCHIVER_DB_VERSION untouched): positions are read from the existing per-index message records.

Deferred to the node cutover PR (P3): the plan's "bucket fixtures only in L1 contract tests" migration. The bucket fixtures in the shared mocks still serve the live bucket-based paths this PR leaves untouched; they go when those paths do.

Stacked on project/fast-inbox

Part of A-1928

Fixes A-1924

Supersedes #25408

…5314)

This is the bottom of the Fast Inbox stack: it targets
`project/fast-inbox` directly and is independently mergeable — nothing
in it depends on the inbox work above it.

## Context

Every field of `RollupConfig` — `vkTreeRoot`, `protocolContractsHash`,
`version`, `feeAsset`, `feeAssetPortal`, `epochProofVerifier`, `inbox`,
`outbox` — is written exactly once in the Rollup's constructor and has
no setter anywhere in `src/`. They were nonetheless kept in storage, so
every read paid a cold `SLOAD`. `propose` paid one,
`submitEpochRootProof` paid six.

## Approach

Move all eight into immutables and drop `config` from `RollupStore`.

Libraries cannot read a contract's immutables, and the `*ExtLib`
libraries here are `external` (delegatecalled), so they cannot either.
The values are assembled into a memory `RollupConfig` by
`RollupCore._getRollupConfig()` and threaded down as parameters: the
full struct into the epoch-proof path, the `IInbox` into propose, and
the fee asset into the reward claims. Propose needed its `IInbox`
bundled with the existing `checkBlob` flag into a `ProposeConfig` struct
— a seventh scalar parameter pushed `ProposeLib.propose` over the stack
limit, and a memory struct costs one slot instead of two.

`config` was the last member of `RollupStore`, so `tips`, `archives` and
`tempCheckpointLogs` keep slots 0–2 and every raw-slot consumer of
`keccak256("aztec.stf.storage")` is unaffected — all of them use offsets
≤ +2. The two that used `+3`/`+4` were `RollupContract.getVkTreeRoot`
and `getProtocolContractsHash` in the TS client, which now call the
contract getters that `IRollup` has exposed since #22563.

The second commit is bytecode budget, not gas. Each immutable read
inlines a 32-byte push, which grew `Rollup`'s runtime code to within 148
bytes of the EIP-170 limit. `Rollup.validateHeaderWithAttestations` was
decoding seven parameters, building a `ValidateHeaderArgs` (which embeds
a full `ProposedHeader`) in memory, resolving the mana min fee through
two delegatecall hops, and re-encoding four arguments — all in the
Rollup's own runtime code. Forwarding the parameters straight through
and assembling the struct in `RollupOperationsExtLib` frees 629 bytes.
The fee value is unchanged: `RewardExtLib.summedMinFee` and
`.getManaMinFeeComponentsAt` are one-line forwarders to the `FeeLib` /
`ProposeLib` functions the ExtLib now calls directly.

## Gas

| Benchmark | Before | After | Δ |
|---|---|---|---|
| `propose` (no validators) | 199,366 | 197,433 | **−1,933** |
| `submitEpochRootProof` (no validators) | 991,032 | 980,225 |
**−10,807** |
| `propose` (100 validators) | 327,774 | 325,847 | **−1,927** |
| `submitEpochRootProof` (100 validators) | 1,572,081 | 1,561,291 |
**−10,790** |
| `aggregate3` (100 validators) | 376,665 | 374,738 | **−1,927** |

The config getters lose their cold `SLOAD` outright — `getInbox` 2,543 →
878, `getVersion` 1,447 → 852, `getOutbox` 2,521 → 856. A handful of
unrelated views move by 22–44 gas as the Rollup's selector dispatch
shifts. Deployment also drops eight `SSTORE`s.

`Rollup` runtime bytecode ends at 23,799 against the 24,576 limit — 777
bytes of margin, against 886 before this change.

Note on the regenerated `gas_report.json`: call counts on a few entries
drop by 36 because the three tests that fail only under
`FORGE_GAS_REPORT` (`testExtraBlobs`, `testRevertInvalidCoinbase`,
`testRevertInvalidTimestamp` — all failing identically on the base
commit) abort at a slightly different point. Per-call gas for those
entries is unchanged.

## Node compatibility

Node binaries already in the field read `vkTreeRoot` and
`protocolContractsHash` from raw slots `+3`/`+4`. Against a rollup
deployed from this branch those slots are zero, so such a node's
`waitForCompatibleRollup` reports a VK mismatch and sits in standby.
#25313 lands the getter-based read on the v5 line so binaries cut from
it work against a rollup deployed from either version; it does nothing
for binaries already released, so this needs sequencing against any node
rollout.
…o 4096 (#25305)

Stacked on #25314.

Opening an Inbox bucket whose ring slot still holds an entry the proven
chain has not consumed now reverts, and the bucket ring grows from 1024
to 4096.

## Context

The Inbox stores rolling-hash buckets in a ring keyed `bucketSeq %
BUCKET_RING_SIZE`, so opening bucket `n` overwrote bucket `n −
RING_SIZE` unconditionally. If L2 stops consuming for long enough
(outage, or a spam burst forcing rollover buckets), unconsumed buckets
get destroyed — and worse: every retained bucket ends up past the
1024-message checkpoint cap while every under-cap bucket has fallen out
of the `getBucket` window, so no bucket is proposable and the pending
chain deadlocks permanently (pinned by `InboxRingDeadlock.t.sol`).

## Approach

- **Revert-on-overwrite anchored to proven consumption.**
`_absorbIntoBucket` refuses to open bucket `n ≥ RING_SIZE` unless
`provenConsumedBucketSeq ≥ n − RING_SIZE`, reverting
`Inbox__WouldOverwriteUnconsumedBucket(evicted)`. Sends halt instead of
destroying messages, and resume automatically once proving catches up.
Anchoring to the *proven* tip is prune-immune (it never rewinds) and
fail-closed (the record can only lag). No governance escape hatch — an
override would recreate the deadlock.
- **`Inbox.markProvenConsumed(uint64)`**: ROLLUP-only, monotonic, never
ahead of `currentBucketSeq`; packed in the same slot as
`currentBucketSeq` so the check reads warm. Called from the single
proven-tip-advancing branch of `submitEpochRootProof`, through the
`RollupConfig` that #25314 threads down as a memory struct.
- **`TempCheckpointLog.inboxConsumedBucket` re-added** (it was dropped
as write-only before this consumer existed). Propose stores the
validated `bucketHint`; the proven-tip advance reads it back. It packs
into the existing `slotNumber`/`inboxMsgTotal` word (offsets 0/4/12,
struct still 8 words), so no extra storage. The TS state-override
encoder follows.
- **Ring size 1024 → 4096.** The size is an economics knob, not a safety
property: a forced rollover bucket costs ~2.2M gas, so exhausting
headroom takes ~62 continuously-owned full 36M-gas L1 blocks (~12.5 min,
~2.2B gas) at 1024 vs ~250 blocks (~50 min, ~9B gas) at 4096, and the
honest proving-lag floor (~385 buckets) gets ~10× margin. Ring size
bounds retention only: +192 KiB of eventual state, no per-send or deploy
gas. `MIN_BUCKET_RING_SIZE` stays 512.
- **Observability**: `getRingHeadroom()` (bucket openings left before
sends halt) and `getProvenConsumedBucketSeq()`.

Gas: bucket-opening `sendL2Message` +41 pre-wrap, +393 on a wrapping
open (steady state); absorb-into-open-bucket unchanged.
`submitEpochRootProof` pays the Inbox write (a cold call plus the
Inbox's own SLOAD/SSTORE — the Inbox address itself is an immutable
after #25314) only when the epoch consumed messages — equal start/end
rolling hashes skip it — so the message-less benchmark scenario reads
980,225 → 980,266, while the message-consuming `gas_report` fixtures pay
it in full (mean 366,242 → 376,201). `propose` 197,433 → 197,740 (the
temp-log read on proof is warm; the consumed-bucket field shares the
slot-number word).

## How to review

Source: `Inbox.sol` (check + `markProvenConsumed` + headroom),
`EpochProofLib.sol` (call site), `CheckpointLog.sol` / `ProposeLib.sol`
/ `STFLib.sol` (temp-log field). Tests: `InboxOverwriteProtection.t.sol`
(wrap/boundary/resume/authority/headroom/batch atomicity/fuzz from the
ring wall) and `rollup/InboxRingDeadlock.t.sol` (unprotected ring
deadlocks, protected one keeps a proposable cursor); `Rollup.t.sol`
covers proven-tip advance, pending-only propose not unlocking, and
prune/re-propose/prove. Full forge suite 912/0/3.

Unrelated, pre-existing: `./bootstrap.sh gas_report` exits non-zero
because three `RollupTest` cases fail only under
`FORGE_GAS_REPORT=true`; identical on the base.

Fixes A-1390
Fixes A-1757
…g its txs (#25323)

Stacked on #25305

## The bug

`CheckpointBuilder.buildBlock` ran the public processor on a world-state
fork that did not yet contain the block's own streaming L1-to-L2
messages; they were appended afterwards inside
`LightweightCheckpointBuilder.addBlock`. The prover node appends them to
its fork **before** re-executing the block (`checkpoint-prover.ts`
`createFork`), and the block-root circuit pins each tx's
`l1_to_l2_tree_snapshot` to the **post**-append root
(`block_root_rollup_inputs_validator.nr`, `block_constant_data.nr`).

So a public tx that consumes a message inserted by its own block
reverted at proposal time (the leaf was not there yet) and succeeded at
proving time (the leaf was there). The tx effects differ, the prover
throws `Block header mismatch` on an already-attested and published
block, and the epoch cannot be proven and gets pruned. Proposer and
validators both use `buildBlock`, so they agreed with each other and
nothing caught it before the prover.

Fixes
[A-1768](https://linear.app/aztec-labs/issue/A-1768/block-builder-runs-the-avm-before-inserting-the-blocks-l1-to-l2)

## Before and after

```mermaid
sequenceDiagram
    participant P as Proposer / validators<br/>(CheckpointBuilder.buildBlock)
    participant F as World-state fork
    participant V as Prover<br/>(checkpoint-prover.ts)

    rect rgb(255, 236, 236)
    note over P,V: Before
    P->>F: ForkCheckpoint.new
    P->>F: processor.process(txs) — L1-to-L2 tree has no messages yet
    F-->>P: consume tx REVERTED (leaf missing)
    P->>F: addBlock → append messages (too late)
    V->>F: append messages
    V->>F: re-execute txs — leaf present
    F-->>V: consume tx SUCCESS
    note over P,V: tx effects differ → "Block header mismatch" → epoch unprovable, pruned
    end

    rect rgb(232, 247, 236)
    note over P,V: After
    P->>F: ForkCheckpoint.new
    P->>F: append messages
    P->>F: processor.process(txs) — post-append tree
    F-->>P: consume tx SUCCESS
    P->>F: sealBlock (messages already in fork)
    V->>F: append messages, re-execute txs
    F-->>V: consume tx SUCCESS — headers match
    end
```

The circuits already pinned each tx's `l1_to_l2_tree_snapshot` to the
post-append root, so only the proposer/validator side moves; the prover
was already right. Appending inside the `ForkCheckpoint` means a failed
block rolls the leaves back with the tx effects.

## The fix

- `CheckpointBuilder.buildBlock` now appends the block's L1-to-L2
messages to the fork right after `ForkCheckpoint.new` and before
`processor.process`, so the AVM reads the same post-append tree the
prover and the circuits use. Appending inside the fork checkpoint means
a failed block (`InsufficientValidTxsError`, processor throw) rolls the
leaves back together with the tx effects, so the retry in the next
sub-slot does not double-insert.
- `LightweightCheckpointBuilder.addBlock` is split into two public
methods over one private implementation: `sealBlock` (the caller already
applied the block's tx effects and messages to the fork; used by
`buildBlock`) and `applyEffectsAndSealBlock` (the builder inserts both,
then seals; used by tests). The former `insertTxsEffects` flag and the
message append were always toggled together, so a single
`applyStateUpdates` switch replaces them. Either way the messages are
accumulated into the checkpoint's message list, so `inboxRollingHash` is
unchanged.

Block bodies, headers, leaf indices and the checkpoint
`inboxRollingHash` are identical before and after; only the tree the AVM
reads *during* execution changes. No circuit, L1, p2p, or serialization
changes. The prover node, the orchestrator, and
`node_public_calls_simulator.ts` were already correct and are untouched.
Proposer and validators switch to the post-append view together, so
there is no protocol-level version skew.

## Tests

Red/green, real world state (no mock call-order assertions):

- `validator-client/src/checkpoint_builder.test.ts`, new `buildBlock
with streaming L1-to-L2 messages (real world state)`:
- *the block's messages are in the fork when the public processor runs*
— fails on the base branch with `Expected: 3n / Received: 0n` (the
L1-to-L2 tree was empty while txs executed); passes with the fix. Also
asserts the header's `nextAvailableLeafIndex` and that the tree holds
the messages exactly once (guards against `sealBlock` re-appending).
- *a failed block rolls its messages back* and *a block below
minValidTxs rolls its messages back* — pin the "append inside the
ForkCheckpoint" requirement.
  - *an empty or absent message list leaves the tree untouched*.
- `prover-client/src/light/lightweight_checkpoint_builder.test.ts`:
*sealBlock reuses leaves already in the fork and produces the same block
as applyEffectsAndSealBlock* — same header, same tree size and same
`inboxRollingHash` as the default path.
- `end-to-end/.../streaming_inbox.test.ts`: new *consumes a message in
the same block that inserts it* — times a public consume tx to the
instant the message's bucket becomes lag-eligible (the node simulates
against the messages predicted for the next block so it enters the
pool), retries with a fresh message if a block slips in between, and
asserts the consume `SUCCESS` when it lands in the inserting block. On
the base branch that receipt is `REVERTED` (`Tried to consume
nonexistent L1-to-L2 message`). Existing test 4 is kept as is.

Surrounding suites (`checkpoint_builder.test.ts`,
`lightweight_checkpoint_builder.test.ts`, `proposal_handler.test.ts`,
`checkpoint_proposal_job.test.ts`) pass.
…k proposal for an unsynced inbox bucket (#25342)

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 `undefined` and the proposal was
rejected on the spot with `bucket_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 the `bucket_unknown` reason (no new reason string to teach the
slashing/`invalid` classification maps) plus a `warn` carrying `reason:
'bucket_sync_timeout'` and `waitedMs`.
- **`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 a `bucket_hash_mismatch_after_sync`
warn. No loop: a persistent mismatch will not resolve by waiting.
- **Everything else rejects immediately** (`bucket_too_new`,
`bucket_moves_backwards`, the caps, `parent_bucket_unresolved`, a
proposal with no `bucketRef`, and any arrival after the deadline has
already passed — in that last case the archiver is not poked at all).
- The wait re-runs `checkStreamingBlockMetadata` rather than just the
bucket lookup, so it also covers the case where "the block before the
checkpoint's first block has not synced" maps to `bucket_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 private `awaitLocalSync(slot, what, resolve)`
that owns the deadline computation, the past-deadline short-circuit, the
forced sync per attempt, and the `TimeoutError` → `undefined`
conversion. Callers keep their own log level and their own timeout
fallback value.

Migrated (behavior preserved exactly, `proposal_handler.test.ts` green
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 its `warn`, so
the mapping is exact.

Not migrated:

- The checkpoint proposal's last-block sync wait in
`validateCheckpointProposal`. It has no past-deadline guard, and
`retryUntil` runs `fn` once *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 to `undefined` in that
case, which the new bucket wait needs, so migrating this site would flip
that case to `last_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 = 2` per `(slot,
indexWithinCheckpoint)`, and gossip validation rejects
`indexWithinCheckpoint >= 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 a `RunningPromise.trigger` that
coalesces concurrent requests into a single sync run (one L1 head query
when nothing changed). That is small, so the cheap
`indexWithinCheckpoint` validation 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. `retryUntil`
converts 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:

```
✕ attests once the referenced bucket shows up on a later archiver sync
    expect(result.isValid).toBe(true) → Received: false   (rejected with bucket_unknown)
✕ rejects with bucket_unknown when the bucket never syncs, no earlier than the deadline
    expect(elapsedMs).toBeGreaterThanOrEqual(2000) → Received: 3   (no wait at all)
✓ rejects immediately without syncing when the attestation deadline has already passed
✓ rejects immediately without syncing when the proposal carries no bucket reference
✕ rejects a hash mismatch that survives one forced sync, without looping
✕ attests when the forced sync replaces our stale bucket with the proposed one
Tests: 4 failed, 2 passed
```

After:

```
Tests: 51 passed, 51 total   (proposal_handler.test.ts: 45 pre-existing + 6 new)
```

Full gate, all from `yarn-project`:

- `yarn build` — clean
- `yarn format validator-client`, `yarn lint validator-client` — clean
- `yarn workspace @aztec/validator-client test` — 274 passed, 3 skipped,
277 total (10 suites)

The existing `streaming_inbox_checks.test.ts` case "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 in `streaming_inbox_checks.ts` and
`checkStreamingBlockMetadata` that 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 stall
`RunningPromise.trigger()`) plus a committee-member selection dance for
minutes of wall clock per case.
… inbox bucket (#25350)

Stacked on #25342

Groundwork, no behaviour change. The archiver already knew which L1
block opened each Inbox bucket — every
`MessageSent` log carries the block number and hash — but only the
number reached the persisted bucket snapshot,
and neither field reached the `InboxBucket` type the archiver serves to
the sequencer and the validator.

`InboxBucket` now carries `l1BlockNumber` and `l1BlockHash` (schema
included, so they survive the JSON-RPC hop
for out-of-process archivers), and `BucketSnapshot` persists the hash
alongside the number. That is what lets the
next PR ask "is the L1 block that opened this bucket still canonical,
and does it have a child?" for the
descendant-confirmed eligibility rule, and what lets the archiver in the
message-only stack compare a stored
bucket against the live chain.

Two smaller things came with it:

- The snapshot records the block a bucket was *opened* in, taken from
its first message. The Inbox keys buckets
by `block.timestamp`, so on a chain where consecutive blocks can share a
timestamp (anvil with manual mining)
one bucket can span several L1 blocks; that is logged at `warn` and the
opening block is kept. Production
Ethereum timestamps are strictly increasing, so a bucket there never
spans more than one block. The cost of
recording only the opening block is that a reorg touching only a later
co-timestamped block of the same bucket
  is invisible to the hash; both fields say so.
- The archiver's message test doubles now derive a bucket's L1 block
from its L1 timestamp rather than from its
sequence number, with a deterministic, distinct hash per block. Rollover
siblings that share a timestamp share
an L1 block, as on chain, and code that confused a bucket sequence with
an L1 block number now fails its tests.

`InboxBucketRef`, the reference carried on block proposals, is
deliberately unchanged: a validator reads the
bucket's L1 block from its own archiver and never from the proposer.

**Breaking store format.** The bucket snapshot gains a field with no
versioning or migration, so an existing
archiver database will not deserialize. That is fine here — this line
has never shipped, and dev databases
resync from L1.

Gates: full `yarn build`, `yarn format` and `yarn lint` on the touched
packages. Tests: `archiver` 575/575,
`stdlib` `src/messaging` + `src/interfaces/archiver.test.ts` 118/118,
`validator-client`, `sequencer-client`
(including the anvil-backed `l1_publisher.integration.test.ts`),
`aztec-node`
`node_public_calls_simulator.test.ts`, `world-state`
`integration.test.ts`. No e2e.

Fixes A-1878
…onical descendant (#25351)

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 `H` — `N` 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
…count

The checkpoint validator resolved the proposal's final leaf count to a bucket of the current partition before
reading the consumed range, and derived an empty bundle when that lookup missed. Both bounds are counts committed
by block headers, so the range is read by count alone; whether the final position is a live bucket end stays the
publication rule enforced by L1 and the existing censorship guard. A parent block that is unavailable locally is
reported as a fetch error instead of an empty bundle, which would have failed the rolling-hash recomputation and
been classified as a slashable header mismatch.
mapRange dropped a 0n start or end because zero is falsy, turning an exclusive end of zero into an unbounded range.
…age source

Adds InboxMessagePosition and InboxMessageRange next to the L1ToL2MessageSource interface, with
getMessagePosition, getSyncedMessagePosition and getL1ToL2MessageRange on the message store, the archiver data
source, its RPC schema and the shared mocks. The range read returns the messages and both bounding positions from
one store transaction, empty ranges included, and raises the typed availability error when the range or its
starting position is unavailable. Positions are read from the existing per-index message records; no schema change.
@spalladino spalladino added the ci-draft Run CI on draft PRs. label Sep 5, 2026
…mock range read

getMessagePosition(0) handed out a shared module-level object through a mutable type, so a caller's mutation leaked
into later reads. The mock message source yielded between capturing a range's leaves and computing its hashes and
never checked the synced tip, so it could pair one version of the log with another's hash and resolve ranges the
archiver rejects. Also corrects the validator JSDoc about what the censorship guard establishes, retires the
obsolete bucket comment in MockPrefilledArchiver, and covers zero-position mutation, a concurrent removal during a
range read, and replay resumption after a failed range.
…es by leaf count

The world-state synchronizer now reads each synced block's consumed messages by leaf count, but the
publisher integration test's block-source stub still only forwarded the removed bucket lookups, so the
first block was never applied to world state and every later block failed to fork from it.
@spalladino
spalladino removed this pull request from stack #25417 September 9, 2026 21:16
@spalladino
spalladino changed the base branch from project/fast-inbox to next September 9, 2026 21:16
@spalladino
spalladino added this pull request to stack #25441 September 9, 2026 21:16
@spalladino
spalladino removed this pull request from stack #25441 September 10, 2026 11:49
@spalladino
spalladino added this pull request to stack #25448 September 10, 2026 11:49
@spalladino
spalladino removed this pull request from stack #25448 September 10, 2026 12:09
@spalladino
spalladino added this pull request to stack #25449 September 10, 2026 12:11
@spalladino
spalladino removed this pull request from stack #25449 September 10, 2026 13:04
@spalladino
spalladino added this pull request to stack #25451 September 10, 2026 13:05
@spalladino
spalladino removed this pull request from stack #25451 September 10, 2026 13:53
@spalladino
spalladino added this pull request to stack #25452 September 10, 2026 13:54
@spalladino spalladino removed the ci-draft Run CI on draft PRs. label Sep 10, 2026
@spalladino
spalladino removed this pull request from stack #25452 September 10, 2026 14:31
@spalladino
spalladino added this pull request to stack #25453 September 10, 2026 14:32
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.

1 participant