Skip to content

feat(fast-inbox): cut the node over to the bucketless message log - #25415

Draft
spalladino wants to merge 24 commits into
spl/fi2-l1-endpoint-resolver-preflightfrom
spl/fi2-bucketless-node-cutover
Draft

feat(fast-inbox): cut the node over to the bucketless message log#25415
spalladino wants to merge 24 commits into
spl/fi2-l1-endpoint-resolver-preflightfrom
spl/fi2-bucketless-node-cutover

Conversation

@spalladino

@spalladino spalladino commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Context

Third PR of the bucketless Fast Inbox replacement stack (yarn-project/tmp/new-inbox/plans/bucketless-message-store-stack-rebuild-plan.md, sections "Message storage and interfaces", "Recovery baseline and bounded changes", "Ordinary blocks, final-block selection and signatures", "Validator consistency", "Node-side final-message target selection" and "Parent-bound preflight"). P1 (#25413) made published-block replay count-addressed; P2 (#25414) added the L1 endpoint resolver and the integrated header-and-Inbox preflight. This PR is the node cutover: the archiver, sequencer, validator and node simulator stop reasoning about L1's bucket partition. Only an ordered message log is persisted, blocks end at arbitrary message prefixes, and a live bucket end is read from L1 only where the checkpoint's final position depends on it.

Approach

Proposals carry a message prefix, not a bucket. InboxBucketRef becomes InboxMessagePrefixRef (the rolling hash alone; the count is the header's L1-to-L2 leaf count). L2BlockSink.addBlock(block, inboxPrefixRef) requires the reference on every production call, and the archiver checks it inside the same store transaction that writes the block: parent continuity, no consumption rewind, and the range [parentCount, blockCount) read in one snapshot whose ending hash must equal the signed reference (InboxPrefixNotSyncedError / InboxPrefixMismatchError). Validators run the same content checks against their own view: a reference the local view cannot confirm (inbox_prefix_unavailable) or disagrees with (inbox_prefix_mismatch) is retried through a bounded sync and never becomes an offense; a re-execution state_mismatch is demoted to a local disagreement when the local prefix at the block's count moved between the bundle read and re-execution. Checkpoint validation is content-only: the consumed bundle is derived by count and the header's inboxRollingHash compared with the local prefix; no bucket or L1 endpoint query is made, so a content-valid but not-yet-publishable endpoint can pass the committee and fail the proposer's preflight instead.

Greedy blocks, an endpoint lookup only where the cap makes it necessary. inbox_message_selection.ts holds the pure selection, decided again on every block attempt from the cursor and the current local view; nothing about the endpoint is retained between blocks. A block's greedy end is min(local, cursor + 256, start + 1024). It consults L1 only when it is the checkpoint's final block, whose position must be a live bucket end, or when that greedy end would pass start + 768 (the cap minus one bucket, so a further step could leave the last legal endpoint behind). One Inbox.getBucketAtOrBeforeTotal call resolves the live bucket end at or below min(local, start + 1024) (a non-final block is bounded by the whole checkpoint so a mandatory bucket beyond its own reach is not stranded by a nearer one; the final block adds its own cursor + 256), and getL1ToL2MessageRange authenticates the range from the cursor to it against the returned hash. A non-final block then ends at the further of min(cursor + 256, endpoint) and the safe local step min(local, cursor + 256, start + 768), so consulting L1 never consumes less than staying below the threshold would have, and such a block may legitimately end inside a bucket; the next attempt reaches the endpoint within one block. The final block ends on the endpoint. The signed prefix hash is always the one at the block's own end, re-read from the local log. For live ends 100/356/612/868/1124 a full backlog is consumed as 256, 512, 768, 868, never 1024; from 700 with local = 1300 and ends 256/512/700/956/1000/1256/1300 the blocks take 256, 256, 188, 256, 44 and finish at 1000. If the sub-slot schedule runs out with the cursor past the checkpoint start, the job resolves the endpoint once more and, unless the cursor already sits on it, builds one extra transaction-less block to reach it, bounded by the proposer timetable's last block build time and, past that, by the checkpoint proposal receive deadline less one propagation budget. A final block that cannot land on a live endpoint (inbox_completion_unresolved), a local prefix that changed under signed blocks (inbox_prefix_reorged) or a range that became unavailable abandons the checkpoint without a conflicting signature; on a non-final block an unresolved endpoint means taking the safe local step. Censorship is left to L1's rules in the preflight and propose.

Publication preflight against L1's current state. SequencerPublisher.validateCheckpointHeader becomes validateCheckpointHeaderAndInbox(header, { expectedTotal, expectedParentCheckpointNumber }, plan?), returning the bucket hint. It runs before gossip with the build-time simulation plan (synthetic pending parent and proven tip) and again after waitForValidParentCheckpointOnL1 with the bundle-preceding operations and, while a prune is due at the target slot, the build's proven pin (see the deviation below), so the send uses a hint resolved against the parent L1 holds at the target slot on the assumption that any due epoch proof lands (a conditional verdict; propose remains the final check); a rejection there is publication_preflight_failed. A checkpoint whose last block the archiver no longer holds under the built hash is not published (checkpoint_blocks_pruned). The node's public call simulator predicts the next block's messages with the same local selection held to the start + 768 threshold, a lower bound: above it the proposer's end depends on an L1 read the simulator does not make.

Message log and recovery. The archiver stores { index, leaf, l1BlockNumber, inboxRollingHash } only; bucket snapshots, the timestamp index, per-message L1 block hashes and the synthetic genesis bucket are deleted along with every bucket read on L1ToL2MessageSource and the archiver RPC schema, and ARCHIVER_DB_VERSION moves to 10 (no migration). InboxMessageSynchronizer captures the L1 head, reads the Inbox position at it, commits each forward batch with the scanned cursor covering it and compares the final position with the captured one; only that comparison writes a syncpoint (see finding 9 below). On disagreement, an exact shorter prefix is truncated by hash; otherwise recovery is the v5-style conservative rollback: a backward anchor search spends at most 32 by-hash event lookups per pass (each in a ±5-block window bounded above by the captured head; a miss only moves to an older candidate; an inverted window is a miss, not a query), and once the newest message still found on L1 is known, one transaction (rollbackMessagesAndPruneProposedBlocks) re-checks the kept prefix's hash, deletes every stored message after the anchor, prunes every uncheckpointed block that consumed a deleted message, moves the scanned cursor to the block before the anchor's and clears the syncpoint. Ordinary forward ingestion then refills the log; there is no replay-and-compare phase. No anchor within reach means rolling back to the deployment block and pruning every proposed block: an accepted liveness cost for a simpler recovery. Budget exhaustion is pending work: the iteration still processes checkpoints but does not advertise the head as synced, and a merely advancing latest does not reset a search pinned to its head. A rollback reaching below the checkpointed tip leaves published blocks to checkpoint sync and withholds getProposedCheckpointData until the checkpointed tip's inboxRollingHash agrees with the log again. Log ranges the provider rejects are bisected at block boundaries; a single unavailable block is reported as a failure, never as an absence of messages.

Removed with this cutover. The bucket selector and the L1 confirmation eligibility rule (#25355's toggle and tracker), the bucket canonicality check and bucket-level rollback (#25354, #25398), the lost-intermediate-boundary invalidation (a block whose end boundary a merge erased stays valid; only the checkpoint's final end must be live), isInboxConsumptionSufficient / getInboxCutoffTimestamp, MessageSentLog.l1BlockTimestamp and its per-log getBlock calls. MIN_BLOCKS_FOR_INBOX_CATCHUP is now ceil(1024 / 256) = 4.

Review fixes (designer review, findings 1-9). A message syncpoint certifies the whole stored prefix, not the last fetched batch: normal ingestion re-verifies that the persisted syncpoint's block is still canonical before fetching forward from it (an empty batch after a reorg below it cannot move the syncpoint over a stale tail), and a batch reaching the captured head is certified only when the log's position after it equals the Inbox's position there, with the head's canonicality rechecked immediately before any rollback mutation. Checkpoint reconciliation (rollup status, proven update, unwind) also runs at a same-height or shorter replacement head without any forward log range, whenever the checkpointed tip disagrees with the message log or the latest checkpoint's L1 block was replaced within the head's reach, so a speculation gate raised by message recovery can lift before L1 grows. The validator validates a checkpoint proposal against one snapshot of its slot's blocks, keyed by the signed archive: a read without it, or one that does not chain, is retried as the non-punitive last_block_not_found (no_blocks_for_slot is unreachable and removed); last_block_archive_mismatch remains slashable but only for a present last block followed by more signed blocks of the same slot. Both integrated preflights are bounded by their phase deadlines (attestation deadline before gossip, last L1 block of the target slot before publication), and a verdict arriving after the deadline or an interrupt is not signed or enqueued on (header_validation_timeout / publication_preflight_timeout). The publication preflight also emits checkpoint-publish-failed when it refuses a checkpoint. Finally, completeness of a log response is never inferred from block identity: the archiver persists a scanned cursor (which L1 blocks were queried, where fetching resumes) separately from the message syncpoint (a position found equal to the Inbox's own position there, which certifies the whole stored log). Intermediate ingestion batches move only the cursor and clear the syncpoint, so an incomplete log response for a canonical block can no longer certify a short log and let a later head at that block take the same-head shortcut. Authenticating every intermediate batch against Inbox.getState at its end block was rejected as the alternative: contract reads more than 128 L1 blocks old are refused (BlockTagTooOldError), so catch-up sync on a non-archive L1 node could not do it. Three further paths that certified an uncompared position are closed with it: syncFromL1's same-hash shortcut now also requires the log to be certified at that head with no recovery in progress (an uncertified batch stored since the last completed iteration used to survive a provider view returning to that head), a missing syncpoint is no longer read as one at the deployment block, and Archiver.rollbackTo commits its message trim with a scanned cursor and no syncpoint in one transaction, since trimming by stored L1 block hints is not a comparison with the Inbox.

Deviations from the plan (accepted in review, recorded here)

  • Calldata struct ABI for the preflight. validateCheckpointHeaderAndInbox takes one CheckpointPreflightArgs calldata struct instead of flat arguments (ten flat arguments were "stack too deep", and the Rollup has 115 B of bytecode headroom). No semantic change: ring wrap, oldest bounds and effective-parent parity are unchanged.
  • MIN_BLOCKS_FOR_INBOX_CATCHUP 7 → 4. Four 256-message blocks reach the 1024-message checkpoint cap under arbitrary-prefix selection; the consensus-config test derives the floor from a 36s slot with 6s blocks.
  • Consistent fixture counts instead of an optional signed reference. Random block fixtures get consistent L1-to-L2 leaf counts rather than an optional prefix reference, keeping the production invariant that every proposed block carries one.
  • Abort when the endpoint vanishes under the final block. A live bucket end that disappears between a non-final block's lookup and the final block (or a range that becomes unavailable) abandons the checkpoint rather than hunting for another. A conservative liveness trade-off, not a safety regression: nothing conflicting is signed.
  • Stale-height finality deferral unchanged. See the accepted limitations below.
  • The publication preflight keeps the proven-tip pin while a prune is due at the target slot (accepted by the designer in review, pending Santiago's confirmation). The plan says the publication preflight should not assume a proof will land; this overrides that wording to preserve the sequencer's existing boundary behaviour, so the pre-publication verdict is conditional on the epoch proof landing by the target slot rather than a demonstration against real state. The on-chain propose remains authoritative. CI's proof_boundary case showed why the pre-existing sequencer behaviour has to win: the base sequencer builds the boundary checkpoint assuming the epoch proof lands by the target slot, and the actual propose is what validates that assumption. Dropping the pin let STFLib.getEffectivePendingCheckpointNumber collapse the landed parent to the proven tip in simulation (Rollup__UnexpectedParentCheckpoint(3,0)) and abandon a slot the send would have won. getPublicationSimulationOverridesPlan keeps proven pinned while isPruneDueAtSlot(targetSlot) holds; the unlanded-parent overrides (archive, fee header, parent cell) are still dropped.
  • Finding-5 guard. Reconciliation at a non-advancing head runs when the checkpointed tip disagrees with the message log or the latest checkpoint's L1 block (within the head) changed hash. A view that merely stops short of that block, with no established disagreement, waits for L1 to grow, so a lagging provider view does not unwind published state.

Simplifications after review (2026-09-07)

  • Recovery no longer replays and compares. The first cut of this PR replayed canonical messages forward from the anchor and mutated the store only on an actual content difference, so a placement-only reorg preserved every proposed block. That phase is gone in favour of the rollback-and-refetch described above, which is the tried v5 shape. A placement-only reorg whose anchor is the newest stored message is still recognised as synced without any mutation; one that moves the tail now costs a refetch of that tail and the proposed blocks that consumed it. The pre-simplification code is kept on spl/fi2-bucketless-node-cutover-backup-20260907 in case it has to come back.
  • No retained completion target. The first cut selected a completion target once per checkpoint, consumed toward it in 256-message steps and froze there; the upper bound depended on the remaining scheduled sub-slots and a target the remaining blocks could not reach was a distinct inbox_completion_unreachable abort. Selection is now stateless per block as described above, the freeze and the remainingScheduledBlocks bound are gone, a bucket that closes later in the slot can still be taken, and the forced tail block replaces the reliance on the timetable always granting a final attempt. The pre-simplification code is kept on spl/fi2-bucketless-node-cutover-backup-after-recovery-20260907.

Accepted limitations

  • The inherited finalized-height anchor shortcut is retained: a message re-mined above the finalized height whose old height was below it can be trusted wrongly. Explicitly deferred by the plan; not claimed fixed.
  • The anchor search is process-local; a restart begins it again from the stored log (correct, possibly slow for deep reorgs). A provider that answers the by-hash lookups with empty results walks the rollback to the finality marker or the deployment block and prunes every proposed block.
  • The publication preflight simulates L1; the actual propose remains authoritative if L1 changes after simulation.
  • l1_publisher.integration.test.ts and the e2e suites were adapted but not run by the implementer (anvil-backed). The reviewer ran the three streaming_inbox_backlog scenarios and both proof_boundary.parallel cases on this head and all passed; the bucket/fresh-node/prover e2es are left to CI.

New e2e tests, written but not executed locally (CI runs them)

  • single-node/cross-chain/streaming_inbox.test.ts — "consumes a message in the same block that inserts it" now waits for the archiver's observation of the message, not for a later L1 block.
  • single-node/cross-chain/streaming_inbox_buckets.test.ts (new, prover node) — "splits one bucket across L2 blocks, replays it on a fresh node and proves the checkpoint"; "abandons a signed checkpoint whose final endpoint vanished and keeps consuming from the preserved prefixes". Both mine co-timestamped anvil blocks with interval mining paused for well under a slot.
  • single-node/cross-chain/streaming_inbox_backlog.test.ts (new) — three scenarios, each filling the Inbox with Multicall3 batches while block production is paused, so the measured demand is the demand the next checkpoint faces: "keeps publishing checkpoints within the caps under a sustained message backlog" (1100 messages, resumed at a slot boundary); "publishes a late checkpoint at the bucket end its remaining sub-slots can carry" (two buckets, resumed one sub-slot into the build frame); "gives up a late slot whose aged backlog needs more blocks than it has left, then recovers" (five aged buckets: L1's censorship assert requires consuming through the fourth, 880 messages and four blocks' worth, so the late proposer's own pre-broadcast preflight rejects it with Rollup__UnconsumedInboxMessages and the following checkpoints drain it). The file gets a 25m CI timeout.

Plan regression list coverage

Regression Test
Messages after checkpoint start enter the next ordinary block without bucket/age/descendant queries checkpoint_proposal_job.test.ts "consumes every observed message with no L1 query…", "produces a message-only block…"
One L1 bucket split across L2 blocks; fresh node replays by count; prover accepts world-state/src/test/integration.test.ts, server_world_state_synchronizer.test.ts count-addressed replay; e2e streaming_inbox_buckets.test.ts "splits one bucket across L2 blocks, replays it on a fresh node and proves the checkpoint" (not run locally)
Final block chooses a reachable live endpoint; an uncompletable greedy end aborts cleanly job tests "…completes at the tip on the final block", "abandons the checkpoint when the final block cannot complete…"
Early-cap guard: 868 not 1024 for 100/356/612/868/1124 job test "stops at the last endpoint within the cap rather than signing the greedy 1024"
Partial blocks to 700, then 800 not 956 for 444/700/800/1056; threshold strictly above start + 768; no lookup below it job tests "selects 800 rather than signing 956…", "does not consult L1 while a large backlog is consumed in steps below the threshold"; inbox_message_selection.test.ts "triggers strictly above the threshold", "bounds a non-final lookup by the checkpoint"
Per-block endpoint selection respects caps, splits a bucket on the block that consulted L1, never consumes less than the safe local step, retries when no endpoint is reachable yet; sustained backlog keeps publishing; lost sub-slots after the crossing block still publish job tests "consumes a full block toward the endpoint instead of stopping at the threshold", "splits a bucket on a block that consulted L1, then finishes the checkpoint on the next one", "takes the safe local step when the resolved endpoint is behind the cursor…", "ends the final block on the endpoint even when the safe local step reaches further", "retries completion on a later block…", "clears a full backlog by the fourth block…", "publishes the full backlog even when the blocks after the crossing block are lost"; e2e streaming_inbox_backlog.test.ts (all three cases)
Forced tail block when the schedule runs out mid-checkpoint job tests "builds a forced tx-less block to end the checkpoint when the timetable runs out", "…after a block that consulted L1 and ended inside a bucket", "…after an ordinary block that consumed nothing", "builds no forced block when the cursor already sits on a live endpoint", "abandons the checkpoint when the forced block finds no live endpoint above the cursor"
Endpoint lookup bounded locally, one Inbox query per querying block, verifies the returned prefix, leaves censorship to preflight job tests assert the getBucketAtOrBeforeTotal calls and the authenticated range; inbox_prefix_reorged covers a changed range; inbox_message_selection.test.ts covers every resolveEndpoint outcome including endpoint_hash_mismatch
Same-message re-placement beyond ±5 plus append preserves proposed blocks archiver-sync.test.ts "re-mines the same messages beyond the lookup window…"
Changed tail rolls back to the newest message still on L1 and prunes only the blocks past it "rolls back to the newest message still found on L1 and re-fetches the rest, dropping unchanged work", "keeps the prefix through a message found inside the lookup window", "refetches the whole L1 block the anchor sits in…", "rolls back to the deployment block and prunes every proposed block when no lookup finds an anchor"
Pure truncation, same-height and shorter-head recovery "truncates to a shorter canonical prefix by hash…", "recovers from a same-height head replacement", "recovers when the head is replaced by a shorter chain", "re-verifies the persisted syncpoint before ingesting forward…", "leaves the batch that refills the log after a rollback uncommitted when it disagrees with the Inbox", "refuses a rollback whose captured head was replaced while the rewind cursor block was read", "keeps the shortened log and the delivered prune when the refetch after a rollback fails"
Progressive batches; later RPC failure preserves completed commits "commits each batch as it completes and keeps them when a later batch fails"
Bounded recovery resumes across passes, handles range limits, reports unavailable logs without deletion "resumes a bounded anchor search across iterations…", "commits nothing when a per-message lookup fails before an anchor is chosen", "treats a lookup range entirely above the replacement head as a miss…", data_retrieval.test.ts bisection (process restart: not tested)
Recovery progresses while latest advances; only head replacement resets; budget exhaustion is not completion "resumes a bounded anchor search…while the head advances", "restarts recovery only when the head it was pinned to is replaced"
Reorg between metadata and range reads is a local-view disagreement streaming_inbox_checks.test.ts "reports a replaced suffix as a mismatch on the bundle read", proposal_handler.test.ts "classifies a replacement between the metadata check and the bundle read as a mismatch"
Mismatch retry and insertion-time mismatch emit no slashing/penalty; local block pruning during checkpoint validation is not an offense proposal_handler.test.ts "rejects a mismatch that survives the deadline…", "classifies an insert-time prefix rejection… as a local disagreement", "refuses to attest, without punishing…", "blocks pruned locally during validation" (full prune, partial prune, non-contiguous read)
Checkpoint content validation makes no bucket/L1 query; nonpublishable endpoint fails only at preflight proposal_handler.test.ts checkpoint prefix tests; job test "abandons the slot when the pre-gossip preflight rejects the checkpoint"
Insertion after rollback rejects, after harmless append succeeds data_store_updater.test.ts "addProposedBlock Inbox prefix guard" (identical-prefix restoration: not tested separately)
Promotion after pruning fails; below-checkpointed-tip disagreement gates speculation, and reconciliation lifts the gate at non-advancing heads data_store_updater.test.ts "evicts the proposed checkpoint…so it can no longer be promoted", "flags a divergence below the checkpointed tip…"; archiver-sync.test.ts "withholds proposed checkpoints while the checkpointed tip disagrees…", "reconciles the checkpointed chain at a replaced head of the same height as / shorter than the checkpoint syncpoint"
Invalid signed metadata and independent misconduct keep their classification proposal_handler.test.ts "keeps a re-execution mismatch with authenticated inputs as a proposer offense" and the existing signature/equivocation tests
Final boundary loss before/after signing abandons without a conflicting signature job tests "abandons the checkpoint when the local message prefix changes under already signed blocks", "abandons the checkpoint when the final block cannot complete…"
Parent changes or pruning invalidate stale publication simulation; synthetic overrides dropped before publication (proven pin kept while a prune is due); preflights bounded by their deadlines job tests "re-runs the preflight before publishing…", "abandons publication when the pre-publication preflight rejects…", "abandons publication when the archiver no longer holds the checkpoint blocks", "keeps the proven-tip pin in the publication preflight while a prune is still due…", "drops every build-time override… once no prune is due", "preflight deadlines" (five cases)
Mined-bucket fixtures with increasing timestamps; co-timestamp anvil case No bucket fixtures remain in the node after this cutover (L1 contract tests keep theirs); e2e streaming_inbox_buckets.test.ts "abandons a signed checkpoint whose final endpoint vanished and keeps consuming from the preserved prefixes" (not run locally)
Stale finality Deferred; documented on InboxMessageSynchronizer and in the archiver README

Stacked on #25414

Part of A-1928
Fixes A-1925
Fixes A-1907
Fixes A-1389
Supersedes #25354 #25355 #25361 #25398

…ix and insert them atomically

Rename the signed per-block Inbox reference to `InboxMessagePrefixRef`: one rolling hash, interpreted
together with the block header's L1-to-L2 leaf count, naming the message prefix the block consumed
through. Intermediate blocks may end at any prefix; no bucket boundary is involved.

Validators check counts and the signed hash against their own message log, read the bundle and its
ending hash from one snapshot before re-executing, and treat an unavailable or mismatching prefix as a
local-view outcome (`inbox_prefix_unavailable` / `inbox_prefix_mismatch`, both unvalidated, retried
through the deadline-bounded sync, never slashed). Checkpoint validation reads the consumed range by
count and compares it with the header rolling hash instead of resolving buckets or checking
censorship. A re-execution mismatch is demoted to a local disagreement when the local prefix moved.

`L2BlockSink.addBlock` requires the reference on every production path, and the archiver validates
prefix, parent continuity and the exact range inside the block-insert transaction.
…ckpoints at a live Inbox endpoint

Ordinary blocks now consume every message the local archiver has observed, up to the per-block and per-checkpoint
caps, with no L1 call and no bucket boundary involved. A checkpoint enters message completion once a greedy step
would cross the last bucket-sized portion of its capacity (start + 768 for the protocol caps), resolves the live
bucket end at or below what its remaining scheduled blocks can carry with one Inbox read, authenticates it against
the local message log in one snapshot, consumes toward it in per-block chunks and freezes once reached. The final
block always completes at a live endpoint or the checkpoint is abandoned.

The publisher's header preflight becomes the Rollup's integrated validateCheckpointHeaderAndInbox, run before gossip
with the build-time simulation plan and again before publication against the real state; the bucket hint the latter
returns is the one sent. A checkpoint whose blocks the local archiver no longer holds is not published.

Removes the bucket selector, the L1 confirmation eligibility rule and the bucket tracker; the node's public call
simulator predicts the next block's messages with the same greedy selection and stops at the tip where the proposer
would enter completion.
…from L1 reorgs by comparing content

The archiver persists only the ordered message log: compact index, leaf, cumulative rolling hash and the L1 block the
message was observed in as a recovery hint. Bucket snapshots, the timestamp index, the per-message L1 block hash and
the synthetic genesis bucket are gone, along with every bucket read on L1ToL2MessageSource and the archiver RPC
schema; consumers address messages by count and position. ARCHIVER_DB_VERSION moves to 10 with no migration.

Message sync captures an L1 head, reads the Inbox position at it, commits each forward batch together with the
syncpoint covering it and compares the final position with the captured one. A disagreement starts a recovery pinned
to that head: an exact shorter prefix is truncated by hash alone; otherwise a bounded backward search (32 event
lookups per pass, misses only move to an older candidate) finds an anchor L1 still emits unchanged, and the canonical
messages are replayed forward one batch per pass and compared with the stored ones. Only an actual content difference
mutates the store: in one transaction the old suffix is removed, the verified replacement appended, the syncpoint
moved and every uncheckpointed block that consumed a replaced message pruned. Budget exhaustion is reported as pending
work and the synchronizer does not advertise the head as synced until recovery reaches it; a replacement reaching
below the checkpointed tip withholds proposed checkpoints until checkpoint sync reconciles the published chain. The
finalized-height anchor shortcut is retained as an explicit deferral. Oversized log ranges are bisected at block
boundaries, and a single unavailable block is reported as a provider failure rather than an absence of messages.

MIN_BLOCKS_FOR_INBOX_CATCHUP becomes ceil(1024 / 256) = 4 now that blocks consume message prefixes rather than whole
buckets. The Inbox contract wrapper no longer fetches block timestamps for MessageSent logs.
…nd log bisection

Archiver sync regressions for the bucketless message log: same-message re-placement beyond the lookup window plus
append leaves proposed blocks alone; a changed tail prunes from the first replaced message rather than at the first
failed lookup; pure truncation, same-height and shorter-head replacement; batches committed progressively with a
later RPC failure preserving them; unavailable logs during recovery reported without deletion; a bounded anchor
search resuming across iterations while the head advances and restarting only when the pinned head is replaced;
proposed checkpoints withheld while the checkpointed tip disagrees with the message log. Unit coverage for the
atomic suffix replacement (prefix re-check, rollback on prune failure, proposed checkpoint eviction blocking
promotion, below-checkpointed-tip flag, truncation) and for log-range bisection.

FakeL1State can replace the L1 chain from a block (new hashes) and make MessageSent log queries fail per range.
READMEs and the streaming Inbox e2e comments describe prefix consumption instead of bucket eligibility.
…gate readiness with the checkpointed tip

The batch that reaches the captured L1 head no longer carries its syncpoint: the head is persisted only once the log
agrees with the Inbox there, so a restart mid-recovery cannot take the same-head shortcut over a disagreeing log.
Forward ingestion and hash-proven truncation recheck the head's identity before comparing or committing, so logs read
from a replaced chain are never paired with the captured head. A recovery that completes relative to the head it was
pinned to reports pending when the iteration's head has moved on, leaving the newer blocks to normal ingestion.

The anchor search trusts only the finality marker persisted by the last sync that agreed with L1, and the marker
advances only on such agreement; a fresher finalized height no longer widens the inherited shortcut to messages this
node never verified against it. While the checkpointed tip disagrees with the message log the synced L1 block is not
advanced either, so proposers cannot build on that tip while checkpoint sync reconciles it.

Tests pin when completion is entered relative to the block being built, use valid bucket partitions, and consume
toward a completion target across several blocks.
…uild the speculation gate from persisted state

The batch of messages reaching the captured L1 head is no longer stored ahead of its syncpoint: it is staged and
committed together with the head only once the position after it equals the Inbox's, so the log never holds
messages past its syncpoint and a later head equal to that syncpoint cannot take the same-head shortcut over messages
the canonical chain dropped. A matching position read by block number is only persisted while the head is still
canonical.

The speculation gate is re-evaluated every iteration from the persisted checkpoint and message state instead of only
after a replacement, so a restart between a replacement and its reconciliation rebuilds it, and while it holds the
archiver reports no synced L2 slot at all, so the latest checkpoint's slot cannot let a proposer build on the
disagreeing checkpointed tip. FakeL1State checkpoint headers now carry the rolling hash over the messages they consume.
…ore it could be committed

An intermediate batch's logs and its syncpoint block are both read by L1 block number. If the chain is replaced
between the two reads, the batch could be committed under the replacement chain's syncpoint, and a later head equal
to that block would pass the same-head shortcut over messages the canonical chain never had. Each intermediate batch
now rechecks that the captured head is still canonical after both reads and is discarded, with the pass reported as
pending, when it is not.
@spalladino
spalladino force-pushed the spl/fi2-bucketless-node-cutover branch from 29cf442 to 8725435 Compare September 5, 2026 06:29
Normal ingestion re-verifies that the persisted syncpoint's block is still
canonical before fetching forward from it, so an empty batch after a reorg
below the syncpoint cannot move it over a stale tail. Recovery commits a
batch reaching the captured head only when the log's position after it is
the Inbox's position there, and rechecks the head after the last L1 read of
the pass, immediately before any mutation.
…t pass the checkpoint syncpoint

A same-height or shorter replacement head never reached handleCheckpoints,
so a speculation gate raised by message recovery could only lift once L1
grew past the syncpoint. The rollup status comparison and unwind now run on
their own, without a forward log range, whenever the checkpointed tip
disagrees with the message log or the latest checkpoint's L1 block was
replaced within the head's reach.
…of its slot's blocks

The checkpoint's last block was read by archive and the slot's blocks read
separately, so a local prune between the two reads produced the slashable
no_blocks_for_slot or last_block_archive_mismatch verdicts. The slot read is
now the single snapshot the validation runs on, keyed by the signed archive;
a read without it, or one that does not chain, is local state in motion and
is retried until the deadline as last_block_not_found. no_blocks_for_slot is
unreachable and removed.
…lines and keep the proven pin while a prune is due

The pre-gossip preflight is bounded by the attestation deadline and the
pre-publication one by the last L1 block of the target slot; a verdict that
arrives after either, or after an interrupt, is not signed or enqueued on.
The publication preflight also carries the build's proven-tip pin while a
prune is still due at the target slot, since the send is what validates the
standing assumption that the epoch proof lands by then; without it the
boundary checkpoint's landed parent collapsed to the proven tip in
simulation and the slot was abandoned.
… boundaries and message backlogs end to end

Adds the missing resolveCompletionTarget endpoint_hash_mismatch case, makes
the same-block consumption e2e case wait for the archiver's observation
rather than a later L1 block, and adds e2e suites for a bucket split across
L2 blocks replayed by a fresh node and proven, a signed final endpoint that
vanishes when a co-timestamped anvil block extends its bucket, a backlog
drained over successive checkpoints within the caps, and a late-starting
proposer deriving its completion capacity from the sub-slots left. The
publication preflight now emits checkpoint-publish-failed when it refuses a
checkpoint, which is what the e2e case observes. The new e2e cases were not
executed locally.
… own position

An intermediate ingestion or replay batch was committed together with a syncpoint at its end block, so an incomplete
log response for a canonical block certified the stored prefix by nothing but that block's identity. A later head at
that block then took the same-head shortcut and reported the log as synced with a message missing.

The persisted position splits in two. The scanned cursor says which L1 blocks were queried and is where fetching
resumes; the syncpoint is a position that was found equal to the Inbox's own position at that block, and storing
messages without such a comparison clears it. Only the syncpoint answers a head as synced, advances the finality
marker or is inherited as canonical, which leaves the intermediate batches certified by the head batch's comparison
alone. Authenticating each intermediate batch instead would need a historical `Inbox.getState` per batch, which non
archive L1 nodes cannot serve (the contract wrappers reject block tags older than 128 blocks).
…le and an unpublishable one

The late-start case expected a publication L1 cannot accept. With five aged 220-message buckets the censorship
assert requires consuming through the fourth (880 messages, four blocks' worth), since the fifth ends 1100 messages
past the parent and is the first endpoint the per-checkpoint cap escape covers; a proposer with two sub-slots left
can carry 512. The two outcomes are now separate scenarios: a backlog its remaining sub-slots can carry whole,
whose endpoint is the newest bucket, publishes; an aged backlog whose mandatory prefix they cannot carry is
rejected by the proposer's own pre-broadcast preflight with `Rollup__UnconsumedInboxMessages`, the slot is given
up, and the following checkpoints consume the mandatory prefix and then the rest.

The sustained-backlog case measured messages sent since an earlier block rather than the outstanding demand the
first checkpoint faces, which the running sequencer had already been eating into while the L1 sends landed. Block
production is now paused while the Inbox is filled in every case, and resumed at a slot boundary or a chosen
sub-slot into the build frame.

CI gets a 25m timeout for the file, matching its three serial scenarios.
…n the proof it assumes

The pre-publication preflight keeps the build's proven pin while a prune is due at the target slot, so calling it a
check against the real state is wrong: its verdict is conditional on the epoch proof landing by the target slot, and
`propose` is what validates that. The unlanded-parent overrides are still dropped, which the comments now say.
…ion nothing compared with L1

The syncpoint/cursor split left three ways to certify an uncompared position. The archiver's outer sync skipped a
whole iteration whenever L1 reported the head of the last completed one, so a message scanned into the log since
then stayed while that head was still advertised as synced; the shortcut now also requires the message log to be
certified at that head with no recovery in progress. The synchronizer treated a missing syncpoint as one at the
deployment block, so a head there could be answered without comparing the log with the Inbox; only a persisted
syncpoint answers a head now, and the deployment block is left as the place scanning starts from. And the manual
`rollbackTo` trimmed the log by its stored L1 block hints and then wrote a syncpoint at the target block, certifying
whatever the log happened to hold; it now commits the trim with a scanned cursor and no syncpoint, in one
transaction, leaving message sync to authenticate it.

`setMessageSyncState` takes a `MessageSyncState` so a caller cannot write a position without saying whether it was
authenticated.
The aged-backlog scenario read the drain from the proposed tip, which does not require the consuming checkpoints to
be published. It now waits for the draining checkpoint to be checkpointed and reads the final totals from the
checkpoints the node holds. Those totals must be bucket ends the censorship assert allows: while an aged bucket is
outstanding within the per-checkpoint cap, a checkpoint cannot publish by consuming nothing either, which the
mandatory-prefix comment now says.
…e extended bucket

The abandoned-endpoint case read the target checkpoint number off a block that
was still only pipelined: at the moment publication is refused, the blocks the
sequencer built on top of the abandoned checkpoint are local but unpublished,
and they are pruned once the slot closes uncheckpointed. The extension then
lands in the republished checkpoint instead, one number below the one the test
had captured, so the wait could never be satisfied within its budget.

Wait for the republished checkpoint first, then locate the block that inserted
the extension. Also corrects the comment on the refusal: the propose is
enqueued as soon as the checkpoint is signed, so the pre-publication preflight
runs before the bucket is extended and it is the send-time bundle simulation
that reverts with Rollup__InvalidInboxRollingHash.
…ad of comparing replayed messages

Recovery no longer replays canonical messages to find the first content
difference before deleting anything. Once the backward search finds a stored
message L1 still emits at the same index and hash, one store transaction
deletes the log suffix after it, prunes the proposed blocks that consumed more
messages than the retained count, rewinds the scanned cursor to the block
before the anchor's and clears the syncpoint. The pass then returns, and
ordinary forward ingestion refills the log: it rewrites the retained rows in
place and appends the canonical suffix, exactly as it does for a plain append.

This is v5's shape and deletes the whole replay phase: replayBatch,
abandonDisagreeingBatch, the replay recovery state and its progress fields, and
the appended messages in the store updater's helper, which becomes
rollbackMessagesAndPruneProposedBlocks and keeps its in-transaction prefix
guard, atomic prune and proposed-checkpoint eviction. Both the recovery path
and the exact shorter-tip truncation use it.

The accepted cost: a message the bounded +-5 lookup cannot place is discarded
even when its content is unchanged and comes straight back, and the proposed
blocks that consumed it go with it. A provider answering eth_getLogs with an
empty result rather than an error has the same effect at a larger scale,
walking the search back to the finality marker or the deployment block. That is
a liveness cost, not a safety one: L1 stays authoritative, the deleted rows are
re-fetched, and published checkpoints are never deleted by this path. An RPC
exception is still not a miss and commits nothing.

The by-hash event lookup is now bounded above by the captured head, so an
anchor can never sit at or past it and leave the rewound cursor with no forward
range to fetch, which would re-enter recovery on every pass.
…querying it

The by-hash Inbox event lookup bounds its window above by the captured L1 head.
When the head sits more than five blocks below the stored height of the message
being looked up, the bounded range inverts (fromBlock above toBlock) and a
provider rejects it rather than reporting it empty. An exception is correctly
not a miss, so the recovery anchor search would retry the same candidate on
every pass instead of walking back to an older one.

InboxContract.getMessageSentEventByHash now returns no event without issuing the
RPC when the range inverts, and declares the undefined it has always been able
to return. The archiver's fake mirrors the guard in its mock of that method and
models the provider underneath it, which now rejects an inverted range the way a
real one does.
…ad of tracking a completion target

Message selection is now decided again on every block attempt, from the cursor
and the current view alone. A block consumes greedily on the local log up to the
per-block and checkpoint caps, and consults L1 only when its prospective end
would pass the threshold one bucket below the checkpoint cap (strictly above
s + 768), or when it is the checkpoint's final block, whose position has to be a
live L1 bucket end.

The lookup is bounded by the checkpoint cap on a non-final block rather than by
that block's own reach, so a mandatory bucket further away is not stranded by a
nearer endpoint; the final block is additionally bounded by what it alone can
carry. A non-final block then ends at the further of the resolved endpoint and
the safe local step held down to the threshold, so consulting L1 never consumes
less than staying below the threshold would have, and such a block may
legitimately end inside a bucket. The final block ends on the endpoint itself,
since a step past it is not publishable. The consumed prefix is re-read from the
local log, so the signed prefix hash is the one at the block's own end and never
the farther endpoint's.

The forced tail block covers the case the loop cannot: on a canStart: false exit
with at least one block built and the cursor past the checkpoint start, the
endpoint is resolved once, and if the cursor is not already on it a tx-less
block is built over the remaining range anyway. Its deadline is the proposer
timetable's last block build time, which already budgets preparation and
propagation ahead of the checkpoint proposal receive deadline validators enforce;
past that time it is still attempted, bounded by that receive deadline less one
propagation budget. The attestation deadline is a re-execution cutoff and is
never used here.

Removed: the retained completion target and its bucket sequence, the
consumption-complete freeze flag, remainingScheduledBlocks and the upper bound
derived from it, the shouldEnterMessageCompletion trigger, the
inbox_completion_unreachable abort and the completion-target abort context. The
fisherman-mode return passes a zero bucket hint, which was already documented as
never read; both publication preflights take the built cursor and get their hint
from L1. The node's next-block prediction now stops at the threshold and its
JSDoc says so: above it the sequencer's end depends on an L1 read the simulator
does not make.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on draft PRs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant