feat(l1): AZIP-14 multiple roots per epoch in Outbox#23477
Conversation
5731282 to
a92a8a6
Compare
3fffc14 to
cca8c9b
Compare
| 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 |
| // 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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
15e1b19 to
8c3c9fd
Compare
8c3c9fd to
75a1576
Compare
75a1576 to
771d2bd
Compare
771d2bd to
5b409e7
Compare
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.
5b409e7 to
922b518
Compare
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 bynumCheckpointsInEpoch(K = _end - _start + 1) rather than by insert ordinal.insert(epoch, K, root)writes at slotK-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
rootIndexThe 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 replayingaccumulateCheckpointOutHashesfor K = 1, 2, … until the root matches, or (b) joinRootAddedevents withL2ProofVerifiedto 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 + 1inEpochProofLib.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 isbytes32[MAX_CHECKPOINTS_PER_EPOCH] rootsper epoch keyed byK-1;insertvalidatesK ∈ [1, MAX]and writes;consumeresolvesroots[K-1]and rejects zero (no proof inserted at that depth);getRootData(epoch, K)returns 0 for K=0 or K>MAX; newgetRoots(epoch)returns the full array; the file-level literalMAX_CHECKPOINTS_PER_EPOCH = 32is constructor-asserted equal toConstants.MAX_CHECKPOINTS_PER_EPOCH.l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol: matching signatures;RootAdded(epoch indexed, numCheckpointsInEpoch, root)carries K (only epoch indexed);getRootCountreplaced bygetRoots.l1-contracts/src/core/libraries/rollup/EpochProofLib.sol: computesnumCheckpointsInEpoch = _args.end - _args.start + 1and threads it throughoutbox.insert.l1-contracts/src/core/libraries/Errors.sol: addsOutbox__InvalidNumCheckpointsInEpoch(uint256).l1-contracts/test/portals/:TokenPortal.withdrawandUniswapPortal.swap*thread_numCheckpointsInEpochthrough;OutboxMessageMetadata._rootIndexrenamed 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 viatestGetRootsReturnsAllSlots, 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.