Skip to content

feat(fast-inbox): tag the first message of each inbox bucket in the rolling hash - #25319

Open
spalladino wants to merge 7 commits into
spl/inbox-descendant-confirmed-eligibilityfrom
spl/a-1748-inbox-bucket-boundary-separator
Open

feat(fast-inbox): tag the first message of each inbox bucket in the rolling hash#25319
spalladino wants to merge 7 commits into
spl/inbox-descendant-confirmed-eligibilityfrom
spl/a-1748-inbox-bucket-boundary-separator

Conversation

@spalladino

@spalladino spalladino commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Stacked on #25351. Fixes A-1748.

What

The Inbox rolling hash now commits to how L1 packed messages into buckets, not just to their order. The first message of every bucket is hashed with a new domain separator, DOM_SEP__INBOX_ROLLING_HASH_BUCKET_START; every other link keeps DOM_SEP__INBOX_ROLLING_HASH. Genesis stays 0; preimage length, sha256 block count and ParityPublicInputs are unchanged. Two Inbox histories with the same leaves regrouped into different buckets now produce different rolling hashes.

This gives the archiver's L1-reorg check a hash-level detector for same-leaves/different-packing reorgs, which are invisible to it today.

  • L1: Hash.accumulateInboxRollingHash(prev, leaf, opensBucket); Inbox._absorbIntoBucket passes bucket.msgCount == 0. Constants allowlist updated so ConstantsGen.sol carries the new separator.
  • Circuits: InboxParityPrivateInputs<S> gains bucket_starts: [bool; S]; accumulate_inbox_rolling_hash selects the separator per link with a boolean select (still one sha256 per link, shared accumulate_sha256* untouched). Guards: lane 0 must open a bucket when num_msgs > 0; padding lanes must not.
  • TS: message bundles become InboxMessageBundle = Fr[][] — leaves grouped per bucket, inner arrays never empty. The archiver's range queries return it, and it is threaded through the sequencer, validator, block builder and prover. InboxParityPrivateInputs.fromMessages is the single place the grouping is turned into flat leaves + flags. (A block's selected range can span several buckets, so the selector passes the archiver's grouping through unchanged.)
  • Vectors: all pinned rolling-hash vectors are re-derived independently by l1-contracts/scripts/inbox_rolling_hash_vectors.py (plain hashlib) and pinned identically in Noir, Solidity and TS, including a boundary-commitment pair ([[11,22,33,44]] vs [[11,22],[33,44]]).
  • Docs: the developer Inbox doc describes both separators and now covers getCurrentBucketSeq / getBucket (the open review thread from feat(fast-inbox): streaming L1-to-L2 messages, AZIP-22 (umbrella) #25041).

Cost

bb gates --scheme ultra_honk, before → after: inbox_parity_64 578,633 → 579,384; inbox_parity_256 2,304,521 → 2,307,528; inbox_parity_1024 9,208,073 → 9,220,104 (+0.13% each, ≈11.7 gates per lane). None crosses its power-of-two padding (2^20 / 2^22 / 2^24), so proving time is unchanged. No other circuit is affected. sendL2Message: 43,269 → 43,360 gas (+91).

Not a migration

The streaming Inbox has not shipped in any release, so this is a consensus-format change with no deployed state to migrate. It regenerates the three InboxParity VKs and, through the new VK tree root, the rollup-*/Prover.toml sample inputs. EpochProvingJobData serialization changes shape (per-checkpoint bucket count + one vector per bucket); older persisted rerun snapshots will not deserialize.

Testing

  • Noir: types (413) and rollup_lib inbox (22) suites; the three parity crates compile and nargo execute solves the checkpoint-root sample inputs with the new VK tree root.
  • Foundry: full suite (913 passed), including new testBucketBoundariesChangeTheChain.
  • TS: unit tests for stdlib, archiver, sequencer-client, validator-client, prover-client, prover-node, aztec-node, world-state; yarn build / format / lint clean.
  • e2e single-node/cross-chain/streaming_inbox.test.ts 4/4 locally. The full rollup_lib Noir suite and the remaining e2e runs are left to CI.

…olling hash

The Inbox rolling hash chained every message with a single domain separator, so
it committed to the message order but said nothing about how L1 packed the
messages into buckets; "consume whole buckets" rested entirely on the
ring-snapshot compare at propose.

The first message of a bucket now hashes with a second separator,
DOM_SEP__INBOX_ROLLING_HASH_BUCKET_START, and every other link keeps
DOM_SEP__INBOX_ROLLING_HASH. Preimage shape, length and the zero genesis are
unchanged. Two Inbox histories over the same leaves packed into different L1
blocks now reach different rolling hashes, which also gives the archiver a
hash-level detector for a same-leaves/different-packing L1 reorg.

InboxParityPrivateInputs gains bucket_starts: [bool; S], with one sha256 per
link and a boolean-selected separator. The circuit asserts lane 0 opens a bucket
whenever the checkpoint consumes anything, and that no padding lane claims to.
Cost: +751 / +3,007 / +12,031 gates on inbox_parity_{64,256,1024} (+0.13%), all
inside their existing padded sizes.

Reference vectors for the L1, Noir and TypeScript tests are derived independently
of all three by l1-contracts/scripts/inbox_rolling_hash_vectors.py.
sendL2Message moves 43,269 -> 43,360 (min/median), 46,588 -> 46,678 (mean) and
102,063 -> 102,144 (max) for the bucket-start test and the constant select; the
Inbox deploy size grows by 54 bytes. The gas_benchmark scenario consumes no
Inbox messages, so gas_benchmark.md and gas_benchmark_results.json are unchanged.
… TS stack

Recomputing the checkpoint's rolling hash now needs to know where each Inbox
bucket begins, which a flat list of leaves cannot express. A checkpoint's (or a
block's) messages are therefore carried as InboxMessageBundle = Fr[][]: the
leaves grouped per bucket in insertion order, every inner array non-empty. The
grouping is what the hash commits to, so there is no parallel flag array to keep
in sync, block bundles concatenate into the checkpoint bundle, and the archiver's
range queries already group per bucket internally.

The archiver's getL1ToL2MessagesBetween{Buckets,LeafCounts} return the bundle,
and it threads through the sequencer's bucket selection, the validator's
re-execution and checkpoint rebuild, the lightweight checkpoint builder and the
prover node. Sites that only need plain leaves - message tree appends, the
message sponge, per-block slices - flatten it. The Fr[][] to leaves + per-lane
bucket-start flags conversion happens once, in
InboxParityPrivateInputs.fromMessages.
…unctions

Documents both rolling-hash separators and how buckets group the messages sent in
one L1 block, and adds getCurrentBucketSeq/getBucket to the view-function table
(the unresolved review thread on the streaming Inbox umbrella).
… tree root

The three inbox_parity VKs change with bucket_starts, moving the VK tree root to
0x29b7bb0a371549fbde9a7c1967f9368c8b78f8d537b1c0a47626757643283c1d. Regenerated
with AZTEC_GENERATE_TEST_DATA=1; the checkpoint-root crates execute against the
new tomls and echo the same root.
@spalladino
spalladino force-pushed the spl/a-1748-inbox-bucket-boundary-separator branch from 929edfb to 359ff40 Compare August 29, 2026 11:10
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