feat(fast-inbox): cut the node over to the bucketless message log - #25415
Draft
spalladino wants to merge 24 commits into
Draft
feat(fast-inbox): cut the node over to the bucketless message log#25415spalladino wants to merge 24 commits into
spalladino wants to merge 24 commits into
Conversation
…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
force-pushed
the
spl/fi2-bucketless-node-cutover
branch
from
September 5, 2026 06:29
29cf442 to
8725435
Compare
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.
…he other private sync helpers
…of headroom, not at most
…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.
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
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.
InboxBucketRefbecomesInboxMessagePrefixRef(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-executionstate_mismatchis 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'sinboxRollingHashcompared 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.tsholds 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 ismin(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 passstart + 768(the cap minus one bucket, so a further step could leave the last legal endpoint behind). OneInbox.getBucketAtOrBeforeTotalcall resolves the live bucket end at or belowmin(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 owncursor + 256), andgetL1ToL2MessageRangeauthenticates the range from the cursor to it against the returned hash. A non-final block then ends at the further ofmin(cursor + 256, endpoint)and the safe local stepmin(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 withlocal = 1300and 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 andpropose.Publication preflight against L1's current state.
SequencerPublisher.validateCheckpointHeaderbecomesvalidateCheckpointHeaderAndInbox(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 afterwaitForValidParentCheckpointOnL1with 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;proposeremains the final check); a rejection there ispublication_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 thestart + 768threshold, 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 onL1ToL2MessageSourceand the archiver RPC schema, andARCHIVER_DB_VERSIONmoves to 10 (no migration).InboxMessageSynchronizercaptures 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 advancinglatestdoes not reset a search pinned to its head. A rollback reaching below the checkpointed tip leaves published blocks to checkpoint sync and withholdsgetProposedCheckpointDatauntil the checkpointed tip'sinboxRollingHashagrees 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.l1BlockTimestampand its per-loggetBlockcalls.MIN_BLOCKS_FOR_INBOX_CATCHUPis nowceil(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_slotis unreachable and removed);last_block_archive_mismatchremains 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 emitscheckpoint-publish-failedwhen 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 againstInbox.getStateat 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, andArchiver.rollbackTocommits 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)
validateCheckpointHeaderAndInboxtakes oneCheckpointPreflightArgscalldata 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_CATCHUP7 → 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.proposeremains authoritative. CI'sproof_boundarycase 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 actualproposeis what validates that assumption. Dropping the pin letSTFLib.getEffectivePendingCheckpointNumbercollapse the landed parent to the proven tip in simulation (Rollup__UnexpectedParentCheckpoint(3,0)) and abandon a slot the send would have won.getPublicationSimulationOverridesPlankeepsprovenpinned whileisPruneDueAtSlot(targetSlot)holds; the unlanded-parent overrides (archive, fee header, parent cell) are still dropped.Simplifications after review (2026-09-07)
spl/fi2-bucketless-node-cutover-backup-20260907in case it has to come back.inbox_completion_unreachableabort. Selection is now stateless per block as described above, the freeze and theremainingScheduledBlocksbound 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 onspl/fi2-bucketless-node-cutover-backup-after-recovery-20260907.Accepted limitations
proposeremains authoritative if L1 changes after simulation.l1_publisher.integration.test.tsand the e2e suites were adapted but not run by the implementer (anvil-backed). The reviewer ran the threestreaming_inbox_backlogscenarios and bothproof_boundary.parallelcases 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 withRollup__UnconsumedInboxMessagesand the following checkpoints drain it). The file gets a 25m CI timeout.Plan regression list coverage
checkpoint_proposal_job.test.ts"consumes every observed message with no L1 query…", "produces a message-only block…"world-state/src/test/integration.test.ts,server_world_state_synchronizer.test.tscount-addressed replay; e2estreaming_inbox_buckets.test.ts"splits one bucket across L2 blocks, replays it on a fresh node and proves the checkpoint" (not run locally)inbox_message_selection.test.ts"triggers strictly above the threshold", "bounds a non-final lookup by the checkpoint"streaming_inbox_backlog.test.ts(all three cases)getBucketAtOrBeforeTotalcalls and the authenticated range;inbox_prefix_reorgedcovers a changed range;inbox_message_selection.test.tscovers everyresolveEndpointoutcome includingendpoint_hash_mismatcharchiver-sync.test.ts"re-mines the same messages beyond the lookup window…"data_retrieval.test.tsbisection (process restart: not tested)latestadvances; only head replacement resets; budget exhaustion is not completionstreaming_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"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)proposal_handler.test.tscheckpoint prefix tests; job test "abandons the slot when the pre-gossip preflight rejects the checkpoint"data_store_updater.test.ts"addProposedBlock Inbox prefix guard" (identical-prefix restoration: not tested separately)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"proposal_handler.test.ts"keeps a re-execution mismatch with authenticated inputs as a proposer offense" and the existing signature/equivocation testsstreaming_inbox_buckets.test.ts"abandons a signed checkpoint whose final endpoint vanished and keeps consuming from the preserved prefixes" (not run locally)InboxMessageSynchronizerand in the archiver READMEStacked on #25414
Part of A-1928
Fixes A-1925
Fixes A-1907
Fixes A-1389
Supersedes #25354 #25355 #25361 #25398