feat(fast-inbox): let the proposer choose between immediate and L1-confirmed inbox consumption - #25355
Open
spalladino wants to merge 6 commits into
Open
Conversation
spalladino
force-pushed
the
spl/inbox-consumption-mode
branch
from
August 28, 2026 23:21
e1acf8b to
82d8f43
Compare
…nfirmed inbox consumption Adds `SEQ_INBOX_L1_CONFIRMATIONS` (`0` or `1`, default `0`). At `0` a proposer consumes an Inbox bucket as soon as its archiver has it, so a bridged message reaches L2 in the next block; at `1` it waits for another L1 block to build on the bucket's opening block, as the descendant-confirmed rule does today. The automine sequencer always consumes immediately, and the node's next-block prediction follows the same setting so simulation matches what the proposer will actually build. Immediate consumption exposes the proposer to one thing: an L1 reorg re-timing a bucket it already consumed. Because the rolling hash commits to messages alone, the sealed header stays valid on L1, but a merge can move the bucket's sequence number. The proposer therefore re-resolves the `propose` bucket hint by content — the bucket carrying the header's rolling hash — once before gossiping the proposal and once more before submission, and abandons the slot when no bucket carries that hash any more.
…ket index The rolling-hash to sequence-number index the propose bucket hint is resolved through is written only as buckets are stored, so an archiver database carried over from an earlier build has buckets that the index does not name. Bumping the schema version makes the store rebuild instead of answering those lookups with "no such bucket".
…ox bucket Re-resolving the bucket only for the L1 `propose` argument left the block proposals peers check unchanged, so after a reorg that merged buckets a validator resolved the sequence number the proposal named and rejected it on a rolling-hash mismatch even though L1 would have accepted the corrected hint. A proposal cannot be re-signed once slashing protection has recorded the duty, so the consumption cursor is now re-resolved by content before each block proposal is signed, and the L1 hint follows from the same cursor. Two further corrections to that resolution: - Key the lookup on the rolling hash the sealed header commits to, which is what L1 checks the hint's bucket against, rather than on the cursor that tracked it. - Re-check the censorship floor at the resolved position before submitting. A bucket that syncs after the checkpoint was built can turn a position that cleared the floor at build time into one `ProposeLib` reverts, and abandoning the slot is cheaper than the reverted propose. Also applies the configured default when picking the eligibility rule, so an absent `inboxL1Confirmations` means immediate consumption in the job as it already does in the node's next-block prediction, rather than waiting.
…k prediction The public-calls simulator held the config object it was constructed with, while the node replaces its own on an admin update. A change to `inboxL1Confirmations` therefore moved the sequencer to a different consumption rule while simulation kept predicting against the old one, so a transaction could be simulated against a bundle the next block would not consume. The node now forwards the update, and keys the update leaves out keep their current value.
…nfig update `Sequencer.updateConfig` picks fields off an already-validated object, so an in-process caller could set a depth the env-var parser and the admin RPC schema would both have refused, and the job treated anything non-zero as "wait one block". The update is now rejected the same way an invalid timetable is, leaving the running config untouched. Also narrows the setting's description to the reorgs that actually cost a slot.
…ors check A reorg that re-mines the same messages no longer costs a slot: the proposer re-resolves what it consumed before publishing, so only a reorg that reorders or drops those messages abandons the checkpoint. The bridge tutorials still described the one-confirmation wait as live-network behaviour, which is no longer the default. Also replaces "validators accept whatever L1 accepts" with what is actually guaranteed, and notes the archiver resync on upgrade.
spalladino
force-pushed
the
spl/inbox-consumption-mode
branch
from
August 29, 2026 11:15
f37dd19 to
339e6ec
Compare
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.
Exposes to operators the choice the proposer makes about when an L1-to-L2 message becomes consumable, and makes the impatient setting safe to run.
The flag
SEQ_INBOX_L1_CONFIRMATIONS(configinboxL1Confirmations), values0or1, default0. Anything else is rejected at startup with a message naming the two supported values, and rejected the same way on a runtime config update (pickFromSchematrusts its input, so the in-process update path had no validation of its own).0— immediate. Consume an Inbox bucket as soon as the local archiver has it, so a bridged message reaches L2 in the next block the proposer builds.1— one L1 confirmation. Consume only once another L1 block has built on the bucket's opening block: the descendant-confirmed rule from feat(fast-inbox): consume inbox buckets once their L1 block has a canonical descendant #25351, which costs roughly one Ethereum slot (~12s) of extra latency per message.The default is
0because the lag simulator bounds the price of impatience at about 0.07% of slots — one or two a day network-wide — assuming ten one-block L1 reorgs per day and messages present in 30% of L1 blocks. That is an upper bound: a reorg that re-mines the same messages costs nothing, because the proposer re-resolves what it consumed before publishing, so only a reorg that reorders or drops those messages actually loses the slot. Nothing worse than a lost slot happens: the chain is unaffected, and the next proposer consumes the message. Waiting buys back those slots and pays ~12s of latency on every message.The automine sequencer ignores the setting and always consumes immediately, since its L1 mines on demand. The node's next-block public-call simulator now reads the same setting, so a transaction is only admitted against a bundle the proposer will actually consume — it previously hardcoded the confirmed rule — and it is told about runtime updates to the setting rather than holding the config object it was constructed with.
Bucket re-resolution by content
Immediate consumption exposes the proposer to exactly one hazard: an L1 reorg that re-times a bucket it has already consumed. Under the message-only rolling hash a re-time leaves the hash untouched, so the sealed checkpoint header stays valid on L1 — but a reorg that merges buckets shifts every sequence number after the merge point, and a sequence number captured when the bundle was selected would point L1, and peers, at a bucket whose rolling hash no longer matches the header.
So the bucket is resolved by content rather than carried forward, via a new
getInboxBucketByRollingHash:enqueueProposeCheckpoint(attestation collection takes seconds; the second check is one store read), the L1proposehint is resolved from the rolling hash the sealed header commits to — the value L1 compares the hint's bucket against — rather than from the cursor that tracked it.A changed sequence number is logged at
info. When no bucket carries the hash at all the consumed prefix no longer ends on a bucket boundary — a reorder or a dropped message — and the slot is abandoned with aninbox_bucket_reorgedfailure rather than sending aproposethat would revert.The mandatory-consumption floor is re-checked at the resolved bucket as part of that last step, so a bucket that syncs after the checkpoint was built (or one the reorg moved under the cutoff) abandons the slot locally instead of being caught by L1 reverting the
propose.Archiver index and database version
This needs a new
L1ToL2MessageSource.getInboxBucketByRollingHash, backed in the archiver by arollingHash → seqindex alongside the bucket snapshots. The index is written wherever a snapshot is written and cleaned wherever one is deleted or rewritten (the existing rewind helper), so every removal path is covered by construction; a stale entry can only cost a read, since the bucket it names is confirmed to carry the hash before it is returned.Fr.ZEROresolves the genesis sentinel.ARCHIVER_DB_VERSIONgoes to 10 for it: the index is only written as buckets are stored, so a database carried over from an earlier build would hold buckets the index does not name and answer those lookups with "no such bucket". Nodes rebuild the archiver store on first start and resync from L1.Validators are unchanged
No validator code changed, deliberately. Check 1 looks the bucket up by
seqand compares only the rolling hash — never the timestamp — so a re-timed bucket is accepted as-is, and #25342's single forced re-sync onbucket_hash_mismatchcovers a validator sitting on the orphaned side of the reorg. With the proposer now re-resolving before it signs, a merge that shiftsseqno longer costs it the attestations of validators that already see the merge; a proposal signed before the merge lands still can, which no proposer-side change can fix. Checking by content on the validator side is a possible later iteration, not part of this PR.New configuration
SEQ_INBOX_L1_CONFIRMATIONSis new and defaults to the behaviour described above; nothing existing changes value. Nodes running either setting interoperate, so a fleet can be rolled in any order. Documented in the developer inbox page and in a new operator changelog page, which also carries the resync note. The bridge tutorials no longer describe the one-confirmation wait as live-network behaviour.Tests
sequencer-client/src/config.test.ts: default0,1accepted,2andabcrejected with the two supported values named.sequencer.test.ts: a runtime update to an unsupported depth is rejected and leaves the running config untouched.checkpoint_proposal_job.test.ts: no L1 reads at depth0; the opening block's child read at depth1; a merge re-seating the rolling hash publishes the new sequence number and signs the block proposal against it; the same reorg landing during attestation collection is caught by the pre-submission lookup, which can only correct the L1 hint; a bucket becoming mandatory during attestation collection leaves nothing enqueued; a vanished rolling hash abandons the slot before broadcast, and after broadcast leaves nothing enqueued.node_public_calls_simulator.test.ts: prediction follows a runtime depth change, and keeps the configured depth when an update leaves it out.message_store.test.ts: resolve by rolling hash, the genesis zero hash, index cleanup for buckets a removal deletes or rewrites, and a hash resolving to the bucket a merge moved it to.archiver-sync.test.ts: the existingfake_l1_statere-time / merge / reorder reorg cases now also assert what a proposer holding the pre-reorg rolling hash resolves afterwards — the same sequence number, the merged one, and nothing. A single test spanning build → re-time → publish is not expressible at unit level (the proposer suite mocks its message source and the archiver suite has no proposer), so the two halves are covered separately.proposal_handler.test.ts: a proposal referencing a re-timed bucket (same seq, same hash, new timestamp) is attested with no forced sync.Gates: full
yarn build,yarn format,yarn lint; sequencer-client 279 passed / 1 skipped, archiver 601/601, aztec-node 183/183, validator-client 274 passed / 3 skipped. No e2e.Stacked on #25354
Fixes A-1881