Skip to content

feat(l1): AZIP-14 multiple roots per epoch in Outbox#23477

Open
spalladino wants to merge 1 commit into
mitch/tmnt-521-rollup-gate-contractfrom
spl/azip-14-multiple-roots-per-epoch
Open

feat(l1): AZIP-14 multiple roots per epoch in Outbox#23477
spalladino wants to merge 1 commit into
mitch/tmnt-521-rollup-gate-contractfrom
spl/azip-14-multiple-roots-per-epoch

Conversation

@spalladino
Copy link
Copy Markdown
Contributor

@spalladino spalladino commented May 21, 2026

Motivation

Today the Outbox stores exactly one L2-to-L1 message root per epoch and overwrites it on every insert. When a partial epoch proof is followed by an extending proof for the same epoch, any user L1 exit built against the earlier root reverts because the on-chain root has been replaced before their transaction lands. Partial-proof users specifically opted in for fast exits, so this is a direct regression of the feature they paid for. Spec: AztecProtocol/governance#33 (AZIP-14).

Approach

The Outbox now stores multiple roots per epoch in a fixed-size array bytes32[MAX_CHECKPOINTS_PER_EPOCH] keyed by numCheckpointsInEpoch (K = _end - _start + 1) rather than by insert ordinal. insert(epoch, K, root) writes at slot K-1. consume(message, epoch, K, leafIndex, path) selects which root to verify against. The nullifier bitmap is shared across every root of the same epoch so a message consumed against one root cannot be replayed against another, and leaf-id stability across extending proofs is delegated to the proving system (called out in the NatSpec).

Why K (numCheckpointsInEpoch) and not a synthetic rootIndex

The original AZIP-14 text used an opaque rootIndex (0, 1, 2, …) tracking insert order. That forces every off-chain consumer (PXE, archiver, block explorers) to recover K for each (epoch, rootIndex) pair before it can build a valid L2-to-L1 membership witness — because the epoch tree's sibling path depends on K: the leftmost K leaves are real checkpoint out-hashes and the rest are zero-padded, so the path for an earlier checkpoint changes as K grows. Off-chain recovery options were (a) recompute K by replaying accumulateCheckpointOutHashes for K = 1, 2, … until the root matches, or (b) join RootAdded events with L2ProofVerified to read K from the rollup-event stream.

Both options work but require new archiver indexing and add coupling. Since the rollup already knows K at insert time (it's literally _args.end - _args.start + 1 in EpochProofLib.submitEpochRootProof), keying storage by K directly removes the recovery problem entirely. The witness builder consumes K, the consume API takes K, the event carries K, the archiver indexes (epoch, K, root) triples. No extra plumbing anywhere.

This deviates from the literal text of AZIP-14. The on-chain security properties are unchanged: still append-only via rollup gating, still shared bitmap per epoch, still leaf-id-stable under the same proving-system trust assumption.

Changes

  • l1-contracts/src/core/messagebridge/Outbox.sol: storage is bytes32[MAX_CHECKPOINTS_PER_EPOCH] roots per epoch keyed by K-1; insert validates K ∈ [1, MAX] and writes; consume resolves roots[K-1] and rejects zero (no proof inserted at that depth); getRootData(epoch, K) returns 0 for K=0 or K>MAX; new getRoots(epoch) returns the full array; the file-level literal MAX_CHECKPOINTS_PER_EPOCH = 32 is constructor-asserted equal to Constants.MAX_CHECKPOINTS_PER_EPOCH.
  • l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol: matching signatures; RootAdded(epoch indexed, numCheckpointsInEpoch, root) carries K (only epoch indexed); getRootCount replaced by getRoots.
  • l1-contracts/src/core/libraries/rollup/EpochProofLib.sol: computes numCheckpointsInEpoch = _args.end - _args.start + 1 and threads it through outbox.insert.
  • l1-contracts/src/core/libraries/Errors.sol: adds Outbox__InvalidNumCheckpointsInEpoch(uint256).
  • l1-contracts/test/portals/: TokenPortal.withdraw and UniswapPortal.swap* thread _numCheckpointsInEpoch through; OutboxMessageMetadata._rootIndex renamed to _numCheckpointsInEpoch.
  • l1-contracts/test/Outbox.t.sol: rewritten for the K-keyed API. 34 tests covering AZIP scenarios (single-K insert/consume, multi-K consume against first/later, cross-root replay rejection in both orders, K=0 / K>MAX / K-with-no-root reverts, RootAdded carries K, getRoots layout, sparse-K layout via testGetRootsReturnsAllSlots, duplicate-root-at-distinct-K, distinct-leafId independent consumes, multi-epoch isolation, N-K fuzz).
  • l1-contracts/test/Rollup.t.sol: the two epoch-proof tests now assert roots land at the correct K values (K=2 after a 1-2 proof, K=1 after a 1-1 proof).
  • l1-contracts/test/outbox/tmnt205.t.sol, l1-contracts/test/portals/TokenPortal.t.sol: consume/withdraw calls updated.

@spalladino spalladino requested a review from just-mitch as a code owner May 21, 2026 16:14
@spalladino spalladino changed the base branch from next to merge-train/spartan May 21, 2026 16:26
@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 5731282 to a92a8a6 Compare May 21, 2026 16:28
@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 3fffc14 to cca8c9b Compare May 22, 2026 07:57
import {Epoch} from "@aztec/core/libraries/TimeLib.sol";
import {BitMaps} from "@oz/utils/structs/BitMaps.sol";

// File-level integer literal so it can be used as a fixed-size array length (Solidity rejects
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solidity 🤦‍♂️

// proof that covered the first `i + 1` checkpoints of this epoch). Unset slots read as zero.
// The array is sized at MAX_CHECKPOINTS_PER_EPOCH because that is the maximum number of
// checkpoints the rollup ever proves in a single epoch.
bytes32[MAX_CHECKPOINTS_PER_EPOCH] roots;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm worried about discoverability of the length of the array here- I think as written user needs to watch events to know what index they should be proving against? We might should add a "max length" field here in EpochData and a getter, and then we can keep the original consume on IOutbox, which defaults to grabbing the most recent root, so user doesn't need to know?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why I added the full getRoots. Users can query that method and in a single call figure out what's the earliest root in the array that includes the checkpoint where their message got mined. I didn't want to add a "max length" field since that would've increased gas costs by having to store more data.

See below for the Clauded pseudocode (see the NEW sections).

computeL2ToL1MembershipWitness(node, outbox, message, txHash, messageIndexInTx?):
  receipt = await node.getTxReceipt(txHash)
  if receipt.epochNumber is undefined or receipt.blockNumber is undefined: return undefined
  epoch = receipt.epochNumber

  [messagesInEpoch, block, txEffect, checkpointsData] = parallel:
    node.getL2ToL1Messages(epoch),
    node.getBlock(receipt.blockNumber),
    node.getTxEffect(txHash),
    node.getCheckpointsData({ epoch })

  if messagesInEpoch is empty or block or txEffect missing: return undefined

  checkpointIndex = checkpointsData.findIndex(c => c.checkpointNumber == block.checkpointNumber)
  if checkpointIndex == -1: return undefined   // tx's checkpoint not yet visible to node
  blockIndex = block.indexWithinCheckpoint
  txIndex   = txEffect.txIndexInBlock

  // NEW — pick the smallest partial-proof root on the Outbox that covers checkpointIndex.
  roots = await outbox.getRoots(epoch)                    // Fr[MAX_CHECKPOINTS_PER_EPOCH]
  numCheckpointsInEpoch = findSmallestCoveringK(roots, checkpointIndex)
  if numCheckpointsInEpoch is undefined: return undefined  // nothing claimable yet for this tx

  // NEW — slice the epoch's message tree to only the first K checkpoints.
  // The existing builder pads to OUT_HASH_TREE_LEAF_COUNT internally, so handing it a shorter
  // outer array is exactly the "first K real, rest zero-padded" tree the Outbox proved against.
  messagesInPartialEpoch = messagesInEpoch.slice(0, numCheckpointsInEpoch)

  if checkpointIndex >= messagesInPartialEpoch.length:
    // Sanity guard — should be impossible since findSmallestCoveringK enforces it.
    throw "node returned a covering K but tx's checkpoint is past it"

  { root, leafIndex, siblingPath } = computeL2ToL1MembershipWitnessFromMessagesInEpoch(
    messagesInPartialEpoch, message, checkpointIndex, blockIndex, txIndex, messageIndexInTx)

  // Cross-check: the recomputed root must equal the root the Outbox is holding for this K.
  // Mismatch = node and L1 disagree about the epoch's contents; fail loud rather than
  // hand back a witness that will revert on chain.
  expected = roots[numCheckpointsInEpoch - 1]
  if !root.equals(expected):
    throw `epoch ${epoch} K=${numCheckpointsInEpoch} root mismatch: ` +
          `local=${root} outbox=${expected}`

  return { epochNumber: epoch, numCheckpointsInEpoch, root, leafIndex, siblingPath }


findSmallestCoveringK(roots, checkpointIndex):
  // To cover the tx, K must be >= checkpointIndex + 1, i.e. roots index i >= checkpointIndex.
  for i in [checkpointIndex .. roots.length - 1]:
    if !roots[i].isZero():
      return i + 1
  return undefined

@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 15e1b19 to 8c3c9fd Compare May 22, 2026 08:39
@spalladino spalladino changed the base branch from merge-train/spartan to mitch/tmnt-521-rollup-gate-contract May 22, 2026 08:46
@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 8c3c9fd to 75a1576 Compare May 22, 2026 09:47
@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 75a1576 to 771d2bd Compare May 22, 2026 10:51
@spalladino spalladino requested a review from a team as a code owner May 22, 2026 10:51
@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 771d2bd to 5b409e7 Compare May 22, 2026 11:23
Implements the spec in AztecProtocol/governance#33. The L1 Outbox stores up
to MAX_CHECKPOINTS_PER_EPOCH roots per epoch, keyed by the partial-proof
depth (numCheckpointsInEpoch, 1-indexed). The nullifier bitmap is shared
across all roots of an epoch, so a message consumed against one partial
proof cannot be replayed against a later extending proof. This removes the
race where a user's pending L1 exit reverted because a later proof
overwrote the root the witness was built against.

L1 contracts
- Outbox.insert(epoch, numCheckpointsInEpoch, root) appends into a fixed-size
  `bytes32[MAX_CHECKPOINTS_PER_EPOCH]` keyed by depth - 1.
- Outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path)
  selects the root slot by depth.
- New view getRoots(epoch) returns the full fixed array; getRootData gains
  the depth parameter.
- TokenPortal/UniswapPortal updated to thread numCheckpointsInEpoch.

Off-chain / yarn-project
- OutboxContract wrapper exposes getRoots and the new consume/getRootData
  signatures; getEpochRootStorageSlot computes
  `keccak256(epoch || 0) + (numCheckpointsInEpoch - 1)`.
- stdlib computeL2ToL1MembershipWitness now takes an OutboxRootsReader,
  queries getRoots, picks the smallest covering depth, slices messagesInEpoch
  to that K before building the tree, cross-checks against the on-chain root,
  and returns numCheckpointsInEpoch in the witness.
- aztec.js portal_manager and end-to-end harness/tests thread the new
  parameter through. Tests that previously computed the witness before the
  proof landed now resolve the epoch via getTxReceipt before advancing.
- RollupCheatCodes.insertOutbox + EpochTestSettler thread
  numCheckpointsInEpoch so the local-network settler still works.
@spalladino spalladino force-pushed the spl/azip-14-multiple-roots-per-epoch branch from 5b409e7 to 922b518 Compare May 22, 2026 11:32
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.

3 participants