Skip to content

feat(fast-inbox): circuits and L1 for the streaming inbox (part 1/3) - #25036

Open
spalladino wants to merge 11 commits into
merge-train/spartanfrom
spl/fast-inbox-1-circuits-l1
Open

feat(fast-inbox): circuits and L1 for the streaming inbox (part 1/3)#25036
spalladino wants to merge 11 commits into
merge-train/spartanfrom
spl/fast-inbox-1-circuits-l1

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Part 1 of 3 of the Fast Inbox (AZIP-22) stack — circuits and L1.

Previously 8 separate PRs (#24587, #24600, #24603, #24612, #24759, #24781, #24771, #24773). Those are now closed and consolidated here as one squashed commit per issue, each carrying its original PR description in the commit body. Same diff, far fewer PRs to keep rebased.

What it contains

Circuits — message-bundle components in rollup-lib; the sha256 inboxRollingHash chain threaded end-to-end (dual-committed alongside the legacy inHash, so e2e stays green under legacy semantics); per-block L1-to-L2 message bundles with parity moved to the checkpoint root; a block_root_msgs_only_rollup circuit for no-tx blocks that still carry messages; a single variable-size InboxParity<S> proof per checkpoint (S ∈ {64, 256, 1024}), replacing parity-base ×4 + parity-root fan-in; and same-block L1-to-L2 message consumption for non-first blocks, so every block's BlockConstantData.l1_to_l2_tree_snapshot is its own post-bundle root.

L1 — rolling-hash buckets maintained in the Inbox alongside the existing frontier trees (sendL2Message snapshots {rollingHash, cumulativeTotal, key} into a per-L1-block ring with rollover-on-overflow, minimum ring size 512), plus the still-unwired streaming propose-time consumption validation (ProposeLib.validateInboxConsumption).

Nothing here changes consensus behaviour: the legacy consume() / inHash path keeps running, and the new validation is a pure view function not yet called from propose.

Commits (bottom → top)

  1. feat(fast-inbox): message-bundle components in rollup-lib (A-1372) — was feat(fast-inbox): message-bundle components in rollup-lib (A-1372) #24587
  2. feat(fast-inbox): introduce inboxRollingHash end to end, dual with inHash (A-1373) — was feat(fast-inbox): introduce inboxRollingHash end to end, dual with inHash (A-1373) #24600
  3. feat(fast-inbox): per-block L1-to-L2 message bundles, move parity to checkpoint root (A-1374) — was feat(fast-inbox): per-block L1-to-L2 message bundles, move parity to checkpoint root (A-1374) #24603
  4. feat(fast-inbox): add block_root_msgs_only_rollup circuit for no-tx blocks with messages (A-1375) — was feat(fast-inbox): add block_root_msgs_only_rollup circuit for no-tx blocks with messages (A-1375) #24612
  5. feat(fast-inbox): single variable-size InboxParity proof per checkpoint (A-1427) — was feat(fast-inbox): single variable-size InboxParity proof per checkpoint (A-1427) #24759
  6. feat(fast-inbox): same-block L1-to-L2 message consumption for non-first blocks (A-1432) — was feat(fast-inbox): same-block L1-to-L2 message consumption for non-first blocks (A-1432) #24781
  7. feat(fast-inbox): rolling-hash buckets in the Inbox alongside frontier trees (A-1377) — was feat(fast-inbox): rolling-hash buckets in the Inbox alongside frontier trees (A-1377) #24771
  8. feat(fast-inbox): streaming inbox propose validation, unwired (A-1378) — was feat(fast-inbox): streaming inbox propose validation, unwired (A-1378) #24773

Rebase notes

Rebased onto merge-train/spartan at 63aa610685, which includes #25007 (split noir-projects into fnd/ and labs/). All circuit paths moved to noir-projects/fnd/noir-protocol-circuits/. Two conflicts of substance were resolved against the new base:

This stack deletes pinned-build.tar.gz, so it builds circuits from source and runs the gates the pinned path skips (nargo fmt --check, reset-cost staleness, full VK generation).

@spalladino
spalladino requested review from a team, LeilaWang, Thunkar and just-mitch as code owners July 28, 2026 22:37
This was referenced Jul 28, 2026
@spalladino
spalladino force-pushed the spl/fast-inbox-1-circuits-l1 branch from e31be72 to 4a406e6 Compare July 28, 2026 22:57
@spalladino
spalladino force-pushed the spl/fast-inbox-1-circuits-l1 branch from 4a406e6 to 896df6e Compare July 28, 2026 23:10
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.
…Hash (A-1373)

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

## What

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.

## Changes

**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.

## Constants

- `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.)

## Testing

- `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.
…checkpoint root (A-1374)

Stacked on #24600 (A-1373). AZIP-22 Fast Inbox, FI-04.

Moves parity verification from the block-root circuits to the **checkpoint root**, and gives every block-root variant a per-block L1→L2 message bundle. This gets the circuit structure right for per-block messages while keeping behavior bit-identical to today — the flip to compact per-block distribution (FI-14) becomes a constant change plus padding removal, not a circuit restructure.

- **Block-root variants** take a message bundle (`l1_to_l2_messages`, `num_msgs`, frontier hint) and append it to the L1→L2 tree via `append_leaves_to_snapshot` (the A-1372 gadget), absorbing the same leaves into an `L1ToL2MessageSponge` (Poseidon2) threaded across the checkpoint's blocks. First-block variants lose the parity-root proof.
- **`BlockRollupPublicInputs`** drops `in_hash` and the FI-03 `inbox_rolling_hash` pair; gains `is_first_block: bool` and `start_msg_sponge`/`end_msg_sponge`. `is_first_block` takes over `in_hash`'s former structural role (leftmost asserted true at the checkpoint root, every right rollup asserted false at merge) and drives the block-end blob-absorb flag.
- **Block merge** propagates `is_first_block` and `start_msg_sponge` from the left, `end_msg_sponge` from the right; `validate_consecutive_block_rollups` asserts `!right.is_first_block` and `right.start_msg_sponge == left.end_msg_sponge`.
- **Checkpoint root** verifies the moved parity-root proof, asserts `parity.start_sponge == empty` and `merged.end_msg_sponge == parity.end_sponge` (the "blocks inserted exactly the parity-committed list, in order" check — no leaf arrays cross a circuit boundary), and sources the header's `in_hash = parity.sha_root` and `inbox_rolling_hash = parity.end_rolling_hash`.
- **Parity** base absorbs the padded batch into a message sponge; parity root threads it across the four bases alongside the rolling hash.

- New `stdlib/src/messaging/l1_to_l2_message_sponge.ts` mirrors the noir sponge (iv-0, absorb-only). Parity/block/checkpoint-root inputs and `conversion/server.ts` mirror the field changes.
- Orchestrator DAG: parity proofs move from `block-proving-state.ts` to `checkpoint-proving-state.ts`; the sub-tree enqueues parity once per checkpoint and surfaces it in `SubTreeResult`; block roots no longer gate on parity. Transitional wiring: first block carries the checkpoint's messages padded to 1024 (`num_msgs=1024`), non-first blocks carry empty bundles inheriting the checkpoint sponge.

`L1_TO_L2_MESSAGE_SPONGE_LENGTH = 10`; `BLOCK_ROLLUP_PUBLIC_INPUTS_LENGTH` 58 → 76. Circuit ABIs changed, so `pinned-build.tar.gz` is regenerated and committed.

- noir `rollup_lib` 386 passed (adds sponge-threading, `is_first_block` structural, and checkpoint-root parity/sponge-mismatch failure cases); transitional equivalence held (`append_matches_subtree_insert_1024`, unchanged checkpoint-header fixtures).
- `yarn build`, `yarn lint`/`format`, forge, orchestrator suites (15), and touched stdlib suites (22) all green.
- Cross-chain `l1_to_l2` e2e: the real send+consume path (the new per-block bundle circuit path) works; the suite's only two cases are the duplicate re-consumption ones, which hit a pre-existing PXE nullifier-sync issue registered flaky in `.test_patterns.yml`, unrelated to this change.
- A codex cross-layer review found no correctness bugs; it confirmed sponge chunking associativity, tree/list pinning, `is_first_block` soundness, header sourcing/continuity, the blob-flag derivation, and TS↔noir serde order.

This branch now carries the oldest-epoch-first proving-broker scheduling fix (originally the head commit of #24759 / A-1427), moved down to the base of this segment. On the intermediate stack the single prover agent otherwise starves `PARITY_BASE` behind later-epoch `BLOCK_ROOT` jobs, so `long_proving_time` deterministically times out here; with the commit present it passes (`generates proof over multiple epochs`, verified on a bb-capable box). The move is content-neutral at the stack top — only the commit's position changed.

The relocated commit's proving-broker test asserts an older epoch beats a higher-priority proof type from a younger epoch. It needs a proof type that is (a) lower-priority than `PRIVATE_TX_BASE_ROLLUP` and (b) present under the same name across the whole stack, since this commit sits at the base and its content flows up unchanged. No parity name qualifies: it originally used `INBOX_PARITY` (only exists at A-1427 and above), and `PARITY_BASE` only exists at A-1374/A-1375 because A-1427 replaces both `PARITY_BASE`/`PARITY_ROOT` with a single `INBOX_PARITY`. The test now uses `PUBLIC_VM`, which is stable across the entire stack and sits just below `PRIVATE_TX_BASE_ROLLUP` in `PROOF_TYPES_IN_PRIORITY_ORDER` at every branch. Verified with a real `tsc` (not jest) at the owning branch (below the flip), at A-1384 (above the flip), and at the stack top.

Replaces #24603.
…locks with messages (A-1375)

Stacked on #24603 (A-1374). AZIP-22 Fast Inbox, FI-05.

## What

Adds `block_root_msgs_only_rollup` (crate `rollup-block-root-msgs-only`, VK index `BLOCK_ROOT_MSGS_ONLY_ROLLUP_VK_INDEX`) — a **non-first, transaction-less** block that still inserts a non-empty L1→L2 message bundle. This lets a proposer keep draining the Inbox into a checkpoint when the tx pool is dry. It is completely inert pre-flip: nothing in the node produces this shape until FI-12.

## Circuit

Modeled on the first-empty variant but for a mid-checkpoint position (`block_root_msgs_only_rollup.nr`):
- asserts `num_msgs > 0` (a message-only block with no messages has no reason to exist);
- inherits `start_sponge_blob` and `start_msg_sponge` from the previous block (non-empty), sets `is_first_block = false`, appends the bundle to the L1→L2 tree and absorbs the message sponge, threads the start sponge blob through unchanged (no tx effects);
- deliberately cannot be leftmost — the checkpoint root requires the leftmost rollup's `is_first_block` to be true.

Composes via a new `new_from_no_rollups_with_start_sponge_blob` (a `new_from_no_rollups` that takes an inherited sponge blob instead of an empty one; the first-empty variant now delegates to it, preserving its empty-sponge start).

## Wiring

- `BLOCK_ROOT_MSGS_ONLY_ROLLUP_VK_INDEX` added to the vk tree and the `ALLOWED_PREVIOUS_VK_INDICES` of the block-merge and both checkpoint-root variants.
- New crate + Nargo registration; `pinned-build.tar.gz` regenerated.
- TS: proving request type + schema + `ProvingJobResultsMap` entry, the `ServerCircuitProver` interface method and all implementers (bb-prover, `TestCircuitProver`, `MockProver`, `BrokerCircuitProverFacade`), the proving-broker queue map + priority list + job-controller dispatch, and npct artifact/type generation.

## Tests

Noir (`rollup_lib`, 386 passed) covers the roadmap done-when — a checkpoint `[first block, msgs-only block, normal block]` — plus the failure cases: `num_msgs == 0` rejected, a msgs-only block cannot be leftmost, and merge continuity catches a bad hinted `start_msg_sponge`. `yarn build`, lint, and the orchestrator + proving-broker suites (123) are green. Cross-chain `l1_to_l2` e2e: only the two pre-registered-flaky duplicate-consume cases fail (a pre-existing PXE nullifier-sync issue, unrelated). A codex review found no soundness or wiring bugs.

## Deferred to FI-12/FI-15 (orchestrator selection + integration test)

`block-proving-state.ts` selecting this variant, and an orchestrator integration test driving a mid-checkpoint msgs-only block, are intentionally **not** in this PR. Driving one requires reworking the transitional per-block message *distribution*: today the first block carries the whole padded checkpoint bundle and non-first blocks inherit the full checkpoint sponge and absorb nothing, so a msgs-only block absorbing real messages mid-chain would break both the block-merge `right.start_msg_sponge == left.end_msg_sponge` continuity and the checkpoint-root `merged.end_msg_sponge == parity.end_sponge` check. That distribution rework belongs with the sequencer that actually produces these blocks (FI-12) and its live-path e2e (FI-15). The circuit is fully proven by the noir tests, and the existing `BlockProvingState` guard still rejects non-first zero-tx blocks, so nothing can construct the shape until that work lands.

## Proving-result schema (CI fix)

The `ProvingJobResult` Zod discriminated union was missing a member for the new `BLOCK_ROOT_MSGS_ONLY_ROLLUP` request type added here, so once the flip (A-1384) lets the node actually produce a message-only block, decoding its proving result threw `Invalid discriminator value` in `jsonParseWithSchema` and stalled the epoch (surfaced as a cross-chain e2e timeout up-stack). The union member is added alongside the request type in this PR — its natural home, since this is where the type is introduced. A stdlib round-trip regression test (`proving-job-source.test.ts`) serializes a message-only block-root result and asserts it decodes back with `type === BLOCK_ROOT_MSGS_ONLY_ROLLUP`.



Replaces #24612.
…nt (A-1427)

Replaces the parity base (×4) + parity root fan-in with **one variable-size `InboxParity<S>` proof per checkpoint**, S ∈ {64, 256, 1024} (one VK per size; the prover proves the smallest rung ≥ the checkpoint's message count). The parity-root circuit is deleted; the checkpoint root keeps a single parity verification, now accepting the 3-rung VK ladder. Net **+1 VK**.

Stacked on #24612 (`spl/a-1375-msgs-only-block`).

- **`in_hash` unconstrained pass-through.** The circuit no longer builds the sha256 frontier tree. `in_hash` stays in the parity public inputs as an unconstrained hint — the orchestrator supplies the true frontier root via `computeInHashFromL1ToL2Messages`, the circuit echoes it out, and the checkpoint root copies it into the header. L1's `require(header.inHash == inbox.consume())` still passes, so **no L1 changes** (`ConstantsGen.sol` regenerates byte-identical to the base).
- **Sponge real-count decoupling.** Block roots absorb the message sponge at the real count while the L1-to-L2 tree insert stays a padded fixed subtree, via a new `L1ToL2MessageBundle { messages, num_msgs, num_real_msgs }` struct so the real count can be dropped later with minimal change. `num_msgs` is not otherwise pinned in-circuit — documented as a dev-mode gap backstopped by the L1 pending-chain header hash, parallel to `in_hash`.

Threads the single sized proof through the orchestrator, bb-prover, proving broker, and TS bindings — collapsing `getBaseParityProof`/`getRootParityProof` into `getInboxParityProof` and `PARITY_BASE`/`PARITY_ROOT` into one `INBOX_PARITY` request type. The UltraHonk parity benchmark and bb.js debug test move to `InboxParity256`.

- `rollup_lib` nargo suite: 387/387 passing.
- Reviewed by a second model (Fable) and codex sol; their build-break and consistency findings are folded in.
- Full `yarn build`, VK/artifact regen, real proofs, and e2e run in CI (blocked locally by a `bb write_vk` memory-arena OOM on the unchanged `rollup_root` circuit).

`inbox_rolling_hash.nr`'s doc comment claims the sha256 chain "matches the L1 Inbox," but `Inbox.sol` accumulates `bytes16(keccak256(...))`. Harmless today (the rolling hash isn't validated on L1), but the comment should be corrected on the base branch.

The oldest-epoch-first proving-broker scheduling fix that previously headed this branch has been moved down to #24603 (A-1374), where it is required to keep `long_proving_time` green on the intermediate stack. The stack top is unchanged — only the commit's position changed.

Replaces #24759.
…st blocks (A-1432)

## What

Makes every block's `BlockConstantData.l1_to_l2_tree_snapshot` the **post-bundle** snapshot for that block — first block or not — so a public/AVM tx in block N can read the L1-to-L2 messages block N inserts (same-block consumption), instead of only from block N+1.

Previously the non-first tx-carrying variants (`block_root`, `block_root_single_tx`) read their start snapshot from `constants.l1_to_l2_tree_snapshot` and never asserted the post-bundle root, giving next-block visibility for mid-checkpoint insertions. Now they:

- take the start L1-to-L2 snapshot as a **witness input** (`previous_l1_to_l2`), pinned by block-merge continuity (`right.start_state == left.end_state`) to the previous block's end state — the checkpoint root forces the leftmost block to be a first-block variant, so every non-first block has a left neighbour pinning its start;
- append their bundle and assert `validate_l1_to_l2_tree_snapshot_in_constants(constants, new_l1_to_l2)`, exactly like the first-block variants.

The tx-less variants (`block_root_empty_tx_first`, `block_root_msgs_only`) carry no tx constants to read against and already witness their start snapshot, so they are unchanged.

## Soundness

The witnessed start snapshot cannot be forged: block-merge / checkpoint-root continuity asserts `right.start_state == left.end_state`, and the checkpoint root asserts the leftmost block is a first-block variant (whose start is pinned to the previous checkpoint). The start is therefore anchored, and the new assert pins the tx-constants snapshot to the computed post-bundle root. The start is **not** derived from "constants minus the bundle" (not expressible).

## Bit-identical pre-flip (public inputs)

Non-first bundles are empty today, so `new_l1_to_l2 == start == today's constants value`; the new assert passes with unchanged values and the block-root **public inputs / state are unchanged**. The circuit constraints, private-input ABI, proof, and VK do change (hence the regen below). This is a restructure-now / flip-minimal change.

## Flip follow-on (not in this PR)

The recursive pinning of the witnessed `previous_l1_to_l2` is already exercised by `block_merge::tests::consecutive_block_rollups_tests::non_consecutive_l1_to_l2_message_tree_snapshots` (a right block whose `start_state.l1_to_l2_message_tree` — i.e. this witness — doesn't match the left block's end is rejected on continuity).

One flip requirement this change surfaces: today the prover gives every non-first block the checkpoint's post-first-block snapshot as its `previousL1ToL2` (`block-proving-state.ts`), which is correct only while non-first bundles are empty. Once non-first blocks insert their own bundles (A-1384), the prover must instead feed block N+1 the end snapshot of block N plus the matching frontier hint. Flagged for the flip plan (A-1384).

## Tests

Adds negative nargo tests: a non-first block (two-tx and single-tx) whose `constants.l1_to_l2_tree_snapshot` differs from its post-bundle root must fail. Red/green verified locally — with the new asserts removed the four negative tests report "Test passed when it should have failed"; restored, they pass. `rollup_lib` block_root suite green (63 tests), block_merge (45), checkpoint_root structure tests (17).

## TS wiring

Mirrors the new `previous_l1_to_l2` field through `stdlib` (`BlockRootRollupPrivateInputs` / `BlockRootSingleTxRollupPrivateInputs` serialization + factory), the Noir ABI conversion (`server.ts`), and the prover-client block-root input builders (`block-proving-state.ts`), which pass the block's `lastL1ToL2MessageTreeSnapshot` (for non-first blocks, the checkpoint's post-first-block snapshot). `stdlib` builds and its serialization test passes.

## Artifact regen (rides CI / mainframe)

This changes the `block_root` and `block_root_single_tx` circuit ABIs, so it invalidates their VKs, the checkpoint-root/block-root `Prover.toml` sample inputs, and the generated `noir-protocol-circuits-types/src/types/index.ts`. These are **not** regenerated locally (`bb write_vk` OOMs on large circuits on dev boxes; the generated `index.ts` also depends on recompiled circuit artifacts). They ride the seeded-S3-cache mainframe/CI flow, consistent with the rest of the Fast Inbox stack.

## Stack

Stacked on `spl/a-1427-inbox-parity`; the L1 PRs (#24771, #24773) are rebased on top of this. Part of the Fast Inbox stack (umbrella #24774).

Fixes A-1432


Replaces #24781.
…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.
Stacked on #24771 (A-1377). AZIP-22 Fast Inbox, FI-08.

## What

Implements the AZIP's censorship assert as `ProposeLib.validateInboxConsumption` — a new library function that is **not yet called**: today's `inbox.consume()` / `inHash` check in `propose` is untouched, so this is dead code until the flip wires it in.

Given the checkpoint header's `inboxRollingHash`, an unsigned calldata bucket hint, the proposed slot, and the parent checkpoint's cumulative consumed total (which the flip will source from the temp checkpoint log), the function enforces:

1. **End anchoring**: the header's rolling hash must equal the snapshot in `inbox.getBucket(hint)` (`Rollup__InvalidInboxRollingHash`). The hint is a lookup aid only — a wrong hint reverts, it cannot change what gets accepted — and `getBucket` itself rejects hints beyond the current bucket or already overwritten in the ring. A checkpoint consuming nothing references the same bucket as its parent (the genesis bucket for the first checkpoint), so there is no base case.
2. **Mandatory consumption**: the first unconsumed bucket (`hint + 1`) must be absent, **past the cutoff**, or **cap-escaped** (`Rollup__UnconsumedInboxMessages`). The cutoff is the build-frame start minus `INBOX_LAG_SECONDS`: a checkpoint proposed in slot S is built during slot S-1 (proposer pipelining), and validators are not required to act on buckets younger than one L1 slot at build start, so `cutoff = toTimestamp(S-1) - 12`. Cap escape allows stopping when consuming through the next bucket would exceed `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT` (1024) messages since the parent's total. Both comparisons are exact-boundary tested.

The function performs **no Inbox write**. It is a pure `view` that **returns** the consumed cumulative total (`bucket.totalMsgCount`). The flip (FI-14) stores that consumed position as part of the per-checkpoint temp-log record, extended to `{inboxRollingHash, inboxMsgTotal, inboxConsumedBucket}` and written by `propose`; that record is the authoritative consumed position and is prune-consistent, since temp logs rewind with the pending chain. Two new checks guard the returned value: consumption must **move forward** (`Rollup__InboxConsumptionBehindParent`, equal allowed — a proposal cannot consume behind its parent, and the check precedes the delta subtractions so a backwards proposal reverts descriptively rather than underflowing), and the consumed delta cannot exceed `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT` in one checkpoint (`Rollup__TooManyInboxMessagesConsumed`). The Inbox-side proven-consumed cache that ring overwrite protection needs moved to **FI-20**, anchored to the proven tip rather than the pending chain (decided 2026-07-17): a pointer advanced with the pending chain is not prune-safe, since after a prune it would sit ahead of what the replacement chain consumed.

`INBOX_LAG_SECONDS = 12` and `MAX_L1_TO_L2_MSGS_PER_CHECKPOINT = 1024` are defined at file level in `ProposeLib` for now; they mirror the protocol constants and should move into the generated `Constants` library once the Solidity emitter allow-list includes them.

## Testing

New `ProposeInboxConsumption.t.sol` (15 tests) drives the library function through a harness that owns the Inbox and TimeLib storage, covering the roadmap done-when plus boundaries:

- **empty Inbox** (hint 0 / hash 0 passes and returns 0; non-zero hash rejected),
- **exact-cutoff bucket** (timestamp == cutoff must be consumed; cutoff + 1 need not),
- **cap escape** (1025 messages spill into buckets of 256+1: stopping at bucket 4 = exactly 1024 escapes and returns the full cap; stopping at bucket 3 = 1024-through-next does not; parent total shifts the arithmetic and kills the escape),
- **cap upper bound** (consuming more than 1024 in one checkpoint from a fresh parent reverts `Rollup__TooManyInboxMessagesConsumed`),
- **moves forward** (a proposal whose referenced bucket total sits behind the parent reverts `Rollup__InboxConsumptionBehindParent`; an equal reference consumes nothing and returns the unchanged total),
- **stale hash** (previous bucket's hash against the current bucket) and **unknown hash** rejection, plus out-of-window hints (future bucket, ring-overwritten bucket),
- **return value**: the successful paths assert the consumed cumulative total the flip will record (0, 3, cap).

The three former consumed-pointer tests are removed along with the pointer. Full `forge test` at the current rebased tip: 890 passed, 0 failed, 3 skipped (the previously noted `testGetEpochProofPublicInputsVerifiesHeaders` failure inherited from the base has since been fixed).



Replaces #24773.
@spalladino
spalladino force-pushed the spl/fast-inbox-1-circuits-l1 branch from 896df6e to 7977123 Compare July 29, 2026 22:42
…nputs (A-1538)

The `InboxParity` circuit took a `start_sponge` seed and echoed it back in its public inputs, but the seed was
structurally always the empty sponge: the L1-to-L2 message sponge resets per checkpoint and there is exactly one
`InboxParity` proof per checkpoint, so no caller could ever pass anything else. The single production construction
(`CheckpointProvingState.getInboxParityInputs`) passed `L1ToL2MessageSponge.empty()` unconditionally.

- `InboxParity` now seeds its message sponge from the empty sponge in-circuit instead of witnessing it, and
  `ParityPublicInputs` no longer carries `start_sponge`.
- The checkpoint root's "inbox parity message sponge must start empty" assert goes away with the field. The invariant
  it protected is now structural: the verified `InboxParity` proof can only have started from the empty sponge. The
  equality between the parity end sponge and the blocks' accumulated sponge is unchanged.
- Drops the field from the stdlib types, the noir conversion mappers, the test factories, and the parity test
  fixtures. The parity unit test that seeded a non-empty sponge to prove pass-through loses its subject and is
  deleted; the rolling-hash threading it also covered is already covered by `accumulate_inbox_rolling_hash`'s own
  tests (`non_zero_start_matches_reference`, `padding_lanes_are_not_absorbed`).

This changes a public-input layout, so the VK tree root moves; the rollup sample inputs are regenerated in a
follow-up commit batched with the block-root variant merge.
…ere, merge block-root variants (A-1539)

Replaces the "a fully-empty block may only be the first block of a checkpoint" rule with an explicit cap on how many
blocks a checkpoint may contain, asserted in the checkpoint root. Empty (0-tx, 0-msg) blocks become legal at any
position, the six block-root variants collapse to three, and the `is_first_block` public input goes away.

Why: there are no per-block rewards to abuse — L1 pays per proven checkpoint and per mana, never per block — so the
emptiness rule was only weak anti-griefing. Without an explicit cap a proposer could already stuff a checkpoint with
well over a thousand cheap blocks, and the cap bounds the epoch prover's worst case identically for empty and full
blocks. Once it exists the emptiness rule buys nothing, and empty blocks let the sequencer keep a regular block
cadence with no "nothing to include" special case.

Circuits:
- `MAX_BLOCKS_PER_CHECKPOINT` (72, one block per second over the production slot) is added to `constants.nr` and
  emitted to `constants.gen.ts` by the generator, so the node and the circuits share one value. It is not consumed by
  Solidity, so `ConstantsGen.sol` is unchanged: `ProposedHeader` carries no block count, so L1 cannot check the cap at
  propose time and validators must reject over-cap proposals before attesting.
- `CheckpointRootInputsValidator` sums `num_blocks()` over its child rollups and asserts the total is within the cap.
  Both checkpoint root variants share the validator, so both get it.
- The six block roots merge to three, keeping the transaction-count axis (0 / 1 / 2 children) because that is what
  drives proving time: `block_root`, `block_root_single_tx` and `block_root_no_txs` (the former msgs-only variant,
  renamed and now also usable as the first block of a checkpoint). The first/non-first axis disappears: every variant
  takes the start message sponge — and, for the tx-less variant, the start sponge blob — as inputs, which the
  checkpoint root pins to their initial values for the leftmost block and the block merge pins to the previous
  block's end values otherwise. The msgs-only variant's `num_msgs != 0` assert is dropped.
- `is_first_block` is removed from `BlockRollupPublicInputs`, its propagation in `merge_block_rollups`, the right-child
  assert in `validate_consecutive_block_rollups` and the leftmost assert in the checkpoint root's inputs validator.
  The block-end blob absorb still needs to know whether a block is the checkpoint's first (the l1-to-l2 tree root is
  absorbed once per checkpoint), and the composer now derives that from `start_sponge_blob.num_absorbed_fields == 0`
  rather than from a witnessed flag. Every block absorbs its block-end fields, so only the leftmost block can start
  from an uninitialized sponge blob, and `start_sponge_blob` is itself pinned by the checkpoint root and the block
  merge — so the derivation cannot be steered by the prover, while a witnessed flag could have been.
- The checkpoint root's four start-value asserts are now the sole anchoring of a checkpoint to its start; a comment on
  them says so, so a later refactor does not drop one assuming a flag still marks the first block.
- Six block-root VK indices become three; the allowed-index lists in the block merge and both checkpoint root variants
  shrink accordingly. The block merge no longer needs distinct left/right allowed sets, and the single-block
  checkpoint root's footnote about the msgs-only entry being unreachable goes away with the entry itself.
- Deletes the `rollup-block-root-first`, `-first-single-tx`, `-first-empty-tx` crates and the two `-first*-simulated`
  crates; renames `rollup-block-root-msgs-only` to `rollup-block-root-no-txs`.

TypeScript: the same three-variant shape in `stdlib` (proving request types, private-input classes, prover interface),
the circuit bindings and artifact lists, and the orchestrator, which now selects the block root by transaction count
alone and passes the start sponges the position implies.

Tests: checkpoint-root tests for exactly-at-cap and cap+1; a fully empty non-first block and a tx-less leftmost block
in the block-root tests, plus a check that only the first block absorbs the extra blob field; a block-merge test that a
mid-checkpoint block claiming an initialized start sponge blob fails continuity.
Both preceding commits change public-input layouts (`ParityPublicInputs` loses `start_sponge`,
`BlockRollupPublicInputs` loses `is_first_block`), and the block-root VK indices move, so the committed rollup sample
inputs carry a stale ABI and a stale `vk_tree_root`.

Regenerated through the canonical path — `AZTEC_GENERATE_TEST_DATA=1 yarn workspace @aztec/prover-client test
src/test/regenerate_rollup_sample_inputs.test.ts` — against circuits and VKs built from source. Adds the
`rollup-block-root-no-txs` sample, which the merged tx-less block root needs now that it is in the `nargo execute`
list, and drops nothing else: the samples for the deleted first-block variants went with their crates.

Verified with `nargo execute` on all eight regenerated crates (block root, block root single tx, block root no txs,
block merge, both checkpoint roots, checkpoint merge, root): every one solves its witness.

VKs, the VK tree, the generated Noir ABI types and the Solidity verifier are generated-and-gitignored, so they are not
committed here; CI rebuilds them.
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