You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(fast-inbox): rolling-hash buckets in the Inbox alongside frontier 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.
0 commit comments