Skip to content

feat(fast-inbox): let the proposer choose between immediate and L1-confirmed inbox consumption - #25355

Open
spalladino wants to merge 6 commits into
spl/inbox-bucket-canonicality-checkfrom
spl/inbox-consumption-mode
Open

feat(fast-inbox): let the proposer choose between immediate and L1-confirmed inbox consumption#25355
spalladino wants to merge 6 commits into
spl/inbox-bucket-canonicality-checkfrom
spl/inbox-consumption-mode

Conversation

@spalladino

@spalladino spalladino commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 (config inboxL1Confirmations), values 0 or 1, default 0. Anything else is rejected at startup with a message naming the two supported values, and rejected the same way on a runtime config update (pickFromSchema trusts its input, so the in-process update path had no validation of its own).

The default is 0 because 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:

  • Before each block proposal is signed, the consumption cursor is re-pointed at the bucket that currently carries its rolling hash. This has to happen before signing: the reference is covered by the proposal signature, and slashing protection refuses a second signature for the same duty, so a reference that goes stale after signing cannot be corrected. Without this the L1 hint and the gossiped proposal disagreed after a merge, and validators rejected the proposal on a rolling-hash mismatch for a checkpoint L1 would have accepted.
  • Before the checkpoint proposal is gossiped, and once more before enqueueProposeCheckpoint (attestation collection takes seconds; the second check is one store read), the L1 propose hint 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 an inbox_bucket_reorged failure rather than sending a propose that 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 a rollingHash → seq index 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.ZERO resolves the genesis sentinel.

ARCHIVER_DB_VERSION goes 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 seq and compares only the rolling hash — never the timestamp — so a re-timed bucket is accepted as-is, and #25342's single forced re-sync on bucket_hash_mismatch covers a validator sitting on the orphaned side of the reorg. With the proposer now re-resolving before it signs, a merge that shifts seq no 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_CONFIRMATIONS is 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: default 0, 1 accepted, 2 and abc rejected 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 depth 0; the opening block's child read at depth 1; 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 existing fake_l1_state re-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

@spalladino
spalladino force-pushed the spl/inbox-consumption-mode branch from e1acf8b to 82d8f43 Compare August 28, 2026 23:21
…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".
@spalladino
spalladino requested a review from just-mitch as a code owner August 29, 2026 11:13
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant