fix(fast-inbox): replay published blocks by message count - #25413
Draft
spalladino wants to merge 12 commits into
Draft
fix(fast-inbox): replay published blocks by message count#25413spalladino wants to merge 12 commits into
spalladino wants to merge 12 commits into
Conversation
…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.
…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.
This was referenced Sep 5, 2026
…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
removed this pull request from stack #25417
September 9, 2026 21:16
spalladino
added this pull request to stack #25441
September 9, 2026 21:16
spalladino
removed this pull request from stack #25441
September 10, 2026 11:49
spalladino
added this pull request to stack #25448
September 10, 2026 11:49
spalladino
removed this pull request from stack #25448
September 10, 2026 12:09
spalladino
added this pull request to stack #25449
September 10, 2026 12:11
spalladino
removed this pull request from stack #25449
September 10, 2026 13:04
spalladino
added this pull request to stack #25451
September 10, 2026 13:05
spalladino
removed this pull request from stack #25451
September 10, 2026 13:53
spalladino
added this pull request to stack #25452
September 10, 2026 13:54
spalladino
removed this pull request from stack #25452
September 10, 2026 14:31
spalladino
added this pull request to stack #25453
September 10, 2026 14:32
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.
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.getL1ToL2MessagesBetweenLeafCountsreads the exact[start, end)range from the indexed message log inside one store transaction together with the synced total, and fails with a typedInboxMessageRangeNotSyncedErrorwhen 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 acheckpoint_header_mismatchoffense.The
kv-storemapRangehelper dropped a0nbound 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
L1ToL2MessageSourceinterface in stdlib and exposed on the archiver data source and RPC schema:InboxMessagePosition,InboxMessageRange,getMessagePosition(totalMessageCount),getSyncedMessagePosition()andgetL1ToL2MessageRange(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_VERSIONuntouched): 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