Skip to content

feat(fast-inbox): message-bundle components in rollup-lib (A-1372) - #24587

Closed
spalladino wants to merge 5 commits into
merge-train/spartanfrom
spl/a-1372-msg-bundle-components
Closed

spalladino wants to merge 5 commits into
merge-train/spartanfrom
spl/a-1372-msg-bundle-components

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

First PR of the Fast Inbox circuits series (A-1372). Pure library components in rollup-lib/types plus new constants — no circuit interface changes, nothing consumes these yet. They exist so the follow-up PRs (inboxRollingHash end to end, per-block bundles) are reviewable as wiring rather than wiring + primitives.

Components

  • append_leaves_to_snapshot (types/src/merkle_tree/append_only_tree.nr): variable-length append of up to MaxLeaves leaves into a poseidon AppendOnlyTreeSnapshot at an arbitrary (non-aligned) index, replacing the alignment assumption of the fixed height-10 subtree insert. Takes a frontier hint validated against the snapshot root (same trust model as sibling-path hints; unpinned hint lanes are provably never read). Implemented as a level-by-level batched merge costing ~MaxLeaves + 3·TreeHeight poseidon hashes — 1,166 for a 1024-leaf bundle in the height-36 tree, 398 for 256 — matching the cost model from the design analysis. An exhaustive small-tree sweep tests every (start, num) combination against a reference tree, and an equivalence test proves a 1024-leaf append at an aligned index reproduces insert_subtree_root_to_snapshot bit for bit (root 0x27b87d4d3b78d1fc49a6cb4d83e0cc81de7f5972cda7fe29a6a82aa2c255cb3d) — this is what keeps the transitional wiring identical to production today.
  • L1ToL2MessageSponge (rollup-lib/src/abis/l1_to_l2_message_sponge.nr): absorb-only poseidon2 sponge over message leaves (the inHashSponge from AZIP-22 Option 3), modeled on SpongeBlob. Compared by state equality, never squeezed on the block path; iv = 0 (unlike SpongeBlob, whose iv encodes expected length because its squeeze feeds the blob challenge — this sponge's TS mirror must use iv 0). Length bound is enforced by the underlying poseidon2_absorb_in_chunks_existing_sponge.
  • accumulate_inbox_rolling_hash (rollup-lib/src/inbox_rolling_hash.nr): rolling truncated sha256 chain, each link h' = sha256ToField(h_32be || leaf_32be) via the existing accumulate_sha256 — byte-identical to Solidity Hash.sol::sha256ToField and TS truncateAndPad. Takes a start, returns an end, no chunk-position assumptions, so it threads sequentially across chunked circuits.
  • Constants: MAX_L1_TO_L2_MSGS_PER_BLOCK = 1024 (transitional; drops to 256 at the flip) and MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024 (semantically today's NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP, which stays untouched until cleanup). TS constants regenerated; ConstantsGen.sol is intentionally unchanged (the Solidity emitter allow-list doesn't include them — L1 constants land with the Inbox L1 work).

Shared test vectors

Generated from an independent sha256 implementation and embedded in the noir tests; the TS mirror and the L1 Foundry tests must pin the same values. chain(start, leaves) folds h' = sha256ToField(h || leaf):

Case Value
chain(0, [11]) 0x00815fb1e9d2076ae5761439b6144ad11da69eb6c41ab2aca39e770407ad8d12
chain(0, [11, 22, 33]) 0x0014cae968461979aab6d33266a2310ed234d3f6cf4472737c57551db07bd0da
chain(0, [1..=256]) 0x00ea95b96f17b75be03525b35a2a1918b42f03ad8c00a437cf641751825f3992
chain(0x2a, [7, 8]) 0x0054d96b8a074a5030a5838972d0a3c04ba47cf5956348c853e02e9566233f65
chain(0x2a, [7]) (intermediate link) 0x0032a934005556d1b9d22708666ee8b05f91fafad624dd64a6ea878e048e5438

Testing

  • 25 new noir tests across both crates (append: empty/single/non-aligned/max/partial/equivalence/exhaustive sweep/failure cases; sponge: split-boundary threading, padding exclusion; rolling hash: reference vectors, segment continuity, padding exclusion). Full suites green: types 373, rollup-lib 381.
  • yarn build green. No compiled circuit artifacts change (library-only code, unused constants).

@spalladino
spalladino requested a review from LeilaWang as a code owner July 7, 2026 17:30
@spalladino
spalladino force-pushed the spl/a-1372-msg-bundle-components branch 2 times, most recently from 1e17c26 to cc40d0b Compare July 14, 2026 19:55
@spalladino
spalladino changed the base branch from merge-train/spartan-v6 to merge-train/spartan July 14, 2026 19:55
@spalladino spalladino changed the title feat: message-bundle components in rollup-lib (A-1372) feat(fast-inbox): message-bundle components in rollup-lib (A-1372) Jul 17, 2026
@spalladino
spalladino force-pushed the spl/a-1372-msg-bundle-components branch 2 times, most recently from 1073d5b to 40ddee2 Compare July 19, 2026 14:05
Pure library components for the Fast Inbox (AZIP-22), consumed by nothing
yet: new MAX_L1_TO_L2_MSGS_PER_BLOCK / MAX_L1_TO_L2_MSGS_PER_CHECKPOINT
constants, a variable-length frontier-based append to an
AppendOnlyTreeSnapshot at arbitrary (non-aligned) indices, an absorb-only
poseidon message-bundle sponge (L1ToL2MessageSponge), and the rolling
sha256 chain helper (accumulate_inbox_rolling_hash) matching the L1
truncated-to-field policy. No circuit interface changes.
Replaces the per-leaf frontier walk (MaxLeaves x TreeHeight poseidon
hashes) with a level-by-level batched merge: the batch is prepended with
the pending left sibling when it starts as a right child, dangling odd
nodes become the new frontier entries, and remaining nodes are paired
into the next level with lane bounds halving per level. Total cost drops
to ~MaxLeaves + 3 x TreeHeight hashes (1,166 for 1024 leaves in the
height-36 tree vs ~36,900 before). Same signature, semantics, and error
messages. Adds an exhaustive small-tree sweep over every (start, num)
combination and a fail-closed test for appending to a completely full
tree.
@spalladino

Copy link
Copy Markdown
Contributor Author

Superseded by #25036.

The Fast Inbox stack has been regrouped from 20 per-issue PRs (plus 2 umbrellas) down to 3 area PRs plus an umbrella, and rebased onto merge-train/spartan including #25007 (the noir-projects fnd//labs/ split). This PR's diff is preserved verbatim as a single squashed commit in #25036, and this description is preserved in that commit's message.

New structure: #25036 (circuits + L1) → #25037 (node + flip) → #25038 (cleanup), with #25039 as the full-stack umbrella. The original branch for this PR is left on the remote as a recovery point.

Closing here to cut the rebase and review overhead of maintaining 22 PRs; the work is not abandoned.

@spalladino spalladino closed this Jul 28, 2026
spalladino added a commit that referenced this pull request Aug 18, 2026
First PR of the Fast Inbox circuits series (A-1372). Pure library components in `rollup-lib`/`types` plus new constants — **no circuit interface changes**, nothing consumes these yet. They exist so the follow-up PRs (`inboxRollingHash` end to end, per-block bundles) are reviewable as wiring rather than wiring + primitives.

### Components

- **`append_leaves_to_snapshot`** (`types/src/merkle_tree/append_only_tree.nr`): variable-length append of up to `MaxLeaves` leaves into a poseidon `AppendOnlyTreeSnapshot` at an arbitrary (non-aligned) index, replacing the alignment assumption of the fixed height-10 subtree insert. Takes a frontier hint validated against the snapshot root (same trust model as sibling-path hints; unpinned hint lanes are provably never read). Implemented as a level-by-level batched merge costing ~`MaxLeaves + 3·TreeHeight` poseidon hashes — 1,166 for a 1024-leaf bundle in the height-36 tree, 398 for 256 — matching the cost model from the design analysis. An exhaustive small-tree sweep tests every `(start, num)` combination against a reference tree, and an equivalence test proves a 1024-leaf append at an aligned index reproduces `insert_subtree_root_to_snapshot` bit for bit (root `0x27b87d4d3b78d1fc49a6cb4d83e0cc81de7f5972cda7fe29a6a82aa2c255cb3d`) — this is what keeps the transitional wiring identical to production today.
- **`L1ToL2MessageSponge`** (`rollup-lib/src/abis/l1_to_l2_message_sponge.nr`): absorb-only poseidon2 sponge over message leaves (the `inHashSponge` from AZIP-22 Option 3), modeled on `SpongeBlob`. Compared by state equality, never squeezed on the block path; iv = 0 (unlike `SpongeBlob`, whose iv encodes expected length because its squeeze feeds the blob challenge — this sponge's TS mirror must use iv 0). Length bound is enforced by the underlying `poseidon2_absorb_in_chunks_existing_sponge`.
- **`accumulate_inbox_rolling_hash`** (`rollup-lib/src/inbox_rolling_hash.nr`): rolling truncated sha256 chain, each link `h' = sha256ToField(h_32be || leaf_32be)` via the existing `accumulate_sha256` — byte-identical to Solidity `Hash.sol::sha256ToField` and TS `truncateAndPad`. Takes a start, returns an end, no chunk-position assumptions, so it threads sequentially across chunked circuits.
- **Constants**: `MAX_L1_TO_L2_MSGS_PER_BLOCK = 1024` (transitional; drops to 256 at the flip) and `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024` (semantically today's `NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP`, which stays untouched until cleanup). TS constants regenerated; `ConstantsGen.sol` is intentionally unchanged (the Solidity emitter allow-list doesn't include them — L1 constants land with the Inbox L1 work).

### Shared test vectors

Generated from an independent sha256 implementation and embedded in the noir tests; the TS mirror and the L1 Foundry tests must pin the same values. `chain(start, leaves)` folds `h' = sha256ToField(h || leaf)`:

| Case | Value |
| --- | --- |
| `chain(0, [11])` | `0x00815fb1e9d2076ae5761439b6144ad11da69eb6c41ab2aca39e770407ad8d12` |
| `chain(0, [11, 22, 33])` | `0x0014cae968461979aab6d33266a2310ed234d3f6cf4472737c57551db07bd0da` |
| `chain(0, [1..=256])` | `0x00ea95b96f17b75be03525b35a2a1918b42f03ad8c00a437cf641751825f3992` |
| `chain(0x2a, [7, 8])` | `0x0054d96b8a074a5030a5838972d0a3c04ba47cf5956348c853e02e9566233f65` |
| `chain(0x2a, [7])` (intermediate link) | `0x0032a934005556d1b9d22708666ee8b05f91fafad624dd64a6ea878e048e5438` |

### Testing

- 25 new noir tests across both crates (append: empty/single/non-aligned/max/partial/equivalence/exhaustive sweep/failure cases; sponge: split-boundary threading, padding exclusion; rolling hash: reference vectors, segment continuity, padding exclusion). Full suites green: types 373, rollup-lib 381.
- `yarn build` green. No compiled circuit artifacts change (library-only code, unused constants).


Replaces #24587.
spalladino added a commit that referenced this pull request Aug 18, 2026
…Hash (A-1373)

Stacked on #24587 (A-1372). Part of the AZIP-22 Fast Inbox work.

Introduces the `inboxRollingHash` — a truncated-to-field sha256 rolling chain over the L1→L2 message leaves — end to end, carried as a **dual** of the legacy `inHash`. The legacy `inHash` remains authoritative; the rolling hash is computed and threaded everywhere but not yet enforced on L1, so this change is behavior-preserving pre-flip.

Each link is `h' = sha256ToField(h_be32 || leaf_be32)` with the top byte of the digest zeroed, genesis value zero.

**Circuits (noir)**
- Parity base computes the rolling chain over its real message leaves (`start_rolling_hash` → `end_rolling_hash`, `num_msgs`), asserting trailing padding lanes are zero. Parity root asserts chunk continuity (`children[i].start == children[i-1].end`) and sums the counts.
- Block and checkpoint rollup public inputs carry a `{start, end}` rolling-hash pair, propagated exactly like `in_hash`. Checkpoint merges assert `right.start == left.end` (decision 11 anchoring).
- The checkpoint header gains `inbox_rolling_hash` immediately after `in_hash`.
- The root rollup public inputs expose the `{previous, end}` pair sourced from the merged checkpoint public inputs, so the epoch's consumed chain segment is passed through to proof verification.

**L1 (Solidity)**
- `ProposedHeaderLib` serializes the new header field (header 348 → 380 bytes).
- `PublicInputArgs` gains `previousInboxRollingHash` / `endInboxRollingHash`; `EpochProofLib` places them at public-input positions 3 and 4 (header hashes, fees, constants and blob inputs shift by two). Both values are deliberately **unvalidated** until the Fast Inbox flip — for now they are pass-through only.

**TypeScript**
- `updateInboxRollingHash` / `accumulateInboxRollingHash` mirror the circuit chain; `getPreviousCheckpointInboxRollingHash` sources the previous checkpoint's end value (returns zero for checkpoint ≤ 1). The sequencer, validator, and prover populate the header field; the orchestrator threads per-base start hashes; the prover-node publisher fills the two new `PublicInputArgs`.
- `RootRollupPublicInputs` gains the pair with matching serialization, conversion, factories and viem types.

- `CHECKPOINT_HEADER_LENGTH` 13 → 14, `BLOCK_ROLLUP_PUBLIC_INPUTS_LENGTH` 56 → 58, `CHECKPOINT_ROLLUP_PUBLIC_INPUTS_LENGTH` 149 → 151, `ROOT_ROLLUP_PUBLIC_INPUTS_LENGTH` 111 → 113.
- Circuit ABIs changed, so the base branch's committed `pinned-build.tar.gz` no longer matches the compiled circuits. It is dropped here rather than carried stale — `bootstrap.sh` recompiles from source whenever the pin is absent — and regenerated once the ABI settles. (#24587 keeps the base pin untouched, so the artifacts stay pinned on the train until this PR.)

- `yarn build` green; `forge test` green (870 passed); noir rollup-lib root suites green (176 passed).
- stdlib serde, prover-node publisher, and the checkpoint-sub-tree / top-tree orchestrator suites green.
- Cross-chain `l1_to_l2` e2e confirms the header field round-trips through L1 and the archiver (decoded `inboxRollingHash` = 0 for the genesis checkpoint, as expected). This suite is registered flaky (`.test_patterns.yml`); the repeated-consumption cases exhibit a pre-existing nullifier-sync timing race unrelated to this change.

Replaces #24600.
spalladino added a commit that referenced this pull request Aug 18, 2026
…r trees (A-1377)

AZIP-22 Fast Inbox, FI-07. First L1 PR of the series; a follow-up stacks the `propose`-side streaming validation on top of this branch.

Stacked on #24781 (`spl/a-1432-same-block-msgs`), the tip of the Fast Inbox circuits stack (#24587 → #24600 → #24603 → #24612 → #24759 → #24781). That stack already carries the small L1 changes this feature builds on — the header's `inboxRollingHash` field, the two epoch-proof public inputs, and the regenerated checkpoint fixtures — so this PR adds only the bucket machinery on top.

## What

`sendL2Message` additionally maintains the **consensus rolling hash** — the truncated-per-link sha256 chain the circuits recompute (`h' = sha256ToField(h || leaf)`, genesis zero; new `Hash.accumulateInboxRollingHash`) — and snapshots it into a fixed-size ring of **buckets**. Frontier trees, `LAG`, the legacy `consume()` flow, and the legacy `bytes16` keccak rolling hash are untouched (the legacy hash now carries a TODO to remove it at cleanup); buckets are completely inert for the legacy flow.

Per the pinned design decisions:

- **Bucket struct** `{rollingHash | totalMsgCount: uint64, timestamp: uint64, msgCount: uint32}` packs into two slots; `totalMsgCount` is the Inbox-wide cumulative count (what the censorship cap-escape check reads), `timestamp` is the bucket's opening L1 block timestamp (recency checks are done in seconds), `msgCount` sits in slot 2's spare bits so the per-bucket cap costs no extra storage access.
- **Ring** indexed by dense bucket sequence number (`seq % BUCKET_RING_SIZE`), 1024 entries in production, sized as an immutable constructor parameter. `getBucket(seq)` reverts with `Inbox__BucketOutOfWindow` outside the live window (`seq <= current < seq + ringSize`, same idiom as the Rollup's roundabout). Overwrite protection for unconsumed buckets is deliberately **not** enforced yet (happy path assumes the ring never wraps into live data).
- **Bucket boundaries**: a bucket only holds messages from a single L1 block; the first message of a new block opens the next bucket, and the 257th message within one block (`MAX_MSGS_PER_BUCKET = 256`, the post-flip per-L2-block insertion cap) **rolls over** into the next bucket with the same timestamp. New buckets inherit the rolling hash and cumulative count, so the chain is continuous across buckets.
- **Genesis base case**: bucket 0 is `{0, 0, deployTime}` and never absorbs (even for a message sent in the deployment block), so a checkpoint consuming nothing against an empty Inbox can always reference a bucket matching its parent's chain position — no special case at `propose`.
- **Event**: `MessageSent` gains `bytes32 inboxRollingHash` and `uint256 bucketSeq` after the legacy args.

## Archiver / TS

The generated `InboxAbi` picks up the new event signature at build time; `MessageSentArgs` and the decode in `ethereum/src/contracts/inbox.ts` carry the two new fields, and the archiver keeps relying only on the legacy args for now (`InboxMessage` unchanged). The archiver test fake fills the new fields with placeholders. Nothing on this branch consumes the new values yet — the node-side cross-check of its TS rolling hash against L1-emitted values comes with the archiver bucket-sync work.

## Testing

- New `InboxBuckets.t.sol` covers the roadmap done-when: accumulation within a block, snapshot freezing at block boundaries with chain continuity into the next bucket, 256-cap rollover within one block, ring wraparound + out-of-window reverts on a small ring, the genesis bucket (including a first message in the deploy block), and event contents.
- **Shared rolling-hash test vectors** pinned in #24587's noir tests (`chain(0,[11])`, `chain(0,[11,22,33])`, `chain(0,[1..=256])`, `chain(0x2a,[7])`, `chain(0x2a,[7,8])`) are asserted against the L1 implementation, so L1, TS, and the circuits provably compute the same chain.
- `forge test` at the current stack top: 890 passed, 0 failed, 3 skipped (the `testGetEpochProofPublicInputsVerifiesHeaders` failure previously inherited from the base has been fixed by the rebased base branches). The 13 `InboxBuckets.t.sol` tests all pass (9 behavioral + 4 gas measurements). Existing `MessageSent` expectations in Inbox/TokenPortal/FeeJuicePortal tests updated for the extended event.
- TS: the regenerated `@aztec/l1-artifacts` `InboxAbi` picks up the two new event fields, and `@aztec/ethereum` typechecks clean against it. `@aztec/archiver`'s full typecheck is blocked only by stale Noir-generated artifacts in a transitive dependency (base-branch circuit changes that require the Noir toolchain, not run in this workspace), so it runs in CI; the archiver change is decode-only and mechanical.

## Gas (`sendL2Message`)

Four Forge gas measurements in `InboxBuckets.t.sol` cover the bucket write paths (inline `gasleft()` deltas, matching the `RollupGetters.t.sol` convention). These feed the capacity analysis (max messages per L1 block from gas):

| Scenario | Gas |
|---|---|
| Absorb into an already-open bucket (common per-message case) | 34,468 |
| First message of a new L1 block (opens a bucket via timestamp) | 78,958 |
| Rollover opening mid-block (256-cap reached, same timestamp) | 55,593 |
| First-ever message (cold state struct + bucket 1) | 128,118 |

Caveat for downstream use: these are **warm execution gas including the CALL overhead** from the test harness. They exclude the 21k intrinsic tx cost, calldata gas, and the cold-access surcharge a standalone EOA transaction pays on its first touch of each slot — the capacity analysis must add those separately. The first-ever figure is the cold-storage case for the bucket/state slots, **not** the global worst-case insert: later frontier-tree leaf indices with more levels to hash can cost more.

Also documents the timestamp-key assumption in `_absorbIntoBucket` (post-merge Ethereum increases `block.timestamp` strictly per block; anvil manual-mining can collapse two blocks into one bucket, which is harmless because the consumption cutoff is timestamp-based).


## Ring-size floor

Raises the constructor guard from `_bucketRingSize > 1` to a floor of **512** (`MIN_BUCKET_RING_SIZE`); production stays at 1024. Rationale: the ring must cover the longest stall the chain recovers from on its own — the prune-and-repropose window of 64 checkpoints (2 epochs = 384 L1 blocks) at the natural one-bucket-per-L1-block cadence, so buckets it re-consumes after a prune are not overwritten first — 384 rounded up to the next power of two, kept at or below the production ring. `testRingWraparound` now exercises a real wraparound against a 512-slot ring, and a new `testConstructorRevertsBelowRingFloor` asserts that constructing below the floor reverts. The full capacity/liveness analysis behind this number feeds the AZIP-22 review.



Replaces #24771.
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