From 922b51811697b489f3f0871e49e9ccbedac7610d Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 22 May 2026 09:29:22 +0100 Subject: [PATCH] feat: AZIP-14 multiple roots per epoch in Outbox 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. --- .../solidity/aave_bridge/AavePortal.sol | 38 +- .../example_swap/ExampleTokenPortal.sol | 42 +- .../example_swap/ExampleUniswapPortal.sol | 36 +- .../solidity/nft_bridge/NFTPortal.sol | 4 +- docs/examples/ts/aave_bridge/index.ts | 49 +- docs/examples/ts/example_swap/index.ts | 12 + docs/examples/ts/token_bridge/index.ts | 33 +- .../core/interfaces/messagebridge/IOutbox.sol | 38 +- l1-contracts/src/core/libraries/Errors.sol | 1 + .../core/libraries/rollup/EpochProofLib.sol | 7 +- .../src/core/messagebridge/Outbox.sol | 117 +++- l1-contracts/test/Outbox.t.sol | 611 ++++++++++++++---- l1-contracts/test/Rollup.t.sol | 21 +- l1-contracts/test/outbox/tmnt205.t.sol | 10 +- l1-contracts/test/portals/DataStructures.sol | 1 + l1-contracts/test/portals/TokenPortal.sol | 9 +- l1-contracts/test/portals/TokenPortal.t.sol | 12 +- l1-contracts/test/portals/UniswapPortal.sol | 4 + l1-contracts/test/portals/UniswapPortal.t.sol | 58 +- .../aztec.js/src/ethereum/portal_manager.ts | 5 +- .../aztec/src/testing/epoch_test_settler.ts | 2 +- .../e2e_token_bridge_tutorial_test.test.ts | 5 +- .../l2_to_l1.test.ts | 6 +- .../token_bridge_private.test.ts | 8 +- .../token_bridge_public.test.ts | 8 +- .../epochs_partial_proof_multi_root.test.ts | 333 ++++++++++ .../end-to-end/src/e2e_p2p/add_rollup.test.ts | 18 +- .../src/shared/cross_chain_test_harness.ts | 13 +- .../end-to-end/src/shared/uniswap_l1_l2.ts | 85 ++- yarn-project/ethereum/src/contracts/outbox.ts | 41 +- .../ethereum/src/test/rollup_cheat_codes.ts | 8 +- .../src/messaging/l2_to_l1_membership.ts | 91 ++- 32 files changed, 1372 insertions(+), 354 deletions(-) create mode 100644 yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts diff --git a/docs/examples/solidity/aave_bridge/AavePortal.sol b/docs/examples/solidity/aave_bridge/AavePortal.sol index 8a97256b4547..805b0479e2c1 100644 --- a/docs/examples/solidity/aave_bridge/AavePortal.sol +++ b/docs/examples/solidity/aave_bridge/AavePortal.sol @@ -34,13 +34,9 @@ contract AavePortal { bool private _initialized; - function initialize( - address _registry, - address _underlying, - address _aToken, - address _aavePool, - bytes32 _l2Bridge - ) external { + function initialize(address _registry, address _underlying, address _aToken, address _aavePool, bytes32 _l2Bridge) + external + { require(!_initialized, "Already initialized"); _initialized = true; @@ -55,6 +51,7 @@ contract AavePortal { inbox = rollup.getInbox(); rollupVersion = rollup.getVersion(); } + // docs:end:portal_setup // docs:start:portal_deposit_to_aave @@ -65,6 +62,7 @@ contract AavePortal { uint256 _amount, bool _withCaller, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external { @@ -74,31 +72,28 @@ contract AavePortal { recipient: DataStructures.L1Actor(address(this), block.chainid), content: Hash.sha256ToField( abi.encodeWithSignature( - "withdraw(address,uint256,address)", - _recipient, - _amount, - _withCaller ? msg.sender : address(0) + "withdraw(address,uint256,address)", _recipient, _amount, _withCaller ? msg.sender : address(0) ) ) }); // Consume the message from the outbox (verifies merkle proof) - outbox.consume(message, _epoch, _leafIndex, _path); + outbox.consume(message, _epoch, _numCheckpointsInEpoch, _leafIndex, _path); // Deposit into Aave instead of sending tokens to the recipient. // The portal must already hold the underlying tokens (pre-funded or bridged separately). underlying.approve(address(aavePool), _amount); aavePool.supply(address(underlying), _amount, address(this), 0); } + // docs:end:portal_deposit_to_aave // docs:start:portal_claim_public /// @notice Withdraw from Aave and send an L1->L2 message to mint tokens publicly on L2 - function claimFromAavePublic( - uint256 _aTokenAmount, - bytes32 _to, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function claimFromAavePublic(uint256 _aTokenAmount, bytes32 _to, bytes32 _secretHash) + external + returns (bytes32, uint256) + { // Withdraw from Aave (returns underlying + yield) aToken.approve(address(aavePool), _aTokenAmount); uint256 withdrawn = aavePool.withdraw(address(underlying), _aTokenAmount, address(this)); @@ -111,22 +106,19 @@ contract AavePortal { (bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, _secretHash); return (key, index); } + // docs:end:portal_claim_public // docs:start:portal_claim_private /// @notice Withdraw from Aave and send an L1->L2 message to mint tokens privately on L2 - function claimFromAavePrivate( - uint256 _aTokenAmount, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function claimFromAavePrivate(uint256 _aTokenAmount, bytes32 _secretHash) external returns (bytes32, uint256) { // Withdraw from Aave (returns underlying + yield) aToken.approve(address(aavePool), _aTokenAmount); uint256 withdrawn = aavePool.withdraw(address(underlying), _aTokenAmount, address(this)); // Send L1->L2 message for private minting DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = - Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", withdrawn)); + bytes32 contentHash = Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", withdrawn)); (bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, _secretHash); return (key, index); diff --git a/docs/examples/solidity/example_swap/ExampleTokenPortal.sol b/docs/examples/solidity/example_swap/ExampleTokenPortal.sol index 5e728b795604..c6729f81e180 100644 --- a/docs/examples/solidity/example_swap/ExampleTokenPortal.sol +++ b/docs/examples/solidity/example_swap/ExampleTokenPortal.sol @@ -27,11 +27,7 @@ contract ExampleTokenPortal { uint256 public rollupVersion; /// @dev No access control for simplicity. A production contract should restrict this to the deployer/owner. - function initialize( - address _registry, - address _underlying, - bytes32 _l2Bridge - ) external { + function initialize(address _registry, address _underlying, bytes32 _l2Bridge) external { registry = IRegistry(_registry); underlying = IERC20(_underlying); l2Bridge = _l2Bridge; @@ -41,37 +37,32 @@ contract ExampleTokenPortal { inbox = rollup.getInbox(); rollupVersion = rollup.getVersion(); } + // docs:end:example_token_portal // docs:start:deposit_to_aztec_public /// @notice Deposit tokens and send L1->L2 message for public minting on Aztec - function depositToAztecPublic( - bytes32 _to, - uint256 _amount, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function depositToAztecPublic(bytes32 _to, uint256 _amount, bytes32 _secretHash) + external + returns (bytes32, uint256) + { DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = Hash.sha256ToField( - abi.encodeWithSignature("mint_to_public(bytes32,uint256)", _to, _amount) - ); + bytes32 contentHash = + Hash.sha256ToField(abi.encodeWithSignature("mint_to_public(bytes32,uint256)", _to, _amount)); underlying.safeTransferFrom(msg.sender, address(this), _amount); return inbox.sendL2Message(actor, contentHash, _secretHash); } + // docs:end:deposit_to_aztec_public /// @notice Deposit tokens and send L1->L2 message for private minting on Aztec - function depositToAztecPrivate( - uint256 _amount, - bytes32 _secretHash - ) external returns (bytes32, uint256) { + function depositToAztecPrivate(uint256 _amount, bytes32 _secretHash) external returns (bytes32, uint256) { DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = Hash.sha256ToField( - abi.encodeWithSignature("mint_to_private(uint256)", _amount) - ); + bytes32 contentHash = Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", _amount)); underlying.safeTransferFrom(msg.sender, address(this), _amount); @@ -80,10 +71,12 @@ contract ExampleTokenPortal { // docs:start:withdraw /// @notice Withdraw tokens after consuming an L2->L1 message. + /// @param _numCheckpointsInEpoch The partial-proof depth (1-indexed) the witness was built against. function withdraw( address _recipient, uint256 _amount, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external { @@ -91,16 +84,11 @@ contract ExampleTokenPortal { sender: DataStructures.L2Actor(l2Bridge, rollupVersion), recipient: DataStructures.L1Actor(address(this), block.chainid), content: Hash.sha256ToField( - abi.encodeWithSignature( - "withdraw(address,uint256,address)", - _recipient, - _amount, - msg.sender - ) + abi.encodeWithSignature("withdraw(address,uint256,address)", _recipient, _amount, msg.sender) ) }); - outbox.consume(message, _epoch, _leafIndex, _path); + outbox.consume(message, _epoch, _numCheckpointsInEpoch, _leafIndex, _path); underlying.safeTransfer(_recipient, _amount); } diff --git a/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol b/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol index 0b62ece196a4..fb62d76f8ff5 100644 --- a/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol +++ b/docs/examples/solidity/example_swap/ExampleUniswapPortal.sol @@ -33,6 +33,7 @@ contract ExampleUniswapPortal { outbox = rollup.getOutbox(); rollupVersion = rollup.getVersion(); } + // docs:end:example_uniswap_portal // docs:start:swap_public @@ -49,19 +50,15 @@ contract ExampleUniswapPortal { bytes32 _secretHashForL1ToL2Message, // Outbox message metadata for the two L2->L1 messages Epoch[2] calldata _epochs, + uint256[2] calldata _numCheckpointsInEpochs, uint256[2] calldata _leafIndices, bytes32[][2] calldata _paths ) external returns (bytes32, uint256) { IERC20 outputAsset = ExampleTokenPortal(_outputTokenPortal).underlying(); // Message 1: Consume the token bridge exit message (withdraw input tokens) - ExampleTokenPortal(_inputTokenPortal).withdraw( - address(this), - _inAmount, - _epochs[0], - _leafIndices[0], - _paths[0] - ); + ExampleTokenPortal(_inputTokenPortal) + .withdraw(address(this), _inAmount, _epochs[0], _numCheckpointsInEpochs[0], _leafIndices[0], _paths[0]); // Message 2: Consume the uniswap swap intent message bytes32 contentHash = Hash.sha256ToField( @@ -84,6 +81,7 @@ contract ExampleUniswapPortal { content: contentHash }), _epochs[1], + _numCheckpointsInEpochs[1], _leafIndices[1], _paths[1] ); @@ -94,12 +92,10 @@ contract ExampleUniswapPortal { // Approve output token portal and deposit back to Aztec outputAsset.approve(_outputTokenPortal, amountOut); - return ExampleTokenPortal(_outputTokenPortal).depositToAztecPublic( - _aztecRecipient, - amountOut, - _secretHashForL1ToL2Message - ); + return ExampleTokenPortal(_outputTokenPortal) + .depositToAztecPublic(_aztecRecipient, amountOut, _secretHashForL1ToL2Message); } + // docs:end:swap_public // docs:start:swap_private @@ -113,19 +109,15 @@ contract ExampleUniswapPortal { bytes32 _secretHashForL1ToL2Message, // Outbox message metadata for the two L2->L1 messages Epoch[2] calldata _epochs, + uint256[2] calldata _numCheckpointsInEpochs, uint256[2] calldata _leafIndices, bytes32[][2] calldata _paths ) external returns (bytes32, uint256) { IERC20 outputAsset = ExampleTokenPortal(_outputTokenPortal).underlying(); // Message 1: Consume the token bridge exit message (withdraw input tokens) - ExampleTokenPortal(_inputTokenPortal).withdraw( - address(this), - _inAmount, - _epochs[0], - _leafIndices[0], - _paths[0] - ); + ExampleTokenPortal(_inputTokenPortal) + .withdraw(address(this), _inAmount, _epochs[0], _numCheckpointsInEpochs[0], _leafIndices[0], _paths[0]); // Message 2: Consume the uniswap swap intent message bytes32 contentHash = Hash.sha256ToField( @@ -147,6 +139,7 @@ contract ExampleUniswapPortal { content: contentHash }), _epochs[1], + _numCheckpointsInEpochs[1], _leafIndices[1], _paths[1] ); @@ -157,10 +150,7 @@ contract ExampleUniswapPortal { // Approve output token portal and deposit back to Aztec privately outputAsset.approve(_outputTokenPortal, amountOut); - return ExampleTokenPortal(_outputTokenPortal).depositToAztecPrivate( - amountOut, - _secretHashForL1ToL2Message - ); + return ExampleTokenPortal(_outputTokenPortal).depositToAztecPrivate(amountOut, _secretHashForL1ToL2Message); } // docs:end:swap_private } diff --git a/docs/examples/solidity/nft_bridge/NFTPortal.sol b/docs/examples/solidity/nft_bridge/NFTPortal.sol index 9ba1a1e2a901..8da7aa5742bc 100644 --- a/docs/examples/solidity/nft_bridge/NFTPortal.sol +++ b/docs/examples/solidity/nft_bridge/NFTPortal.sol @@ -31,6 +31,7 @@ contract NFTPortal { inbox = rollup.getInbox(); rollupVersion = rollup.getVersion(); } + // docs:end:portal_setup // docs:start:portal_deposit_and_withdraw @@ -52,6 +53,7 @@ contract NFTPortal { function withdraw( uint256 tokenId, Epoch epoch, + uint256 numCheckpointsInEpoch, uint256 leafIndex, bytes32[] calldata path ) external { @@ -62,7 +64,7 @@ contract NFTPortal { content: Hash.sha256ToField(abi.encodePacked(tokenId, msg.sender)) }); - outbox.consume(message, epoch, leafIndex, path); + outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path); // Unlock NFT nftContract.transferFrom(address(this), msg.sender, tokenId); diff --git a/docs/examples/ts/aave_bridge/index.ts b/docs/examples/ts/aave_bridge/index.ts index 9fe9353f2768..1665a2a5df4d 100644 --- a/docs/examples/ts/aave_bridge/index.ts +++ b/docs/examples/ts/aave_bridge/index.ts @@ -4,6 +4,7 @@ import { SetPublicAuthwitContractInteraction } from "@aztec/aztec.js/authorizati import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient, waitForNode } from "@aztec/aztec.js/node"; import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { OutboxContract } from "@aztec/ethereum/contracts"; import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; import { sha256ToField } from "@aztec/foundation/crypto/sha256"; import { @@ -24,11 +25,16 @@ import { AaveBridgeContract } from "./artifacts/AaveBridge.js"; // docs:start:setup // Setup L1 client using anvil's default mnemonic const MNEMONIC = "test test test test test test test test test test test junk"; -const l1Client = createExtendedL1Client([process.env.ETHEREUM_HOST ?? "http://localhost:8545"], MNEMONIC); +const l1Client = createExtendedL1Client( + [process.env.ETHEREUM_HOST ?? "http://localhost:8545"], + MNEMONIC, +); // Setup L2 using Aztec's local network console.log("Setting up L2...\n"); -const node = createAztecNodeClient(process.env.AZTEC_NODE_URL ?? "http://localhost:8080"); +const node = createAztecNodeClient( + process.env.AZTEC_NODE_URL ?? "http://localhost:8080", +); await waitForNode(node); const aztecWallet = await EmbeddedWallet.create(node, { ephemeral: true }); const [accData] = await getInitialTestAccountsData(); @@ -204,7 +210,11 @@ const burnAuthwit = await SetPublicAuthwitContractInteraction.create( account.address, { caller: l2Bridge.address, - action: l2Token.methods.burn_public(account.address, amountToDeposit, burnNonce), + action: l2Token.methods.burn_public( + account.address, + amountToDeposit, + burnNonce, + ), }, true, ); @@ -231,7 +241,10 @@ console.log(`Exit sent (block: ${exitReceipt.blockNumber})`); // toFunctionSelector computes keccak256 of the signature and takes the first 4 bytes. const portalEthAddress = EthAddress.fromString(portalAddress.toString()); const withdrawContent = sha256ToField([ - Buffer.from(toFunctionSelector("withdraw(address,uint256,address)").substring(2), "hex"), + Buffer.from( + toFunctionSelector("withdraw(address,uint256,address)").substring(2), + "hex", + ), portalEthAddress.toBuffer32(), new Fr(amountToDeposit).toBuffer(), EthAddress.ZERO.toBuffer32(), @@ -258,19 +271,30 @@ if (!exitReceipt.blockNumber) { } const exitBlockNumber = exitReceipt.blockNumber; console.log("Waiting for block to be proven..."); -let provenBlockNumber = await node.getBlockNumber('proven'); +let provenBlockNumber = await node.getBlockNumber("proven"); while (provenBlockNumber < exitBlockNumber) { console.log( ` Waiting... (proven: ${provenBlockNumber}, needed: ${exitBlockNumber})`, ); await new Promise((resolve) => setTimeout(resolve, 10000)); - provenBlockNumber = await node.getBlockNumber('proven'); + provenBlockNumber = await node.getBlockNumber("proven"); } console.log("Block proven!\n"); -// Compute the membership witness using the message hash and the L2 tx hash -const witness = await computeL2ToL1MembershipWitness(node, msgLeaf, exitReceipt.txHash); +// Compute the membership witness using the message hash and the L2 tx hash. +// The Outbox is queried to pick the smallest partial-proof root that covers the tx's checkpoint. +const outbox = new OutboxContract( + l1Client, + nodeInfo.l1ContractAddresses.outboxAddress, +); +const witness = await computeL2ToL1MembershipWitness( + node, + outbox, + msgLeaf, + exitReceipt.txHash, +); const epoch = witness!.epochNumber; +const numCheckpointsInEpoch = witness!.numCheckpointsInEpoch; const siblingPathHex = witness!.siblingPath .toBufferArray() @@ -290,6 +314,7 @@ const depositToAaveHash = await l1Client.writeContract({ amountToDeposit, false, // withCaller = false (matches caller_on_l1 = address(0)) BigInt(epoch), + BigInt(numCheckpointsInEpoch), BigInt(witness!.leafIndex), siblingPathHex, ], @@ -369,7 +394,9 @@ await mine2Blocks(aztecWallet, account.address); // The mock Aave pool returns 10% yield, so 500 DAI becomes 550 DAI const expectedWithYield = amountToDeposit + (amountToDeposit * 1000n) / 10000n; -console.log(`Expected amount with yield: ${expectedWithYield / 10n ** 18n} tokens`); +console.log( + `Expected amount with yield: ${expectedWithYield / 10n ** 18n} tokens`, +); // On L2: consume the L1->L2 message and mint tokens (with yield) console.log("Claiming tokens on L2..."); @@ -392,7 +419,9 @@ const expectedFinal = initialRemaining + expectedWithYield; // 500 + 550 = 1050 console.log(`Initial deposit: ${depositAmount / 10n ** 18n} tokens`); console.log(`Deposited to Aave: ${amountToDeposit / 10n ** 18n} tokens`); -console.log(`Yield earned (10%): ${(expectedWithYield - amountToDeposit) / 10n ** 18n} tokens`); +console.log( + `Yield earned (10%): ${(expectedWithYield - amountToDeposit) / 10n ** 18n} tokens`, +); console.log(`Expected balance: ${expectedFinal / 10n ** 18n} tokens`); console.log(`Actual balance: ${finalBalance / 10n ** 18n} tokens`); console.log( diff --git a/docs/examples/ts/example_swap/index.ts b/docs/examples/ts/example_swap/index.ts index 8838512e7b4f..c9ef13ccab86 100644 --- a/docs/examples/ts/example_swap/index.ts +++ b/docs/examples/ts/example_swap/index.ts @@ -5,6 +5,7 @@ import { SetPublicAuthwitContractInteraction } from "@aztec/aztec.js/authorizati import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient, waitForNode } from "@aztec/aztec.js/node"; import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { OutboxContract } from "@aztec/ethereum/contracts"; import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; import { sha256ToField } from "@aztec/foundation/crypto/sha256"; import { TokenContract } from "@aztec/noir-contracts.js/Token"; @@ -446,8 +447,14 @@ const exitMsgLeaf = computeL2ToL1MessageHash({ // docs:end:consume_l1_messages_setup // docs:start:consume_l1_messages_witnesses +// The Outbox is queried to pick the smallest partial-proof root that covers each tx's checkpoint. +const outbox = new OutboxContract( + l1Client, + nodeInfo.l1ContractAddresses.outboxAddress, +); const exitWitness = await computeL2ToL1MembershipWitness( node, + outbox, exitMsgLeaf, swapReceipt.txHash, ); @@ -500,6 +507,7 @@ const swapMsgLeaf = computeL2ToL1MessageHash({ const swapWitness = await computeL2ToL1MembershipWitness( node, + outbox, swapMsgLeaf, swapReceipt.txHash, ); @@ -527,6 +535,10 @@ const l1SwapHash = await l1Client.writeContract({ size: 32, }), [BigInt(exitWitness!.epochNumber), BigInt(swapWitness!.epochNumber)], + [ + BigInt(exitWitness!.numCheckpointsInEpoch), + BigInt(swapWitness!.numCheckpointsInEpoch), + ], [BigInt(exitWitness!.leafIndex), BigInt(swapWitness!.leafIndex)], [exitSiblingPath, swapSiblingPath], ], diff --git a/docs/examples/ts/token_bridge/index.ts b/docs/examples/ts/token_bridge/index.ts index 997c3cadad62..1bd8ab21ec74 100644 --- a/docs/examples/ts/token_bridge/index.ts +++ b/docs/examples/ts/token_bridge/index.ts @@ -4,6 +4,7 @@ import { AztecAddress, EthAddress } from "@aztec/aztec.js/addresses"; import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient } from "@aztec/aztec.js/node"; import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { OutboxContract } from "@aztec/ethereum/contracts"; import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; import { sha256ToField } from "@aztec/foundation/crypto/sha256"; import { @@ -63,7 +64,10 @@ console.log(`NFTPortal: ${portalAddress}\n`); // docs:start:deploy_l2_contracts console.log("Deploying L2 contracts...\n"); -const { contract: l2Nft } = await NFTPunkContract.deploy(aztecWallet, account.address).send({ +const { contract: l2Nft } = await NFTPunkContract.deploy( + aztecWallet, + account.address, +).send({ from: account.address, }); @@ -289,7 +293,7 @@ const msgLeaf = computeL2ToL1MessageHash({ console.log("Waiting for block to be proven..."); console.log(` Exit block number: ${exitReceipt.blockNumber}`); -let provenBlockNumber = await node.getBlockNumber('proven'); +let provenBlockNumber = await node.getBlockNumber("proven"); console.log(` Current proven block: ${provenBlockNumber}`); while (provenBlockNumber < exitReceipt.blockNumber!) { @@ -297,14 +301,25 @@ while (provenBlockNumber < exitReceipt.blockNumber!) { ` Waiting... (proven: ${provenBlockNumber}, needed: ${exitReceipt.blockNumber})`, ); await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds - provenBlockNumber = await node.getBlockNumber('proven'); + provenBlockNumber = await node.getBlockNumber("proven"); } console.log("Block proven!\n"); -// Compute the membership witness using the message hash and the L2 tx hash -const witness = await computeL2ToL1MembershipWitness(node, msgLeaf, exitReceipt.txHash); +// Compute the membership witness using the message hash and the L2 tx hash. +// The Outbox is queried to pick the smallest partial-proof root that covers the tx's checkpoint. +const outbox = new OutboxContract( + l1Client, + nodeInfo.l1ContractAddresses.outboxAddress, +); +const witness = await computeL2ToL1MembershipWitness( + node, + outbox, + msgLeaf, + exitReceipt.txHash, +); const epoch = witness!.epochNumber; +const numCheckpointsInEpoch = witness!.numCheckpointsInEpoch; console.log(` Epoch for block ${exitReceipt.blockNumber}: ${epoch}`); const siblingPathHex = witness!.siblingPath @@ -319,7 +334,13 @@ const withdrawHash = await l1Client.writeContract({ address: portalAddress.toString() as `0x${string}`, abi: NFTPortal.abi, functionName: "withdraw", - args: [tokenId, BigInt(epoch), BigInt(witness!.leafIndex), siblingPathHex], + args: [ + tokenId, + BigInt(epoch), + BigInt(numCheckpointsInEpoch), + BigInt(witness!.leafIndex), + siblingPathHex, + ], }); await l1Client.waitForTransactionReceipt({ hash: withdrawHash }); console.log("NFT withdrawn to L1\n"); diff --git a/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol b/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol index d0005f39bde1..1bc4fd14facd 100644 --- a/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol +++ b/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol @@ -5,6 +5,10 @@ pragma solidity >=0.8.27; import {DataStructures} from "../../libraries/DataStructures.sol"; import {Epoch} from "../../libraries/TimeLib.sol"; +// File-level integer literal so it can be used as a fixed-size array length. MUST equal +// `Constants.MAX_CHECKPOINTS_PER_EPOCH`; the Outbox constructor enforces this at deploy time. +uint256 constant MAX_CHECKPOINTS_PER_EPOCH = 32; + /** * @title IOutbox * @author Aztec Labs @@ -12,18 +16,24 @@ import {Epoch} from "../../libraries/TimeLib.sol"; * and will be consumed by the portal contracts. */ interface IOutbox { - event RootAdded(Epoch indexed epoch, bytes32 indexed root); + event RootAdded(Epoch indexed epoch, uint256 numCheckpointsInEpoch, bytes32 root); event MessageConsumed(Epoch indexed epoch, bytes32 indexed root, bytes32 indexed messageHash, uint256 leafId); // docs:start:outbox_insert /** - * @notice Inserts the root of a merkle tree containing all of the L2 to L1 messages in an epoch specified by _epoch. + * @notice Inserts the root of a merkle tree containing all of the L2 to L1 messages in an epoch + * after a proof covering the first `_numCheckpointsInEpoch` checkpoints of that epoch lands. * @dev Only callable by the rollup contract * @dev Emits `RootAdded` upon inserting the root successfully + * @dev Successive inserts for the same epoch with larger `_numCheckpointsInEpoch` values do not + * disturb earlier entries, so users with witnesses built against an earlier partial proof can still + * consume them. * @param _epoch - The epoch in which the L2 to L1 messages reside + * @param _numCheckpointsInEpoch - The number of checkpoints the inserting proof covered in this + * epoch. Must be in [1, MAX_CHECKPOINTS_PER_EPOCH]. * @param _root - The merkle root of the tree where all the L2 to L1 messages are leaves */ - function insert(Epoch _epoch, bytes32 _root) external; + function insert(Epoch _epoch, uint256 _numCheckpointsInEpoch, bytes32 _root) external; // docs:end:outbox_insert // docs:start:outbox_consume @@ -33,6 +43,9 @@ interface IOutbox { * @dev Emits `MessageConsumed` when consuming messages * @param _message - The L2 to L1 message * @param _epoch - The epoch that contains the message we want to consume + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root this + * consume verifies against. The caller's witness path must have been built against the epoch tree + * padded to that number of real checkpoints. * @param _leafIndex - The index at the level in the epoch message tree where the message is located * @param _path - The sibling path used to prove inclusion of the message, the _path length depends * on the location of the L2 to L1 message in the epoch message tree. @@ -40,6 +53,7 @@ interface IOutbox { function consume( DataStructures.L2ToL1Msg calldata _message, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external; @@ -56,12 +70,24 @@ interface IOutbox { // docs:end:outbox_has_message_been_consumed_at_epoch_and_index /** - * @notice Fetch the root data for a given epoch - * Returns (0, 0) if the epoch is not proven + * @notice Fetch the root data for a given epoch and partial-proof depth. + * Returns 0 if no proof has been inserted at that depth. * * @param _epoch - The epoch to fetch the root data for + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root to fetch * * @return bytes32 - The root of the merkle tree containing the L2 to L1 messages */ - function getRootData(Epoch _epoch) external view returns (bytes32); + function getRootData(Epoch _epoch, uint256 _numCheckpointsInEpoch) external view returns (bytes32); + + /** + * @notice Fetch every root stored for a given epoch. The returned array has + * MAX_CHECKPOINTS_PER_EPOCH entries; slot `i` holds the root for + * `numCheckpointsInEpoch = i + 1`, or zero if no proof of that depth has been inserted. + * + * @param _epoch - The epoch to fetch the roots for + * + * @return bytes32[] - The roots stored for this epoch. + */ + function getRoots(Epoch _epoch) external view returns (bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory); } diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index fd0adb66cae1..f407cc5f90b1 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -48,6 +48,7 @@ library Errors { error Outbox__NothingToConsumeAtEpoch(Epoch epoch); // 0x5e3d32ce error Outbox__PathTooLong(); error Outbox__LeafIndexOutOfBounds(uint256 leafIndex, uint256 pathLength); + error Outbox__InvalidNumCheckpointsInEpoch(uint256 numCheckpointsInEpoch); // Rollup error Rollup__InsufficientBondAmount(uint256 minimum, uint256 provided); // 0xa165f276 diff --git a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol index ca52d60f5b01..5e84462ab748 100644 --- a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol +++ b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol @@ -127,8 +127,11 @@ library EpochProofLib { // a partial epoch cannot produce a non-empty out hash and later revert to an empty one as more checkpoints are // included. Therefore, it is safe to skip insertion when the out hash is empty. if (_args.args.outHash != bytes32(Constants.EMPTY_EPOCH_OUT_HASH)) { - // Insert L2->L1 messages root into outbox for consumption. - rollupStore.config.outbox.insert(endEpoch, _args.args.outHash); + // Insert L2->L1 messages root into outbox for consumption. The Outbox keys each root by + // the number of checkpoints proven in this epoch so off-chain consumers can map a tx's + // position-within-epoch directly to the smallest proof that covers it. + uint256 numCheckpointsInEpoch = _args.end - _args.start + 1; + rollupStore.config.outbox.insert(endEpoch, numCheckpointsInEpoch, _args.args.outHash); } } diff --git a/l1-contracts/src/core/messagebridge/Outbox.sol b/l1-contracts/src/core/messagebridge/Outbox.sol index 00fea24cf935..e7981abd6a63 100644 --- a/l1-contracts/src/core/messagebridge/Outbox.sol +++ b/l1-contracts/src/core/messagebridge/Outbox.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.27; import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol"; +import {Constants} from "@aztec/core/libraries/ConstantsGen.sol"; import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; import {MerkleLib} from "@aztec/core/libraries/crypto/MerkleLib.sol"; import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; @@ -11,15 +12,39 @@ import {Errors} from "@aztec/core/libraries/Errors.sol"; 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 +// dotted library-member access in array-length positions). MUST equal +// `Constants.MAX_CHECKPOINTS_PER_EPOCH`; the Outbox constructor enforces this at deploy time. +uint256 constant MAX_CHECKPOINTS_PER_EPOCH = 32; + /** * @title Outbox * @author Aztec Labs * @notice Lives on L1 and is used to consume L2 -> L1 messages. Messages are inserted by the Rollup * and will be consumed by the portal contracts. * - * @dev Messages are tracked using unique leaf IDs computed from their position in the epoch's tree structure. - * This design ensures that when longer epoch proofs are submitted (proving more blocks), messages from - * earlier blocks retain their consumed status because their leaf IDs remain stable. + * @dev Each epoch may accumulate multiple message roots when `insert` is called more than once + * (e.g. when a partial epoch proof is followed by an extending proof). Roots are keyed by the + * number of checkpoints proven in that epoch (`numCheckpointsInEpoch`, in [1, MAX_CHECKPOINTS_PER_EPOCH]), + * so an off-chain consumer can map their L2 transaction's position-within-epoch directly to the + * smallest proof that covers it without needing to recover that count from chain history. + * + * The nullifier bitmap is shared across every root of the same epoch, so a message consumed against + * one root cannot be replayed against another root of the same epoch. + * + * Messages are tracked using unique leaf IDs computed from their position in the epoch's tree structure. + * This design ensures that when longer epoch proofs are submitted (proving more checkpoints), messages + * from earlier checkpoints retain their consumed status because their leaf IDs remain stable. + * + * @dev The Outbox does not (and cannot) verify on chain that a given message has the same leaf id + * across two different roots of the same epoch. Leaf-id stability across extending partial-epoch + * proofs is a property the rollup's proving system is expected to uphold (each checkpoint's subtree + * is built only from its own messages, and the epoch tree is padded to a fixed size, so positions + * of already-included messages are preserved when more checkpoints are added). A buggy or malicious + * rollup that submitted two proofs for the same epoch where the same message lived at different + * positions would, on the Outbox side, produce two different leaf ids on the shared bitmap and + * therefore allow that message to be consumed twice. This is the same trust boundary the Outbox + * has always had with the rollup; AZIP-14 does not extend it. * * For detailed information about the tree structure and leaf ID computation, see: * yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts @@ -28,11 +53,15 @@ contract Outbox is IOutbox { using Hash for DataStructures.L2ToL1Msg; using BitMaps for BitMaps.BitMap; - struct RootData { - // This is the outHash in the root rollup's public inputs. - // It represents the root of the epoch tree containing all L2->L1 messages. - bytes32 root; - // Bitmap tracking which messages (by leaf ID) have been consumed. + struct EpochData { + // Slot `i` holds the epoch-tree out-hash root for `numCheckpointsInEpoch = i + 1` (i.e. the + // 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; + // Bitmap tracking which messages (by leaf ID) have been consumed within this epoch. + // The bitmap is shared across every root of the epoch: a message consumed against one + // root cannot be replayed against another root for the same epoch. // Leaf IDs are stable across different epoch proof lengths, ensuring consumed // messages remain marked as consumed when longer proofs are submitted. BitMaps.BitMap nullified; @@ -40,9 +69,16 @@ contract Outbox is IOutbox { IRollup public immutable ROLLUP; uint256 public immutable VERSION; - mapping(Epoch => RootData root) internal roots; + mapping(Epoch epoch => EpochData epochData) internal epochs; constructor(address _rollup, uint256 _version) { + // Keep the file-level literal in lockstep with the generated constant. If this ever fires, + // update MAX_CHECKPOINTS_PER_EPOCH at the top of this file (and IOutbox.sol). + require( + MAX_CHECKPOINTS_PER_EPOCH == Constants.MAX_CHECKPOINTS_PER_EPOCH, + Errors.Outbox__InvalidNumCheckpointsInEpoch(MAX_CHECKPOINTS_PER_EPOCH) + ); + ROLLUP = IRollup(_rollup); VERSION = _version; } @@ -53,15 +89,27 @@ contract Outbox is IOutbox { * @dev Only callable by the rollup contract * @dev Emits `RootAdded` upon inserting the root successfully * + * @dev `_numCheckpointsInEpoch` identifies which partial-proof depth this root corresponds to: + * the rollup proved the first `_numCheckpointsInEpoch` checkpoints of `_epoch`. A subsequent + * insert for the same epoch with a larger `_numCheckpointsInEpoch` adds a new entry without + * disturbing earlier ones, so users with witnesses built against an earlier partial proof can + * still consume them. + * * @param _epoch - The epoch in which the L2 to L1 messages reside + * @param _numCheckpointsInEpoch - The number of checkpoints the inserting proof covered in this + * epoch. Must be in [1, MAX_CHECKPOINTS_PER_EPOCH]. Values outside that range will revert. * @param _root - The merkle root of the tree where all the L2 to L1 messages are leaves */ - function insert(Epoch _epoch, bytes32 _root) external override(IOutbox) { + function insert(Epoch _epoch, uint256 _numCheckpointsInEpoch, bytes32 _root) external override(IOutbox) { require(msg.sender == address(ROLLUP), Errors.Outbox__Unauthorized()); + require( + _numCheckpointsInEpoch >= 1 && _numCheckpointsInEpoch <= MAX_CHECKPOINTS_PER_EPOCH, + Errors.Outbox__InvalidNumCheckpointsInEpoch(_numCheckpointsInEpoch) + ); - roots[_epoch].root = _root; + epochs[_epoch].roots[_numCheckpointsInEpoch - 1] = _root; - emit RootAdded(_epoch, _root); + emit RootAdded(_epoch, _numCheckpointsInEpoch, _root); } /** @@ -72,6 +120,9 @@ contract Outbox is IOutbox { * * @param _message - The L2 to L1 message * @param _epoch - The epoch that contains the message we want to consume + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root this + * consume verifies against. The caller's witness `_path` must have been built against the epoch + * tree padded to that number of real checkpoints. * @param _leafIndex - The index at the level in the wonky tree where the message is located * @param _path - The sibling path used to prove inclusion of the message, the _path length depends * on the location of the L2 to L1 message in the wonky tree. @@ -79,6 +130,7 @@ contract Outbox is IOutbox { function consume( DataStructures.L2ToL1Msg calldata _message, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external override(IOutbox) { @@ -92,22 +144,27 @@ contract Outbox is IOutbox { require(block.chainid == _message.recipient.chainId, Errors.Outbox__InvalidChainId()); - RootData storage rootData = roots[_epoch]; + require( + _numCheckpointsInEpoch >= 1 && _numCheckpointsInEpoch <= MAX_CHECKPOINTS_PER_EPOCH, + Errors.Outbox__NothingToConsumeAtEpoch(_epoch) + ); - bytes32 root = rootData.root; + EpochData storage epochData = epochs[_epoch]; + bytes32 root = epochData.roots[_numCheckpointsInEpoch - 1]; + // A zero root means no proof was ever inserted for this `_numCheckpointsInEpoch`. require(root != bytes32(0), Errors.Outbox__NothingToConsumeAtEpoch(_epoch)); // Compute the unique leaf ID for this message. uint256 leafId = (1 << _path.length) + _leafIndex; - require(!rootData.nullified.get(leafId), Errors.Outbox__AlreadyNullified(_epoch, leafId)); + require(!epochData.nullified.get(leafId), Errors.Outbox__AlreadyNullified(_epoch, leafId)); bytes32 messageHash = _message.sha256ToField(); MerkleLib.verifyMembership(_path, messageHash, _leafIndex, root); - rootData.nullified.set(leafId); + epochData.nullified.set(leafId); emit MessageConsumed(_epoch, root, messageHash, leafId); } @@ -123,19 +180,35 @@ contract Outbox is IOutbox { * @return bool - True if the message has been consumed, false otherwise */ function hasMessageBeenConsumedAtEpoch(Epoch _epoch, uint256 _leafId) external view override(IOutbox) returns (bool) { - return roots[_epoch].nullified.get(_leafId); + return epochs[_epoch].nullified.get(_leafId); } /** - * @notice Fetch the root data for a given epoch - * Returns (0, 0) if the epoch is not proven + * @notice Fetch the root data for a given epoch and partial-proof depth + * Returns 0 if no proof has been inserted at that depth (or if the depth is out of range) * * @param _epoch - The epoch to fetch the root data for + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root to fetch * * @return bytes32 - The root of the merkle tree containing the L2 to L1 messages */ - function getRootData(Epoch _epoch) external view override(IOutbox) returns (bytes32) { - RootData storage rootData = roots[_epoch]; - return rootData.root; + function getRootData(Epoch _epoch, uint256 _numCheckpointsInEpoch) external view override(IOutbox) returns (bytes32) { + if (_numCheckpointsInEpoch == 0 || _numCheckpointsInEpoch > MAX_CHECKPOINTS_PER_EPOCH) { + return bytes32(0); + } + return epochs[_epoch].roots[_numCheckpointsInEpoch - 1]; + } + + /** + * @notice Fetch every root stored for a given epoch. The returned array has + * MAX_CHECKPOINTS_PER_EPOCH entries; slot `i` holds the root for + * `numCheckpointsInEpoch = i + 1`, or zero if no proof of that depth has been inserted. + * + * @param _epoch - The epoch to fetch the roots for + * + * @return bytes32[] - The roots stored for this epoch. + */ + function getRoots(Epoch _epoch) external view override(IOutbox) returns (bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory) { + return epochs[_epoch].roots; } } diff --git a/l1-contracts/test/Outbox.t.sol b/l1-contracts/test/Outbox.t.sol index 6f97c5cc9adc..ab92987330a0 100644 --- a/l1-contracts/test/Outbox.t.sol +++ b/l1-contracts/test/Outbox.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.27; import {Test} from "forge-std/Test.sol"; -import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; +import {Outbox, MAX_CHECKPOINTS_PER_EPOCH} from "@aztec/core/messagebridge/Outbox.sol"; import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; @@ -26,6 +26,9 @@ contract OutboxTest is Test { uint256 internal constant AZTEC_VERSION = 1; Epoch internal constant DEFAULT_EPOCH = Epoch.wrap(1); + // Most tests insert a single root for an epoch. Use K = 1 to identify it. + uint256 internal constant K1 = 1; + address internal ROLLUP_CONTRACT; Outbox internal outbox; NaiveMerkle internal epochTree; @@ -51,6 +54,7 @@ contract OutboxTest is Test { function _consumeMessageAtEpoch( Epoch epoch, + uint256 numCheckpointsInEpoch, NaiveMerkle tree, uint256 leafIndex, bytes32 leaf, @@ -66,18 +70,19 @@ contract OutboxTest is Test { vm.expectEmit(true, true, true, true, address(outbox)); emit IOutbox.MessageConsumed(epoch, root, leaf, leafId); - outbox.consume(message, epoch, leafIndex, path); + outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path); bool statusAfterConsumption = outbox.hasMessageBeenConsumedAtEpoch(epoch, leafId); assertEq(abi.encode(1), abi.encode(statusAfterConsumption)); } function _consumeMessage(uint256 leafIndex, bytes32 leaf, DataStructures.L2ToL1Msg memory message) internal { - _consumeMessageAtEpoch(DEFAULT_EPOCH, epochTree, leafIndex, leaf, message); + _consumeMessageAtEpoch(DEFAULT_EPOCH, K1, epochTree, leafIndex, leaf, message); } function _consumeNullifiedMessageAtEpoch( Epoch epoch, + uint256 numCheckpointsInEpoch, NaiveMerkle tree, uint256 leafIndex, DataStructures.L2ToL1Msg memory message @@ -86,11 +91,11 @@ contract OutboxTest is Test { (bytes32[] memory path,) = tree.computeSiblingPath(leafIndex); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, epoch, leafId)); - outbox.consume(message, epoch, leafIndex, path); + outbox.consume(message, epoch, numCheckpointsInEpoch, leafIndex, path); } function _consumeNullifiedMessage(uint256 leafIndex, DataStructures.L2ToL1Msg memory message) internal { - _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, epochTree, leafIndex, message); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, K1, epochTree, leafIndex, message); } function testRevertIfInsertingFromNonRollup(address _caller) public { @@ -99,14 +104,29 @@ contract OutboxTest is Test { vm.prank(_caller); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__Unauthorized.selector)); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); + } + + function testRevertIfInsertingNumCheckpointsZero() public { + bytes32 root = epochTree.computeRoot(); + vm.prank(ROLLUP_CONTRACT); + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidNumCheckpointsInEpoch.selector, 0)); + outbox.insert(DEFAULT_EPOCH, 0, root); + } + + function testRevertIfInsertingNumCheckpointsAboveMax(uint256 _n) public { + uint256 n = bound(_n, MAX_CHECKPOINTS_PER_EPOCH + 1, type(uint256).max); + bytes32 root = epochTree.computeRoot(); + vm.prank(ROLLUP_CONTRACT); + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidNumCheckpointsInEpoch.selector, n)); + outbox.insert(DEFAULT_EPOCH, n, root); } function testRevertIfPathTooLong() public { DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); bytes32[] memory path = new bytes32[](256); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__PathTooLong.selector)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 0, path); } function testRevertIfLeafIndexOutOfBounds(uint256 _leafIndex) public { @@ -114,10 +134,9 @@ contract OutboxTest is Test { bytes32[] memory path = new bytes32[](4); uint256 leafIndex = bound(_leafIndex, 1 << path.length, type(uint256).max); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__LeafIndexOutOfBounds.selector, leafIndex, path.length)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, leafIndex, path); } - // This function tests the insertion of random arrays of L2 to L1 messages // We make a naive tree with a computed height, insert the leafs into it, and compute a root. We then add the root as // the root of the L2 to L1 message tree, expect for the correct event to be emitted, and then query for the root in // the contract, making sure the roots match. @@ -133,12 +152,16 @@ contract OutboxTest is Test { bytes32 root = tree.computeRoot(); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.RootAdded(DEFAULT_EPOCH, root); + emit IOutbox.RootAdded(DEFAULT_EPOCH, K1, root); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); - bytes32 actualRoot = outbox.getRootData(DEFAULT_EPOCH); - assertEq(root, actualRoot); + assertEq(root, outbox.getRootData(DEFAULT_EPOCH, K1)); + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory roots = outbox.getRoots(DEFAULT_EPOCH); + assertEq(roots[0], root); + for (uint256 i = 1; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + assertEq(roots[i], bytes32(0)); + } } function testRevertIfConsumingMessageBelongingToOther() public { @@ -148,7 +171,7 @@ contract OutboxTest is Test { vm.prank(NOT_RECIPIENT); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidRecipient.selector, address(this), NOT_RECIPIENT)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 1, path); } function testRevertIfConsumingMessageWithInvalidChainId() public { @@ -159,7 +182,7 @@ contract OutboxTest is Test { fakeMessage.recipient.chainId = block.chainid + 1; vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__InvalidChainId.selector)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 1, path); } function testRevertIfVersionMismatch() public { @@ -170,7 +193,7 @@ contract OutboxTest is Test { vm.expectRevert( abi.encodeWithSelector(Errors.Outbox__VersionMismatch.selector, message.sender.version, AZTEC_VERSION) ); - outbox.consume(message, DEFAULT_EPOCH, 1, path); + outbox.consume(message, DEFAULT_EPOCH, K1, 1, path); } function testRevertIfNothingInsertedAtEpoch() public { @@ -179,7 +202,54 @@ contract OutboxTest is Test { (bytes32[] memory path,) = epochTree.computeSiblingPath(0); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 1, path); + } + + function testRevertIfConsumingAtNumCheckpointsWithoutRoot() public { + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + // Insert at K=1, but try to consume at K=2 (no root there). + vm.prank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, K1, root); + + (bytes32[] memory path,) = epochTree.computeSiblingPath(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); + outbox.consume(fakeMessage, DEFAULT_EPOCH, 2, 0, path); + } + + function testRevertIfConsumingAtNumCheckpointsZero() public { + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + vm.prank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, K1, root); + + (bytes32[] memory path,) = epochTree.computeSiblingPath(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); + outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, 0, path); + } + + function testRevertIfConsumingAtNumCheckpointsAboveMax(uint256 _n) public { + uint256 n = bound(_n, MAX_CHECKPOINTS_PER_EPOCH + 1, type(uint256).max); + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + vm.prank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, K1, root); + + (bytes32[] memory path,) = epochTree.computeSiblingPath(0); + + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__NothingToConsumeAtEpoch.selector, DEFAULT_EPOCH)); + outbox.consume(fakeMessage, DEFAULT_EPOCH, n, 0, path); } function testValidInsertAndConsume() public { @@ -190,7 +260,7 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); uint256 leafIndex = 0; _consumeMessage(leafIndex, leaf, fakeMessage); @@ -204,7 +274,7 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); uint256 leafIndex = 0; _consumeMessage(leafIndex, leaf, fakeMessage); @@ -220,7 +290,7 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); NaiveMerkle smallerTree = new NaiveMerkle(DEFAULT_TREE_HEIGHT - 1); smallerTree.insertLeaf(leaf); @@ -228,7 +298,7 @@ contract OutboxTest is Test { (bytes32[] memory path,) = smallerTree.computeSiblingPath(0); vm.expectRevert(abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, root, smallerTreeRoot, leaf, 0)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 0, path); } function testRevertIfTryingToConsumeMessageNotInTree() public { @@ -245,12 +315,12 @@ contract OutboxTest is Test { bytes32 modifiedRoot = modifiedTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); (bytes32[] memory path,) = modifiedTree.computeSiblingPath(0); vm.expectRevert(abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, root, modifiedRoot, modifiedLeaf, 0)); - outbox.consume(fakeMessage, DEFAULT_EPOCH, 0, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, 0, path); } // This test takes awhile so to keep it somewhat reasonable we've set a limit on the amount of fuzz runs @@ -278,9 +348,9 @@ contract OutboxTest is Test { bytes32 root = tree.computeRoot(); vm.expectEmit(true, true, true, true, address(outbox)); - emit IOutbox.RootAdded(epoch, root); + emit IOutbox.RootAdded(epoch, K1, root); vm.prank(ROLLUP_CONTRACT); - outbox.insert(epoch, root); + outbox.insert(epoch, K1, root); for (uint256 i = 0; i < numberOfMessages; i++) { (bytes32[] memory path, bytes32 leaf) = tree.computeSiblingPath(i); @@ -289,7 +359,7 @@ contract OutboxTest is Test { vm.expectEmit(true, true, true, true, address(outbox)); emit IOutbox.MessageConsumed(epoch, root, leaf, leafId); vm.prank(_recipients[i]); - outbox.consume(messages[i], epoch, i, path); + outbox.consume(messages[i], epoch, K1, i, path); } } @@ -302,18 +372,235 @@ contract OutboxTest is Test { bytes32 root = epochTree.computeRoot(); vm.startPrank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, 1, root); + outbox.insert(DEFAULT_EPOCH, 2, root); + vm.stopPrank(); + + assertEq(root, outbox.getRootData(DEFAULT_EPOCH, 1)); + assertEq(root, outbox.getRootData(DEFAULT_EPOCH, 2)); + + // numCheckpointsInEpoch=0 and >MAX both return zero, no revert. + assertEq(bytes32(0), outbox.getRootData(DEFAULT_EPOCH, 0)); + assertEq(bytes32(0), outbox.getRootData(DEFAULT_EPOCH, 3)); + assertEq(bytes32(0), outbox.getRootData(DEFAULT_EPOCH, MAX_CHECKPOINTS_PER_EPOCH + 1)); + + // Unrelated epoch returns zero for any K. + Epoch otherEpoch = DEFAULT_EPOCH + Epoch.wrap(1); + assertEq(bytes32(0), outbox.getRootData(otherEpoch, 1)); + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory otherRoots = outbox.getRoots(otherEpoch); + for (uint256 i = 0; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + assertEq(otherRoots[i], bytes32(0)); + } + } + + function testGetRootsReturnsAllSlots() public { + bytes32 r1 = bytes32(uint256(0xa)); + bytes32 r3 = bytes32(uint256(0xb)); + bytes32 rMax = bytes32(uint256(0xc)); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, r1); + outbox.insert(DEFAULT_EPOCH, 3, r3); + outbox.insert(DEFAULT_EPOCH, MAX_CHECKPOINTS_PER_EPOCH, rMax); + vm.stopPrank(); + + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory roots = outbox.getRoots(DEFAULT_EPOCH); + assertEq(roots[0], r1); + assertEq(roots[1], bytes32(0)); + assertEq(roots[2], r3); + for (uint256 i = 3; i < MAX_CHECKPOINTS_PER_EPOCH - 1; i++) { + assertEq(roots[i], bytes32(0)); + } + assertEq(roots[MAX_CHECKPOINTS_PER_EPOCH - 1], rMax); + } + + function testRootAddedEventCarriesNumCheckpoints() public { + bytes32 r1 = bytes32(uint256(0xa)); + bytes32 r2 = bytes32(uint256(0xb)); + bytes32 r3 = bytes32(uint256(0xc)); + + vm.startPrank(ROLLUP_CONTRACT); + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, 1, r1); + outbox.insert(DEFAULT_EPOCH, 1, r1); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, 2, r2); + outbox.insert(DEFAULT_EPOCH, 2, r2); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, 3, r3); + outbox.insert(DEFAULT_EPOCH, 3, r3); + vm.stopPrank(); + + assertEq(outbox.getRootData(DEFAULT_EPOCH, 1), r1); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 2), r2); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 3), r3); + } + + function testConsumeAgainstFirstRootOfMultiple() public { + // Single message included in the first (smaller) root, then a second root is inserted on top. + // Consuming against the first root must still succeed. + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 firstRoot = epochTree.computeRoot(); + + // Insert a second (different) root for the same epoch at K=2. + bytes32 secondRoot = bytes32(uint256(uint256(firstRoot) ^ 0x1)); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, firstRoot); + outbox.insert(DEFAULT_EPOCH, 2, secondRoot); + vm.stopPrank(); + + uint256 leafIndex = 0; + uint256 leafId = 2 ** DEFAULT_TREE_HEIGHT + leafIndex; + (bytes32[] memory path,) = epochTree.computeSiblingPath(leafIndex); + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, firstRoot, leaf, leafId); + outbox.consume(fakeMessage, DEFAULT_EPOCH, 1, leafIndex, path); + + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId)); + } + + function testReplayAcrossRootsRejected() public { + // Build a wonky tree so the leaf id of an earlier-checkpoint message is preserved when the tree grows. + // After consuming against the first root, attempting to replay against a second root with the same leaf id + // must revert with Outbox__AlreadyNullified. + DataStructures.L2ToL1Msg[] memory msgs = new DataStructures.L2ToL1Msg[](3); + bytes32[] memory leaves = new bytes32[](3); + for (uint256 i = 0; i < 3; i++) { + msgs[i] = _fakeMessage(address(this), i + 123); + leaves[i] = msgs[i].sha256ToField(); + } + + // First root (K=1): wonky tree with the first 2 messages. + // firstRoot + // / \ + // m0 m1 + bytes32 firstRoot; + { + NaiveMerkle firstTree = new NaiveMerkle(1); + firstTree.insertLeaf(leaves[0]); + firstTree.insertLeaf(leaves[1]); + firstRoot = firstTree.computeRoot(); + } + + // Second root (K=2): an extended wonky tree that still has m0 at the top-left position. + // secondRoot + // / \ + // m0 subtree(m1,m2) + bytes32 secondRoot; + bytes32 subtreeRoot; + { + NaiveMerkle subtree = new NaiveMerkle(1); + subtree.insertLeaf(leaves[1]); + subtree.insertLeaf(leaves[2]); + subtreeRoot = subtree.computeRoot(); + NaiveMerkle secondTree = new NaiveMerkle(1); + secondTree.insertLeaf(leaves[0]); + secondTree.insertLeaf(subtreeRoot); + secondRoot = secondTree.computeRoot(); + } + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, firstRoot); + outbox.insert(DEFAULT_EPOCH, 2, secondRoot); vm.stopPrank(); + // leaves[0]'s leaf id is the same against either root because its position in the wonky tree is preserved. + uint256 leafId = (1 << 1); + + // Consume against the first root: sibling is leaves[1]. { - bytes32 actualRoot = outbox.getRootData(DEFAULT_EPOCH); - assertEq(root, actualRoot); + bytes32[] memory path = new bytes32[](1); + path[0] = leaves[1]; + outbox.consume(msgs[0], DEFAULT_EPOCH, 1, 0, path); + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId)); } + // Attempt to replay against the second root: must revert because the bitmap is shared. + // Against the second root, m0's sibling is `subtreeRoot`. { - bytes32 actualRoot = outbox.getRootData(DEFAULT_EPOCH + Epoch.wrap(1)); - assertEq(bytes32(0), actualRoot); + bytes32[] memory path = new bytes32[](1); + path[0] = subtreeRoot; + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); + outbox.consume(msgs[0], DEFAULT_EPOCH, 2, 0, path); + } + + // A message that only exists in the second root can still be consumed against K=2. + // leaves[2] sits at leafIndex=3 (depth 2): subtree(m1,m2) right child. + { + uint256 m2LeafIndex = 3; + uint256 m2LeafId = (1 << 2) + m2LeafIndex; + bytes32[] memory m2Path = new bytes32[](2); + m2Path[0] = leaves[1]; + m2Path[1] = leaves[0]; + + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, secondRoot, leaves[2], m2LeafId); + outbox.consume(msgs[2], DEFAULT_EPOCH, 2, m2LeafIndex, m2Path); + } + } + + // Companion to testReplayAcrossRootsRejected. Consumes msgs[0] against the K=2 root first + // (forcing MerkleLib to actually accept the path against the second root, proving it's a valid + // proof), and then attempts to replay against K=1. The replay must revert. + function testReplayAcrossRootsRejectedReverseOrder() public { + DataStructures.L2ToL1Msg[] memory msgs = new DataStructures.L2ToL1Msg[](3); + bytes32[] memory leaves = new bytes32[](3); + for (uint256 i = 0; i < 3; i++) { + msgs[i] = _fakeMessage(address(this), i + 200); + leaves[i] = msgs[i].sha256ToField(); + } + + bytes32 firstRoot; + { + NaiveMerkle firstTree = new NaiveMerkle(1); + firstTree.insertLeaf(leaves[0]); + firstTree.insertLeaf(leaves[1]); + firstRoot = firstTree.computeRoot(); + } + + bytes32 secondRoot; + bytes32 subtreeRoot; + { + NaiveMerkle subtree = new NaiveMerkle(1); + subtree.insertLeaf(leaves[1]); + subtree.insertLeaf(leaves[2]); + subtreeRoot = subtree.computeRoot(); + NaiveMerkle secondTree = new NaiveMerkle(1); + secondTree.insertLeaf(leaves[0]); + secondTree.insertLeaf(subtreeRoot); + secondRoot = secondTree.computeRoot(); + } + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, firstRoot); + outbox.insert(DEFAULT_EPOCH, 2, secondRoot); + vm.stopPrank(); + + uint256 leafId = (1 << 1); + + // Consume msgs[0] against the SECOND root first. This goes through MerkleLib verification + // and only succeeds if the (path, leafIndex) genuinely proves m0 against secondRoot. + { + bytes32[] memory path = new bytes32[](1); + path[0] = subtreeRoot; + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.MessageConsumed(DEFAULT_EPOCH, secondRoot, leaves[0], leafId); + outbox.consume(msgs[0], DEFAULT_EPOCH, 2, 0, path); + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId)); + } + + // Now replay against K=1 with a valid first-root path. Must revert at the shared bitmap check. + { + bytes32[] memory path = new bytes32[](1); + path[0] = leaves[1]; + vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); + outbox.consume(msgs[0], DEFAULT_EPOCH, 1, 0, path); } } @@ -325,7 +612,7 @@ contract OutboxTest is Test { bytes32 root = leaf; vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, leaf); + outbox.insert(DEFAULT_EPOCH, K1, leaf); uint256 leafIndex = 0; uint256 leafId = 1; @@ -336,7 +623,7 @@ contract OutboxTest is Test { vm.expectEmit(true, true, true, true, address(outbox)); emit IOutbox.MessageConsumed(DEFAULT_EPOCH, root, leaf, leafId); bytes32[] memory path = new bytes32[](0); - outbox.consume(fakeMessage, DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessage, DEFAULT_EPOCH, K1, leafIndex, path); bool statusAfterConsumption = outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafId); assertEq(abi.encode(1), abi.encode(statusAfterConsumption)); @@ -357,26 +644,18 @@ contract OutboxTest is Test { // / \ // tx0 tx1 - // First, build the left subtree with 2 leaves. - // subtreeRoot - // / \ - // tx0 tx1 NaiveMerkle subtree = new NaiveMerkle(1); subtree.insertLeaf(leaves[0]); subtree.insertLeaf(leaves[1]); bytes32 subtreeRoot = subtree.computeRoot(); - // Then, build the top tree with the subtree root and the last leaf. - // outHash - // / \ - // subtreeRoot tx2 NaiveMerkle topTree = new NaiveMerkle(1); topTree.insertLeaf(subtreeRoot); topTree.insertLeaf(leaves[2]); bytes32 root = topTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); // Consume the message of tx0. { @@ -390,10 +669,10 @@ contract OutboxTest is Test { path[0] = subtreePath[0]; path[1] = topTreePath[0]; } - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume the message of tx1. @@ -408,10 +687,10 @@ contract OutboxTest is Test { path[0] = subtreePath[0]; path[1] = topTreePath[0]; } - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume the message of tx2. @@ -420,10 +699,10 @@ contract OutboxTest is Test { uint256 leafIndex = 1; uint256 leafId = 2 ** 1 + 1; (bytes32[] memory path,) = topTree.computeSiblingPath(1); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } } @@ -442,10 +721,8 @@ contract OutboxTest is Test { bytes32[] memory txOutHashes = new bytes32[](3); - // tx0 has 1 message, the message leaf is the root. txOutHashes[0] = leaves[0]; - // Build the subtree of tx1 with 3 message. bytes32 tx1SubtreeRoot; { NaiveMerkle subtree = new NaiveMerkle(1); @@ -458,7 +735,6 @@ contract OutboxTest is Test { txOutHashes[1] = topTree.computeRoot(); } - // Build the subtree of tx2 with 3 messages. bytes32 tx2SubtreeRoot; { NaiveMerkle tx2Subtree = new NaiveMerkle(1); @@ -471,17 +747,6 @@ contract OutboxTest is Test { txOutHashes[2] = tx2TopTree.computeRoot(); } - // Build a wonky tree of 3 txs. - // outHash - // / \ - // . tx2 - // / \ - // tx0 tx1 - - // First, build the left subtree with 2 txOutHashes. - // subtreeRoot - // / \ - // tx0 tx1 bytes32 subtreeRoot; { NaiveMerkle subtree = new NaiveMerkle(1); @@ -490,10 +755,6 @@ contract OutboxTest is Test { subtreeRoot = subtree.computeRoot(); } - // Then, build the top tree with the subtree root and the last txOutHash. - // outHash - // / \ - // subtreeRoot tx2 { NaiveMerkle topTree = new NaiveMerkle(1); topTree.insertLeaf(subtreeRoot); @@ -501,98 +762,72 @@ contract OutboxTest is Test { bytes32 root = topTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, root); + outbox.insert(DEFAULT_EPOCH, K1, root); } // Consume messages[0] in tx0. { - // outHash - // / \ - // . tx2 - // / \ - // m0 tx1 uint256 msgIndex = 0; uint256 leafIndex = 0; uint256 leafId = 2 ** 2; bytes32[] memory path = new bytes32[](2); path[0] = txOutHashes[1]; path[1] = txOutHashes[2]; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume messages[2] in tx1. { - // outHash - // / \ - // . tx2 - // / \ - // tx0 tx1 - // / \ - // . m3 - // / \ - // m1 m2 uint256 msgIndex = 2; - uint256 leafIndex = 5; // Leaf at index 5 in a balanced tree of height 4. + uint256 leafIndex = 5; uint256 leafId = 2 ** 4 + leafIndex; bytes32[] memory path = new bytes32[](4); path[0] = leaves[1]; path[1] = leaves[3]; path[2] = txOutHashes[0]; path[3] = txOutHashes[2]; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume messages[4] in tx2. { - // outHash - // / \ - // . tx2 - // / \ - // . m6 - // / \ - // m4 m5 uint256 msgIndex = 4; - uint256 leafIndex = 4; // Leaf at index 4 in a balanced tree of height 3. + uint256 leafIndex = 4; uint256 leafId = 2 ** 3 + leafIndex; bytes32[] memory path = new bytes32[](3); path[0] = leaves[5]; path[1] = leaves[6]; path[2] = subtreeRoot; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } // Consume messages[6] in tx2. { - // outHash - // / \ - // . tx2 - // / \ - // . m6 uint256 msgIndex = 6; - uint256 leafIndex = 3; // Leaf at index 3 in a balanced tree of height 2. + uint256 leafIndex = 3; uint256 leafId = 2 ** 2 + leafIndex; bytes32[] memory path = new bytes32[](2); path[0] = tx2SubtreeRoot; path[1] = subtreeRoot; - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, leafIndex, path); + outbox.consume(fakeMessages[msgIndex], DEFAULT_EPOCH, K1, leafIndex, path); } } - // This test checks that the status of existing messages is preserved when the root for an epoch is overwritten. + // This test checks that the status of existing messages is preserved when a new (extending) root is inserted + // for the same epoch. function testConsumeAgainFailAfterChainProgressed() public { - // Create 3 messages to be inserted into the epoch tree. DataStructures.L2ToL1Msg[] memory fakeMessages = new DataStructures.L2ToL1Msg[](3); bytes32[] memory leaves = new bytes32[](3); for (uint256 i = 0; i < 3; i++) { @@ -600,51 +835,53 @@ contract OutboxTest is Test { leaves[i] = fakeMessages[i].sha256ToField(); } - // First, insert the root of a short epoch containing 2 checkpoints, each has 1 message. + // First, insert the root of a short partial proof covering 2 checkpoints (K=2). epochTree.insertLeaf(leaves[0]); epochTree.insertLeaf(leaves[1]); - bytes32 rootForShortEpoch = epochTree.computeRoot(); + bytes32 rootForShortProof = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, rootForShortEpoch); + outbox.insert(DEFAULT_EPOCH, 2, rootForShortProof); - // Consume leaves[1] + // Consume leaves[1] against K=2. { uint256 leafIndex = 1; - _consumeMessage(leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 2, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } - // Then, insert the root of a long epoch containing 3 checkpoints, including the existing 2 checkpoints, plus a new - // checkpoint with 1 tx/message. + // Then, insert the root of an extending proof covering 3 checkpoints (K=3). epochTree.insertLeaf(leaves[2]); - bytes32 rootForLongEpoch = epochTree.computeRoot(); + bytes32 rootForLongProof = epochTree.computeRoot(); vm.prank(ROLLUP_CONTRACT); - outbox.insert(DEFAULT_EPOCH, rootForLongEpoch); + outbox.insert(DEFAULT_EPOCH, 3, rootForLongProof); + + assertEq(outbox.getRootData(DEFAULT_EPOCH, 2), rootForShortProof); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 3), rootForLongProof); - // Cannot to consume leaves[1] again. + // Cannot consume leaves[1] again against either root: the bitmap is shared. { uint256 leafIndex = 1; - _consumeNullifiedMessage(leafIndex, fakeMessages[leafIndex]); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, 2, epochTree, leafIndex, fakeMessages[leafIndex]); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, 3, epochTree, leafIndex, fakeMessages[leafIndex]); } - // leaves[0] can still be consumed. + // leaves[0] can still be consumed against either root. { uint256 leafIndex = 0; - _consumeMessage(leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 3, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } - // New leaf leaves[2] can be consumed. + // New leaf leaves[2] can be consumed against K=3. { uint256 leafIndex = 2; - _consumeMessage(leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 3, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } } // This test checks that the status of existing messages is preserved when the root for a new epoch is inserted. function testConsumeMessagesInTwoEpochs() public { - // Insert 2 checkpoints to the epoch tree, each has 1 message. DataStructures.L2ToL1Msg[] memory fakeMessages = new DataStructures.L2ToL1Msg[](2); bytes32[] memory leaves = new bytes32[](2); for (uint256 i = 0; i < 2; i++) { @@ -655,38 +892,148 @@ contract OutboxTest is Test { epochTree.insertLeaf(leaves[1]); bytes32 root = epochTree.computeRoot(); - // First, insert the root for the first epoch. Epoch epoch1 = DEFAULT_EPOCH; vm.prank(ROLLUP_CONTRACT); - outbox.insert(epoch1, root); + outbox.insert(epoch1, K1, root); - // Consume leaves[1] in the first epoch { uint256 leafIndex = 1; - _consumeMessageAtEpoch(epoch1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(epoch1, K1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } - // Then, insert the root of the same epoch tree for the second epoch. Epoch epoch2 = epoch1 + Epoch.wrap(1); vm.prank(ROLLUP_CONTRACT); - outbox.insert(epoch2, root); + outbox.insert(epoch2, K1, root); // Cannot consume leaves[1] again in the first epoch. { uint256 leafIndex = 1; - _consumeNullifiedMessageAtEpoch(epoch1, epochTree, leafIndex, fakeMessages[leafIndex]); + _consumeNullifiedMessageAtEpoch(epoch1, K1, epochTree, leafIndex, fakeMessages[leafIndex]); } // The same leaf leaves[1] in the second epoch can be consumed. { uint256 leafIndex = 1; - _consumeMessageAtEpoch(epoch2, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(epoch2, K1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); } // leaves[0] in the first epoch can still be consumed. { uint256 leafIndex = 0; - _consumeMessageAtEpoch(epoch1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + _consumeMessageAtEpoch(epoch1, K1, epochTree, leafIndex, leaves[leafIndex], fakeMessages[leafIndex]); + } + } + + // Inserting the same root value at two distinct K values produces two addressable entries. + // Consuming against either marks the shared bitmap, blocking a second consume against the other + // for the same leaf id. + function testDuplicateRootInsertedAtDistinctIndices() public { + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 123); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, root); + outbox.insert(DEFAULT_EPOCH, 2, root); + vm.stopPrank(); + + assertEq(outbox.getRootData(DEFAULT_EPOCH, 1), root); + assertEq(outbox.getRootData(DEFAULT_EPOCH, 2), root); + + _consumeMessageAtEpoch(DEFAULT_EPOCH, 1, epochTree, 0, leaf, fakeMessage); + _consumeNullifiedMessageAtEpoch(DEFAULT_EPOCH, 2, epochTree, 0, fakeMessage); + } + + // Different leaf ids within the same epoch but across different roots can each be consumed + // independently — the bitmap only blocks a given leaf id, not arbitrary leaves on the same root. + function testDistinctLeafIdsAcrossRootsConsumeIndependently() public { + DataStructures.L2ToL1Msg memory msgA = _fakeMessage(address(this), 1); + DataStructures.L2ToL1Msg memory msgB = _fakeMessage(address(this), 2); + bytes32 leafA = msgA.sha256ToField(); + bytes32 leafB = msgB.sha256ToField(); + + NaiveMerkle treeA = new NaiveMerkle(DEFAULT_TREE_HEIGHT); + treeA.insertLeaf(leafA); + bytes32 rootA = treeA.computeRoot(); + + NaiveMerkle treeB = new NaiveMerkle(1); + treeB.insertLeaf(leafB); + bytes32 rootB = treeB.computeRoot(); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(DEFAULT_EPOCH, 1, rootA); + outbox.insert(DEFAULT_EPOCH, 2, rootB); + vm.stopPrank(); + + uint256 leafIdA = (1 << DEFAULT_TREE_HEIGHT) + 0; + uint256 leafIdB = (1 << 1) + 0; + assertTrue(leafIdA != leafIdB); + + _consumeMessageAtEpoch(DEFAULT_EPOCH, 1, treeA, 0, leafA, msgA); + _consumeMessageAtEpoch(DEFAULT_EPOCH, 2, treeB, 0, leafB, msgB); + + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafIdA)); + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(DEFAULT_EPOCH, leafIdB)); + } + + // Bitmap state and roots must be isolated between epochs even when both have multiple inserts. + function testMultiRootEpochsAreIsolated() public { + Epoch epoch1 = DEFAULT_EPOCH; + Epoch epoch2 = DEFAULT_EPOCH + Epoch.wrap(1); + + DataStructures.L2ToL1Msg memory fakeMessage = _fakeMessage(address(this), 7); + bytes32 leaf = fakeMessage.sha256ToField(); + epochTree.insertLeaf(leaf); + bytes32 root = epochTree.computeRoot(); + + bytes32 sentinel = bytes32(uint256(0xdead)); + + vm.startPrank(ROLLUP_CONTRACT); + outbox.insert(epoch1, 1, root); + outbox.insert(epoch1, 2, sentinel); + outbox.insert(epoch2, 1, root); + outbox.insert(epoch2, 2, sentinel); + vm.stopPrank(); + + uint256 leafIndex = 0; + uint256 leafId = (1 << DEFAULT_TREE_HEIGHT) + leafIndex; + + _consumeMessageAtEpoch(epoch1, 1, epochTree, leafIndex, leaf, fakeMessage); + + assertTrue(outbox.hasMessageBeenConsumedAtEpoch(epoch1, leafId)); + assertFalse(outbox.hasMessageBeenConsumedAtEpoch(epoch2, leafId)); + + _consumeMessageAtEpoch(epoch2, 1, epochTree, leafIndex, leaf, fakeMessage); + + _consumeNullifiedMessageAtEpoch(epoch2, 1, epochTree, leafIndex, fakeMessage); + } + + // Fuzz: inserting N non-zero roots at arbitrary distinct K values in [1, MAX] keeps each + // (K, root) pair retrievable via getRootData/getRoots and emits matching RootAdded events. + function testFuzzInsertManyRootsIndexingAndEvents(bytes32[] calldata _roots) public { + uint256 n = _roots.length; + vm.assume(n > 0 && n <= MAX_CHECKPOINTS_PER_EPOCH); + + vm.startPrank(ROLLUP_CONTRACT); + for (uint256 i = 0; i < n; i++) { + vm.assume(_roots[i] != bytes32(0)); + uint256 k = i + 1; + vm.expectEmit(true, true, true, true, address(outbox)); + emit IOutbox.RootAdded(DEFAULT_EPOCH, k, _roots[i]); + outbox.insert(DEFAULT_EPOCH, k, _roots[i]); + } + vm.stopPrank(); + + for (uint256 i = 0; i < n; i++) { + assertEq(outbox.getRootData(DEFAULT_EPOCH, i + 1), _roots[i]); + } + bytes32[MAX_CHECKPOINTS_PER_EPOCH] memory roots = outbox.getRoots(DEFAULT_EPOCH); + for (uint256 i = 0; i < n; i++) { + assertEq(roots[i], _roots[i]); + } + for (uint256 i = n; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + assertEq(roots[i], bytes32(0)); } } } diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index c0dbd9f6fb96..0a73e9be1083 100644 --- a/l1-contracts/test/Rollup.t.sol +++ b/l1-contracts/test/Rollup.t.sol @@ -839,9 +839,10 @@ contract RollupTest is RollupBase { // Submit proof for checkpoints 1-2 with outHash2 _submitEpochProof(1, 2, checkpoint.archive, checkpoint2Data.archive, checkpoint2Data.batchedBlobInputs, outHash2); - // Verify the state after the first proof + // Verify the state after the first proof (covered 2 checkpoints → K=2). assertEq(rollup.getProvenCheckpointNumber(), 2, "Proven checkpoint number should be 2"); - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash2, "OutHash should be outHash2"); + assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should be outHash2"); + assertEq(outbox.getRootData(Epoch.wrap(0), 1), bytes32(0), "Root at K=1 should be unset"); // Attempt to submit proof for checkpoints 1-1 with outHash1 (shorter proof) // This should not revert, but should not update anything @@ -850,8 +851,9 @@ contract RollupTest is RollupBase { // Verify that the proven checkpoint number did NOT regress assertEq(rollup.getProvenCheckpointNumber(), 2, "Proven checkpoint number should still be 2"); - // Verify that the outHash did NOT change - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash2, "OutHash should still be outHash2"); + // Verify that no new root was inserted (the shorter proof gate in EpochProofLib skips insert). + assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should still be outHash2"); + assertEq(outbox.getRootData(Epoch.wrap(0), 1), bytes32(0), "Root at K=1 should remain unset"); } function testLongerEpochProofCanUpdateAfterShorterProof() public setUpFor("mixed_checkpoint_1") { @@ -871,19 +873,20 @@ contract RollupTest is RollupBase { // Submit proof for checkpoints 1-1 with outHash1 (shorter proof first) _submitEpochProof(1, 1, checkpoint.archive, checkpoint1Data.archive, checkpoint1Data.batchedBlobInputs, outHash1); - // Verify the state after the first proof + // Verify the state after the first proof (covered 1 checkpoint → K=1). assertEq(rollup.getProvenCheckpointNumber(), 1, "Proven checkpoint number should be 1"); - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash1, "OutHash should be outHash1"); + assertEq(outbox.getRootData(Epoch.wrap(0), 1), outHash1, "Root at K=1 should be outHash1"); // Submit proof for checkpoints 1-2 with outHash2 (longer proof) - // This SHOULD update both the proven checkpoint number and the outHash + // This SHOULD update the proven checkpoint number and insert a new root at K=2. _submitEpochProof(1, 2, checkpoint.archive, checkpoint2Data.archive, checkpoint2Data.batchedBlobInputs, outHash2); // Verify that the proven checkpoint number progressed to 2 assertEq(rollup.getProvenCheckpointNumber(), 2, "Proven checkpoint number should be 2"); - // Verify that the outHash was updated to outHash2 - assertEq(outbox.getRootData(Epoch.wrap(0)), outHash2, "OutHash should be outHash2"); + // The earlier root at K=1 remains addressable, and the new root sits at K=2. + assertEq(outbox.getRootData(Epoch.wrap(0), 1), outHash1, "Root at K=1 should remain outHash1"); + assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should be outHash2"); } function _submitEpochProof( diff --git a/l1-contracts/test/outbox/tmnt205.t.sol b/l1-contracts/test/outbox/tmnt205.t.sol index 45cf31c63353..ad3cc6f70a1d 100644 --- a/l1-contracts/test/outbox/tmnt205.t.sol +++ b/l1-contracts/test/outbox/tmnt205.t.sol @@ -34,7 +34,7 @@ contract Tmnt205Test is Test { $root = _buildWonkyTree(); vm.prank(rollup); - outbox.insert(DEFAULT_EPOCH, $root); + outbox.insert(DEFAULT_EPOCH, 1, $root); } function test_replays_exact() public { @@ -62,11 +62,11 @@ contract Tmnt205Test is Test { vm.expectEmit(true, true, true, true, address(outbox)); emit IOutbox.MessageConsumed(DEFAULT_EPOCH, $root, message.sha256ToField(), leafId); - outbox.consume(message, DEFAULT_EPOCH, leafIndex, path); + outbox.consume(message, DEFAULT_EPOCH, 1, leafIndex, path); // It should always revert, here, either incorrect values or already used. vm.expectRevert(); - outbox.consume(message, DEFAULT_EPOCH, leafIndex2, path); + outbox.consume(message, DEFAULT_EPOCH, 1, leafIndex2, path); } function test_overrides() public { @@ -93,7 +93,7 @@ contract Tmnt205Test is Test { // The outbox should revert earlier to that due to the index beyond boundary vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__LeafIndexOutOfBounds.selector, a_leafIndex, a_path.length)); - outbox.consume(a_message, DEFAULT_EPOCH, a_leafIndex, a_path); + outbox.consume(a_message, DEFAULT_EPOCH, 1, a_leafIndex, a_path); // Real message DataStructures.L2ToL1Msg memory r_message = $msgs[4]; @@ -105,7 +105,7 @@ contract Tmnt205Test is Test { leftSubTree.insertLeaf($txOutHashes[0]); leftSubTree.insertLeaf($txOutHashes[1]); r_path[2] = leftSubTree.computeRoot(); - outbox.consume(r_message, DEFAULT_EPOCH, r_leafIndex, r_path); + outbox.consume(r_message, DEFAULT_EPOCH, 1, r_leafIndex, r_path); } function _fakeMessage(address _recipient, uint256 _content) internal view returns (DataStructures.L2ToL1Msg memory) { diff --git a/l1-contracts/test/portals/DataStructures.sol b/l1-contracts/test/portals/DataStructures.sol index 611a15c32d7a..afbcb865f538 100644 --- a/l1-contracts/test/portals/DataStructures.sol +++ b/l1-contracts/test/portals/DataStructures.sol @@ -7,6 +7,7 @@ import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; library DataStructures { struct OutboxMessageMetadata { Epoch _epoch; + uint256 _numCheckpointsInEpoch; uint256 _leafIndex; bytes32[] _path; } diff --git a/l1-contracts/test/portals/TokenPortal.sol b/l1-contracts/test/portals/TokenPortal.sol index 170a0da3e386..fa4a84303511 100644 --- a/l1-contracts/test/portals/TokenPortal.sol +++ b/l1-contracts/test/portals/TokenPortal.sol @@ -117,8 +117,10 @@ contract TokenPortal { * @param _amount - The amount to withdraw * @param _withCaller - Flag to use `msg.sender` as caller, otherwise address(0) * @param _epoch - The epoch the message is in - * @param _leafIndex - The amount to withdraw - * @param _path - Flag to use `msg.sender` as caller, otherwise address(0) + * @param _numCheckpointsInEpoch - The number of checkpoints in the partial proof whose root this + * consume verifies against + * @param _leafIndex - The index of the leaf in the epoch message tree + * @param _path - The sibling path proving inclusion of the message in the epoch's root * Must match the caller of the message (specified from L2) to consume it. */ function withdraw( @@ -126,6 +128,7 @@ contract TokenPortal { uint256 _amount, bool _withCaller, Epoch _epoch, + uint256 _numCheckpointsInEpoch, uint256 _leafIndex, bytes32[] calldata _path ) external { @@ -141,7 +144,7 @@ contract TokenPortal { ) }); - outbox.consume(message, _epoch, _leafIndex, _path); + outbox.consume(message, _epoch, _numCheckpointsInEpoch, _leafIndex, _path); underlying.safeTransfer(_recipient, _amount); } diff --git a/l1-contracts/test/portals/TokenPortal.t.sol b/l1-contracts/test/portals/TokenPortal.t.sol index 4a6977ab4a6d..7f6ab9be9fc3 100644 --- a/l1-contracts/test/portals/TokenPortal.t.sol +++ b/l1-contracts/test/portals/TokenPortal.t.sol @@ -206,7 +206,7 @@ contract TokenPortalTest is Test { bytes32 treeRoot = tree.computeRoot(); // Insert messages into the outbox (impersonating the rollup contract) vm.prank(address(rollup)); - outbox.insert(_epoch, treeRoot); + outbox.insert(_epoch, 1, treeRoot); return (l2ToL1Message, siblingPath, treeRoot); } @@ -225,14 +225,14 @@ contract TokenPortalTest is Test { vm.startPrank(_caller); vm.expectEmit(true, true, true, true); emit IOutbox.MessageConsumed(DEFAULT_EPOCH, treeRoot, l2ToL1Message, leafId); - tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, leafIndex, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 1, leafIndex, siblingPath); // Should have received 654 RNA tokens assertEq(testERC20.balanceOf(recipient), withdrawAmount); // Should not be able to withdraw again vm.expectRevert(abi.encodeWithSelector(Errors.Outbox__AlreadyNullified.selector, DEFAULT_EPOCH, leafId)); - tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, leafIndex, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 1, leafIndex, siblingPath); vm.stopPrank(); } @@ -246,13 +246,13 @@ contract TokenPortalTest is Test { vm.expectRevert( abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, treeRoot, consumedRoot, l2ToL1MessageHash, 0) ); - tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, 0, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, 1, 0, siblingPath); (l2ToL1MessageHash, consumedRoot) = _createWithdrawMessageForOutbox(address(0)); vm.expectRevert( abi.encodeWithSelector(Errors.MerkleLib__InvalidRoot.selector, treeRoot, consumedRoot, l2ToL1MessageHash, 0) ); - tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 0, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, false, DEFAULT_EPOCH, 1, 0, siblingPath); vm.stopPrank(); } @@ -266,7 +266,7 @@ contract TokenPortalTest is Test { vm.expectEmit(true, true, true, true); emit IOutbox.MessageConsumed(DEFAULT_EPOCH, treeRoot, l2ToL1Message, leafId); - tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, leafIndex, siblingPath); + tokenPortal.withdraw(recipient, withdrawAmount, true, DEFAULT_EPOCH, 1, leafIndex, siblingPath); // Should have received 654 RNA tokens assertEq(testERC20.balanceOf(recipient), withdrawAmount); diff --git a/l1-contracts/test/portals/UniswapPortal.sol b/l1-contracts/test/portals/UniswapPortal.sol index 91d676a90e09..317ba3fe27d5 100644 --- a/l1-contracts/test/portals/UniswapPortal.sol +++ b/l1-contracts/test/portals/UniswapPortal.sol @@ -86,6 +86,7 @@ contract UniswapPortal { _inAmount, true, _outboxMessageMetadata[0]._epoch, + _outboxMessageMetadata[0]._numCheckpointsInEpoch, _outboxMessageMetadata[0]._leafIndex, _outboxMessageMetadata[0]._path ); @@ -119,6 +120,7 @@ contract UniswapPortal { content: vars.contentHash }), _outboxMessageMetadata[1]._epoch, + _outboxMessageMetadata[1]._numCheckpointsInEpoch, _outboxMessageMetadata[1]._leafIndex, _outboxMessageMetadata[1]._path ); @@ -188,6 +190,7 @@ contract UniswapPortal { _inAmount, true, _outboxMessageMetadata[0]._epoch, + _outboxMessageMetadata[0]._numCheckpointsInEpoch, _outboxMessageMetadata[0]._leafIndex, _outboxMessageMetadata[0]._path ); @@ -220,6 +223,7 @@ contract UniswapPortal { content: vars.contentHash }), _outboxMessageMetadata[1]._epoch, + _outboxMessageMetadata[1]._numCheckpointsInEpoch, _outboxMessageMetadata[1]._leafIndex, _outboxMessageMetadata[1]._path ); diff --git a/l1-contracts/test/portals/UniswapPortal.t.sol b/l1-contracts/test/portals/UniswapPortal.t.sol index 815d5890d9d0..1da7f9fe3c52 100644 --- a/l1-contracts/test/portals/UniswapPortal.t.sol +++ b/l1-contracts/test/portals/UniswapPortal.t.sol @@ -179,7 +179,7 @@ contract UniswapPortalTest is Test { (bytes32[] memory swapSiblingPath,) = tree.computeSiblingPath(1); vm.prank(address(rollup)); - outbox.insert(_epoch, treeRoot); + outbox.insert(_epoch, 1, treeRoot); return (treeRoot, withdrawSiblingPath, swapSiblingPath); } @@ -210,8 +210,12 @@ contract UniswapPortalTest is Test { ); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -257,8 +261,12 @@ contract UniswapPortalTest is Test { ); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -306,8 +314,12 @@ contract UniswapPortalTest is Test { ); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -331,8 +343,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; uniswapPortal.swapPublic( @@ -367,8 +383,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; vm.prank(_caller); @@ -403,8 +423,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; vm.startPrank(_caller); @@ -486,8 +510,12 @@ contract UniswapPortalTest is Test { _addMessagesToOutbox(daiWithdrawMessageHash, swapMessageHash, DEFAULT_EPOCH); PortalDataStructures.OutboxMessageMetadata[2] memory outboxMessageMetadata = [ - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 0, _path: withdrawSiblingPath}), - PortalDataStructures.OutboxMessageMetadata({_epoch: DEFAULT_EPOCH, _leafIndex: 1, _path: swapSiblingPath}) + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 0, _path: withdrawSiblingPath + }), + PortalDataStructures.OutboxMessageMetadata({ + _epoch: DEFAULT_EPOCH, _numCheckpointsInEpoch: 1, _leafIndex: 1, _path: swapSiblingPath + }) ]; bytes32 messageHashPortalChecksAgainst = _createUniswapSwapMessagePrivate(address(this)); diff --git a/yarn-project/aztec.js/src/ethereum/portal_manager.ts b/yarn-project/aztec.js/src/ethereum/portal_manager.ts index 3b8975b35e5a..5b913a6d2b4e 100644 --- a/yarn-project/aztec.js/src/ethereum/portal_manager.ts +++ b/yarn-project/aztec.js/src/ethereum/portal_manager.ts @@ -412,6 +412,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { * @param amount - Amount to withdraw. * @param recipient - Who will receive the funds. * @param epochNumber - Epoch number of the message. + * @param numCheckpointsInEpoch - The partial-proof depth (1-indexed) the witness was built against. * @param messageIndex - Index of the message. * @param siblingPath - Sibling path of the message. */ @@ -419,11 +420,12 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { amount: bigint, recipient: EthAddress, epochNumber: EpochNumber, + numCheckpointsInEpoch: number, messageIndex: bigint, siblingPath: SiblingPath, ) { this.logger.info( - `Sending L1 tx to consume message at epoch ${epochNumber} index ${messageIndex} to withdraw ${amount}`, + `Sending L1 tx to consume message at epoch ${epochNumber} numCheckpointsInEpoch ${numCheckpointsInEpoch} index ${messageIndex} to withdraw ${amount}`, ); const messageLeafId = getL2ToL1MessageLeafId({ leafIndex: messageIndex, siblingPath }); @@ -440,6 +442,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { amount, false, BigInt(epochNumber), + BigInt(numCheckpointsInEpoch), messageIndex, siblingPath.toBufferArray().map((buf: Buffer): Hex => `0x${buf.toString('hex')}`), ]); diff --git a/yarn-project/aztec/src/testing/epoch_test_settler.ts b/yarn-project/aztec/src/testing/epoch_test_settler.ts index 37697b74ed29..50b52a1ade32 100644 --- a/yarn-project/aztec/src/testing/epoch_test_settler.ts +++ b/yarn-project/aztec/src/testing/epoch_test_settler.ts @@ -52,7 +52,7 @@ export class EpochTestSettler { const outHash = computeEpochOutHash(messagesInEpoch); if (!outHash.isZero()) { - await this.rollupCheatCodes.insertOutbox(epoch, outHash.toBigInt()); + await this.rollupCheatCodes.insertOutbox(epoch, messagesInEpoch.length, outHash.toBigInt()); } else { this.log.info(`No L2 to L1 messages in epoch ${epoch}`); } diff --git a/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts b/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts index 532d25ee5832..e0c213083d2d 100644 --- a/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts +++ b/yarn-project/end-to-end/src/composed/e2e_token_bridge_tutorial_test.test.ts @@ -6,6 +6,7 @@ import { Fr } from '@aztec/aztec.js/fields'; import { createLogger } from '@aztec/aztec.js/log'; import { createAztecNodeClient, waitForNode } from '@aztec/aztec.js/node'; import { createExtendedL1Client } from '@aztec/ethereum/client'; +import { OutboxContract } from '@aztec/ethereum/contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import { FeeAssetHandlerAbi, @@ -196,7 +197,8 @@ describe('e2e_cross_chain_messaging token_bridge_tutorial_test', () => { .simulate({ from: ownerAztecAddress }); logger.info(`New L2 balance of ${ownerAztecAddress} is ${newL2Balance}`); - const result = await computeL2ToL1MembershipWitness(node, l2ToL1Message, l2TxReceipt.txHash); + const outboxContract = new OutboxContract(l1Client, l1ContractAddresses.outboxAddress); + const result = await computeL2ToL1MembershipWitness(node, outboxContract, l2ToL1Message, l2TxReceipt); if (!result) { throw new Error('L2 to L1 message not found'); } @@ -205,6 +207,7 @@ describe('e2e_cross_chain_messaging token_bridge_tutorial_test', () => { withdrawAmount, EthAddress.fromString(ownerEthAddress), result.epochNumber, + result.numCheckpointsInEpoch, result.leafIndex, result.siblingPath, ); diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts index 6c123772337b..b28d12ad0763 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l2_to_l1.test.ts @@ -254,13 +254,14 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => { async function expectConsumeMessageToSucceed(msg: ReturnType, l2TxHash: TxHash) { const msgLeaf = computeMessageLeaf(msg); - const result = (await computeL2ToL1MembershipWitness(aztecNode, msgLeaf, l2TxHash))!; - const { epochNumber: epoch, ...witness } = result; + const result = (await computeL2ToL1MembershipWitness(aztecNode, outbox, msgLeaf, l2TxHash))!; + const { epochNumber: epoch, numCheckpointsInEpoch, ...witness } = result; const leafId = getL2ToL1MessageLeafId(witness); const txHash = await outbox.consume( msg, epoch, + numCheckpointsInEpoch, witness.leafIndex, witness.siblingPath.toFields().map(f => f.toString()), ); @@ -307,6 +308,7 @@ describe('e2e_cross_chain_messaging l2_to_l1', () => { outbox.consume( msg, witness.epochNumber, + witness.numCheckpointsInEpoch, witness.leafIndex, witness.siblingPath.toFields().map(f => f.toString()), ), diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts index 2060c5263a68..3ca2ca73a878 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts @@ -75,13 +75,19 @@ describe('e2e_cross_chain_messaging token_bridge_private', () => { // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. await t.advanceToEpochProven(l2TxReceipt); - const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(aztecNode, l2ToL1Message, l2TxReceipt.txHash))!; + const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness( + aztecNode, + crossChainTestHarness.outboxContract, + l2ToL1Message, + l2TxReceipt, + ))!; // Check balance before and after exit. expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); await crossChainTestHarness.withdrawFundsFromBridgeOnL1( withdrawAmount, l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, l2ToL1MessageResult.leafIndex, l2ToL1MessageResult.siblingPath, ); diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts index 4f4e4d34cc12..40b605a3117d 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_public.test.ts @@ -82,13 +82,19 @@ describe('e2e_cross_chain_messaging token_bridge_public', () => { // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. await t.advanceToEpochProven(l2TxReceipt); - const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(aztecNode, l2ToL1Message, l2TxReceipt.txHash))!; + const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness( + aztecNode, + crossChainTestHarness.outboxContract, + l2ToL1Message, + l2TxReceipt, + ))!; // Check balance before and after exit. expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); await crossChainTestHarness.withdrawFundsFromBridgeOnL1( withdrawAmount, l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, l2ToL1MessageResult.leafIndex, l2ToL1MessageResult.siblingPath, ); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts new file mode 100644 index 000000000000..55f015b6ede1 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_partial_proof_multi_root.test.ts @@ -0,0 +1,333 @@ +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import { EpochTestSettler } from '@aztec/aztec/testing'; +import { MAX_CHECKPOINTS_PER_EPOCH } from '@aztec/constants'; +import { OutboxContract, type ViemL2ToL1Msg } from '@aztec/ethereum/contracts'; +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; +import { EpochNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { OutboxAbi } from '@aztec/l1-artifacts'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; +import { computeL2ToL1MessageHash } from '@aztec/stdlib/hash'; +import { computeEpochOutHash, computeL2ToL1MembershipWitness, getL2ToL1MessageLeafId } from '@aztec/stdlib/messaging'; +import { type TxReceipt, TxStatus } from '@aztec/stdlib/tx'; + +import { jest } from '@jest/globals'; +import { type Hex, decodeEventLog } from 'viem'; + +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 10); + +// Since AZIP-14 the Outbox can hold up to MAX_CHECKPOINTS_PER_EPOCH partial-proof roots per epoch, one +// per `numCheckpointsInEpoch` (1-indexed). This test stages three progressively-deeper roots for +// the same epoch by driving the EpochTestSettler after each checkpointed tx, then tests: +// (a) consuming a message uses the smallest covering root the client helper picks, +// (b) the user can consume the same message against any covering root (both K=1 and K=2 cover +// a message in checkpoint 0), and the shared bitmap prevents double-consume across K, and +// (c) a message whose checkpoint is not yet covered by any root yields no witness. +describe('e2e_epochs/epochs_partial_proof_multi_root', () => { + let test: EpochsTestContext; + let logger: Logger; + let node: AztecNode; + let l1Client: ExtendedViemWalletClient; + let l1Contracts: L1ContractAddresses; + let outbox: OutboxContract; + let settler: EpochTestSettler; + + // The L1 EOA that submits the consume() tx. The L2-to-L1 message recipient must equal this + // address. The Outbox enforces `msg.sender == _message.recipient.actor` on consume. + let recipient: EthAddress; + + beforeEach(async () => { + test = await EpochsTestContext.setup({ + numberOfAccounts: 1, + minTxsPerBlock: 1, + // Long epoch so 4 well-spaced checkpoints comfortably fit before the boundary. + aztecEpochDuration: 1000, + // Don't let the real prover land a partial proof under us. We drive Outbox state via the + // settler. `aztecProofSubmissionEpochs` >> test duration makes it impossible to enter the + // submission window. + aztecProofSubmissionEpochs: 1024, + startProverNode: false, + enableProposerPipelining: true, + disableAnvilTestWatcher: true, + }); + ({ logger } = test); + node = test.context.aztecNode; + l1Client = test.context.deployL1ContractsValues.l1Client; + l1Contracts = test.context.deployL1ContractsValues.l1ContractAddresses; + outbox = new OutboxContract(l1Client, l1Contracts.outboxAddress); + recipient = EthAddress.fromString(l1Client.account.address); + + // Construct a standalone EpochTestSettler that we drive by hand: we never call `.start()`, + // just `handleEpochReadyToProve(epoch)` synchronously after each tx is checkpointed. That + // keeps the prover-style polling loop out of the way and lets the test choose exactly when + // each progressive K root lands in the Outbox. + settler = new EpochTestSettler( + test.context.cheatCodes.eth, + l1Contracts.rollupAddress, + test.context.aztecNodeService.getBlockSource(), + logger.createChild('epoch-settler'), + { pollingIntervalMs: 200 }, + ); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + it('stages 3 partial-proof roots and lets messages consume against any covering root', async () => { + const { wallet } = test.context; + const [from] = test.context.accounts; + const version = BigInt(test.context.aztecNodeConfig.rollupVersion); + const chainId = BigInt(l1Client.chain.id); + + // Deploy TestContract. It provides `create_l2_to_l1_message_arbitrary_recipient_private`, + // the minimal way to emit a single L2-to-L1 message per tx. + logger.warn(`Deploying TestContract`); + const { contract } = await TestContract.deploy(wallet).send({ from }); + logger.warn(`Deployed TestContract at ${contract.address}`); + + // Warp past the current epoch so the deploy blocks (and any setup blocks) sit alone in the + // previous epoch, leaving the new epoch with only the 4 L2-to-L1 message txs we are about to + // send. Without this, the deploys would share an epoch with the messages and the per-checkpoint + // assertions below would include the empty deploy checkpoints. + await test.context.cheatCodes.rollup.advanceToNextEpoch(); + + // Make each L2-to-L1 message ABI-shaped for the Outbox consume() call. + const makeMsg = (content: Fr): ViemL2ToL1Msg => ({ + sender: { actor: contract.address.toString() as Hex, version }, + recipient: { actor: recipient.toString() as Hex, chainId }, + content: content.toString() as Hex, + }); + const computeLeaf = (msg: ViemL2ToL1Msg) => + computeL2ToL1MessageHash({ + l2Sender: contract.address, + l1Recipient: EthAddress.fromString(msg.recipient.actor), + content: Fr.fromString(msg.content), + rollupVersion: new Fr(msg.sender.version), + chainId: new Fr(msg.recipient.chainId), + }); + + // Send 4 messages, each landing in a separate checkpoint of the same epoch. We send a tx, + // wait for it to be CHECKPOINTED, then drive the settler to insert a partial-proof root that + // covers the checkpoints seen so far (K=1, then K=2, then K=3). Between sends we advance an + // L2 slot so the next tx lands in a fresh slot, and therefore the next checkpoint (one slot = + // one checkpoint in current Aztec). The 4th tx is sent but intentionally NOT settled, to + // exercise the "no covering root yet" negative case below. + const sends: { msg: ViemL2ToL1Msg; leaf: Fr; receipt: TxReceipt }[] = []; + let epoch: EpochNumber | undefined; + for (let i = 0; i < 4; i++) { + const content = Fr.random(); + const msg = makeMsg(content); + const leaf = computeLeaf(msg); + logger.warn(`Sending L2-to-L1 message ${i} (content=${content.toString()})`); + const { receipt } = await contract.methods + .create_l2_to_l1_message_arbitrary_recipient_private(content, recipient) + .send({ from, wait: { waitForStatus: TxStatus.CHECKPOINTED } }); + logger.warn(`Tx ${i} checkpointed`, { + txHash: receipt.txHash.toString(), + blockNumber: receipt.blockNumber, + epochNumber: receipt.epochNumber, + }); + sends.push({ msg, leaf, receipt }); + + if (epoch === undefined) { + epoch = receipt.epochNumber; + if (epoch === undefined) { + throw new Error('First tx is missing an epochNumber on its receipt'); + } + } + expect(receipt.epochNumber).toBe(epoch); + + // Settle the epoch for the first 3 txs. The settler reads only checkpointed blocks from the + // node, groups them per checkpoint, and inserts the resulting out-hash at + // `numCheckpointsInEpoch = i + 1`. The 4th tx is left unsettled. + if (i < 3) { + logger.warn(`Settling epoch ${epoch} after tx ${i} (expecting K=${i + 1})`); + await settler.handleEpochReadyToProve(epoch); + } + + // Push the next tx into a fresh slot. Skipped after the last one. + if (i < 3) { + await test.context.cheatCodes.rollup.advanceToNextSlot(); + } + } + + if (epoch === undefined) { + throw new Error('No txs were sent'); + } + + // Confirm all 4 txs landed in the same epoch and 4 distinct checkpoints. + const checkpointNumbers: number[] = []; + for (const [i, { receipt }] of sends.entries()) { + expect(receipt.epochNumber).toBe(epoch); + const block = (await node.getBlock(receipt.blockNumber!))!; + checkpointNumbers.push(Number(block.checkpointNumber)); + logger.warn(`Tx ${i} block ${receipt.blockNumber} is in checkpoint ${block.checkpointNumber}`); + } + expect(new Set(checkpointNumbers).size).toBe(4); + // Checkpoints should be monotonically increasing (we advanced slots one-by-one). + for (let i = 1; i < checkpointNumbers.length; i++) { + expect(checkpointNumbers[i]).toBeGreaterThan(checkpointNumbers[i - 1]); + } + + // Pull all messages in the epoch from the node, organized as checkpoint -> block -> tx -> + // message. With 4 distinct checkpoints, each holding a single block with a single tx with a + // single message, this should have shape [[[[m0]]], [[[m1]]], [[[m2]]], [[[m3]]]]. + const messagesPerCheckpoint = await node.getL2ToL1Messages(epoch); + expect(messagesPerCheckpoint.length).toBe(4); + for (let i = 0; i < 4; i++) { + expect(messagesPerCheckpoint[i]).toHaveLength(1); + expect(messagesPerCheckpoint[i][0]).toHaveLength(1); + expect(messagesPerCheckpoint[i][0][0]).toHaveLength(1); + expect(messagesPerCheckpoint[i][0][0][0].toString()).toBe(sends[i].leaf.toString()); + } + + // Recompute the 3 partial-proof roots locally for K in {1, 2, 3} to cross-check the settler. + // This mirrors what the rollup proves on chain: slice the outer (checkpoint) array to the + // first K entries and feed it to `computeEpochOutHash`, which internally zero-pads up to + // OUT_HASH_TREE_LEAF_COUNT. + const root1 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 1)); + const root2 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 2)); + const root3 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 3)); + expect(root1.equals(root2)).toBe(false); + expect(root2.equals(root3)).toBe(false); + + // Sanity-check the Outbox holds exactly the 3 roots the settler inserted, padded with zeros + // beyond. + const onChainRoots = await outbox.getRoots(epoch); + expect(onChainRoots).toHaveLength(MAX_CHECKPOINTS_PER_EPOCH); + expect(onChainRoots[0].toString()).toBe(root1.toString()); + expect(onChainRoots[1].toString()).toBe(root2.toString()); + expect(onChainRoots[2].toString()).toBe(root3.toString()); + for (let i = 3; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + expect(onChainRoots[i].isZero()).toBe(true); + } + + // Consume msg2 against the smallest covering root the helper picks (K=2). + { + const witness = (await computeL2ToL1MembershipWitness(node, outbox, sends[1].leaf, sends[1].receipt))!; + expect(witness).toBeDefined(); + expect(witness.epochNumber).toBe(epoch); + expect(witness.numCheckpointsInEpoch).toBe(2); + expect(witness.root.toString()).toBe(root2.toString()); + + await expectConsumeSucceeds(sends[1].msg, sends[1].leaf, witness, epoch); + } + + // Negative: msg4 lives in checkpoint at index 3, and the largest covering K we inserted is + // K=3 (covers indices 0..2). The helper must return undefined: no covering root yet. + { + const witness = await computeL2ToL1MembershipWitness(node, outbox, sends[3].leaf, sends[3].receipt); + expect(witness).toBeUndefined(); + } + + // "User can pick any covering root" property. We consume msg1 (in checkpoint at index 0) + // against K=1, the default the helper picks. Then we manually build a witness against K=2 + // (by hiding slot 0 of the on-chain roots from the helper so it walks past it) and verify + // the second consume reverts due to the shared bitmap. + { + // K=1 path (default helper choice). + const witnessK1 = (await computeL2ToL1MembershipWitness(node, outbox, sends[0].leaf, sends[0].receipt))!; + expect(witnessK1.numCheckpointsInEpoch).toBe(1); + expect(witnessK1.root.toString()).toBe(root1.toString()); + await expectConsumeSucceeds(sends[0].msg, sends[0].leaf, witnessK1, epoch); + + // K=2 path: feed the helper a roots array with slot 0 zeroed so it picks the next covering + // root (K=2). Both K=1 and K=2 cover checkpoint 0, so this is a legitimate witness, but + // the shared bitmap (indexed by stable leafId) must prevent a second consume. + const rootsWithoutK1: Fr[] = [Fr.ZERO, ...onChainRoots.slice(1)]; + const witnessK2 = (await computeL2ToL1MembershipWitness(node, rootsWithoutK1, sends[0].leaf, sends[0].receipt))!; + expect(witnessK2.numCheckpointsInEpoch).toBe(2); + expect(witnessK2.root.toString()).toBe(root2.toString()); + // The K=2 witness must produce the same leafId as the K=1 witness (leafId is stable). + expect(getL2ToL1MessageLeafId(witnessK2)).toBe(getL2ToL1MessageLeafId(witnessK1)); + + // Trying to consume msg1 again, under either K, reverts. + await expect( + outbox.consume( + sends[0].msg, + witnessK2.epochNumber, + witnessK2.numCheckpointsInEpoch, + witnessK2.leafIndex, + witnessK2.siblingPath.toFields().map(f => f.toString()), + ), + ).rejects.toThrow(); + } + + // Replay protection: consuming msg2 again (now under any covering root) reverts. + { + const witness = (await computeL2ToL1MembershipWitness(node, outbox, sends[1].leaf, sends[1].receipt))!; + await expect( + outbox.consume( + sends[1].msg, + witness.epochNumber, + witness.numCheckpointsInEpoch, + witness.leafIndex, + witness.siblingPath.toFields().map(f => f.toString()), + ), + ).rejects.toThrow(); + } + + // After all the above, slot 3..31 of the Outbox stayed zero (we never staged K=4..32). + const rootsAfter = await outbox.getRoots(epoch); + for (let i = 3; i < MAX_CHECKPOINTS_PER_EPOCH; i++) { + expect(rootsAfter[i].isZero()).toBe(true); + } + + // Once we drive the settler one more time, K=4 lands and the previously-unwitnessable msg4 + // becomes consumable. + { + await settler.handleEpochReadyToProve(epoch); + const root4 = computeEpochOutHash(messagesPerCheckpoint.slice(0, 4)); + await retryUntil(async () => !(await outbox.getRoots(epoch))[3].isZero(), 'K=4 root visible', 10, 0.1); + + const witness = (await computeL2ToL1MembershipWitness(node, outbox, sends[3].leaf, sends[3].receipt))!; + expect(witness.numCheckpointsInEpoch).toBe(4); + expect(witness.root.toString()).toBe(root4.toString()); + await expectConsumeSucceeds(sends[3].msg, sends[3].leaf, witness, epoch); + } + }); + + /** + * Submits an Outbox.consume tx and asserts: the L1 tx succeeds, exactly one MessageConsumed log + * is emitted, and the log's epoch/root/messageHash/leafId match the supplied witness. + */ + async function expectConsumeSucceeds( + msg: ViemL2ToL1Msg, + leaf: Fr, + witness: NonNullable>>, + epoch: EpochNumber, + ) { + const txHash = await outbox.consume( + msg, + witness.epochNumber, + witness.numCheckpointsInEpoch, + witness.leafIndex, + witness.siblingPath.toFields().map(f => f.toString()), + ); + const l1Receipt = await l1Client.waitForTransactionReceipt({ hash: txHash }); + expect(l1Receipt.status).toBe('success'); + expect(l1Receipt.logs.length).toBe(1); + + const decoded = decodeEventLog({ + abi: OutboxAbi, + data: l1Receipt.logs[0].data, + topics: l1Receipt.logs[0].topics, + }) as { + eventName: 'MessageConsumed'; + args: { epoch: bigint; root: `0x${string}`; messageHash: `0x${string}`; leafId: bigint }; + }; + expect(decoded.eventName).toBe('MessageConsumed'); + expect(decoded.args.epoch).toBe(BigInt(epoch)); + expect(decoded.args.root).toBe(witness.root.toString()); + expect(decoded.args.messageHash).toBe(leaf.toString()); + expect(decoded.args.leafId).toBe(getL2ToL1MessageLeafId(witness)); + } +}); diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index fb5bd2afffd9..19abe18e1076 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -6,7 +6,7 @@ import { waitForProven } from '@aztec/aztec.js/contracts'; import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; import { RollupCheatCodes } from '@aztec/aztec/testing'; -import { FeeAssetHandlerContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; +import { FeeAssetHandlerContract, OutboxContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import { deployRollupForUpgrade } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; @@ -353,15 +353,20 @@ describe('e2e_p2p_add_rollup', () => { chainId: new Fr(l1Client.chain.id), }); - const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(node, leaf, l2OutgoingReceipt.txHash))!; - const { epochNumber: epoch, ...l2ToL1MessageWitness } = l2ToL1MessageResult; - const leafId = getL2ToL1MessageLeafId(l2ToL1MessageWitness); - // We need to advance to the next epoch so that the out hash will be set to outbox when the epoch is proven. const cheatcodes = RollupCheatCodes.create(l1RpcUrls, l1ContractAddresses, t.ctx.dateProvider); - await cheatcodes.advanceToEpoch(EpochNumber(epoch + 1)); + const minedReceipt = await node.getTxReceipt(l2OutgoingReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('Outgoing tx is not yet in an epoch'); + } + await cheatcodes.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); await waitForProven(node, l2OutgoingReceipt, { provenTimeout: 300 }); + const outboxContract = new OutboxContract(l1Client, l1ContractAddresses.outboxAddress); + const l2ToL1MessageResult = (await computeL2ToL1MembershipWitness(node, outboxContract, leaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch, ...l2ToL1MessageWitness } = l2ToL1MessageResult; + const leafId = getL2ToL1MessageLeafId(l2ToL1MessageWitness); + // Then we want to go and comsume it! const outbox = getContract({ address: l1ContractAddresses.outboxAddress.toString(), @@ -377,6 +382,7 @@ describe('e2e_p2p_add_rollup', () => { args: [ l2ToL1Message, BigInt(epoch), + BigInt(numCheckpointsInEpoch), BigInt(l2ToL1MessageWitness.leafIndex), l2ToL1MessageWitness.siblingPath .toBufferArray() diff --git a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts index c1b2b39780eb..4e1d6902221b 100644 --- a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts +++ b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts @@ -13,6 +13,7 @@ import type { AztecNode } from '@aztec/aztec.js/node'; import type { SiblingPath } from '@aztec/aztec.js/trees'; import type { TxReceipt } from '@aztec/aztec.js/tx'; import type { Wallet } from '@aztec/aztec.js/wallet'; +import { OutboxContract } from '@aztec/ethereum/contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; @@ -166,6 +167,7 @@ export class CrossChainTestHarness { private readonly l1TokenManager: L1TokenManager; private readonly l1TokenPortalManager: L1TokenPortalManager; + public readonly outboxContract: OutboxContract; constructor( /** Aztec node instance. */ @@ -206,6 +208,7 @@ export class CrossChainTestHarness { this.logger, ); this.l1TokenManager = this.l1TokenPortalManager.getTokenManager(); + this.outboxContract = new OutboxContract(this.l1Client, this.l1ContractAddresses.outboxAddress); } async mintTokensOnL1(amount: bigint) { @@ -319,10 +322,18 @@ export class CrossChainTestHarness { withdrawFundsFromBridgeOnL1( amount: bigint, epochNumber: EpochNumber, + numCheckpointsInEpoch: number, messageIndex: bigint, siblingPath: SiblingPath, ) { - return this.l1TokenPortalManager.withdrawFunds(amount, this.ethAccount, epochNumber, messageIndex, siblingPath); + return this.l1TokenPortalManager.withdrawFunds( + amount, + this.ethAccount, + epochNumber, + numCheckpointsInEpoch, + messageIndex, + siblingPath, + ); } async transferToPrivateOnL2(shieldAmount: bigint) { diff --git a/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts b/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts index 1950d9925e4a..0cc530f4287a 100644 --- a/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts +++ b/yarn-project/end-to-end/src/shared/uniswap_l1_l2.ts @@ -250,14 +250,13 @@ export const uniswapL1L2TestSuite = ( // ensure that uniswap contract didn't eat the funds. await wethCrossChainHarness.expectPublicBalanceOnL2(uniswapL2Contract.address, 0n); - // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch. - const swapResult = (await computeL2ToL1MembershipWitness( - aztecNode, - swapPrivateLeaf, - l2UniswapInteractionReceipt.txHash, - ))!; - const { epochNumber: epoch } = swapResult; - await cheatCodes.rollup.advanceToEpoch(EpochNumber(epoch + 1)); + // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch + // before we can ask the witness helper for a covering root. + const minedReceipt = await aztecNode.getTxReceipt(l2UniswapInteractionReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('L2 Uniswap interaction tx is not yet in an epoch'); + } + await cheatCodes.rollup.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); await waitForProven(aztecNode, l2UniswapInteractionReceipt, { provenTimeout: 300 }); // 5. Consume L2 to L1 message by calling uniswapPortal.swap_private() @@ -265,11 +264,11 @@ export const uniswapL1L2TestSuite = ( const daiL1BalanceOfPortalBeforeSwap = await daiCrossChainHarness.getL1BalanceOf( daiCrossChainHarness.tokenPortalAddress, ); - const withdrawResult = (await computeL2ToL1MembershipWitness( - aztecNode, - withdrawLeaf, - l2UniswapInteractionReceipt.txHash, - ))!; + // Fetch the outbox roots once and share them between the two witnesses in this epoch. + const epochRoots = await wethCrossChainHarness.outboxContract.getRoots(minedReceipt.epochNumber); + const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, swapPrivateLeaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch } = swapResult; + const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, withdrawLeaf, minedReceipt))!; const swapPrivateL2MessageIndex = swapResult.leafIndex; const swapPrivateSiblingPath = swapResult.siblingPath; @@ -279,6 +278,7 @@ export const uniswapL1L2TestSuite = ( const withdrawMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(withdrawResult.numCheckpointsInEpoch), _leafIndex: BigInt(withdrawL2MessageIndex), _path: withdrawSiblingPath .toBufferArray() @@ -287,6 +287,7 @@ export const uniswapL1L2TestSuite = ( const swapPrivateMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(numCheckpointsInEpoch), _leafIndex: BigInt(swapPrivateL2MessageIndex), _path: swapPrivateSiblingPath .toBufferArray() @@ -843,9 +844,23 @@ export const uniswapL1L2TestSuite = ( chainId: new Fr(l1Client.chain.id), }); - const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, swapPrivateLeaf, withdrawReceipt.txHash))!; - const { epochNumber: epoch } = swapResult; - const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, withdrawLeaf, withdrawReceipt.txHash))!; + // ensure that user's funds were burnt + await wethCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge); + + // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch + // before we can ask the witness helper for a covering root. + const minedReceipt = await aztecNode.getTxReceipt(withdrawReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('Withdraw tx is not yet in an epoch'); + } + await cheatCodes.rollup.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); + await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); + + // Fetch the outbox roots once and share them between the two witnesses in this epoch. + const epochRoots = await wethCrossChainHarness.outboxContract.getRoots(minedReceipt.epochNumber); + const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, swapPrivateLeaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch } = swapResult; + const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, withdrawLeaf, minedReceipt))!; const swapPrivateL2MessageIndex = swapResult.leafIndex; const swapPrivateSiblingPath = swapResult.siblingPath; @@ -855,6 +870,7 @@ export const uniswapL1L2TestSuite = ( const withdrawMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(withdrawResult.numCheckpointsInEpoch), _leafIndex: BigInt(withdrawL2MessageIndex), _path: withdrawSiblingPath .toBufferArray() @@ -863,19 +879,13 @@ export const uniswapL1L2TestSuite = ( const swapPrivateMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(numCheckpointsInEpoch), _leafIndex: BigInt(swapPrivateL2MessageIndex), _path: swapPrivateSiblingPath .toBufferArray() .map((buf: Buffer) => `0x${buf.toString('hex')}`) as readonly `0x${string}`[], }; - // ensure that user's funds were burnt - await wethCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge); - - // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch. - await cheatCodes.rollup.advanceToEpoch(EpochNumber(epoch + 1)); - await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); - // On L1 call swap_public! logger.info('call swap_public on L1'); const swapArgs = [ @@ -975,9 +985,23 @@ export const uniswapL1L2TestSuite = ( chainId: new Fr(l1Client.chain.id), }); - const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, swapPublicLeaf, withdrawReceipt.txHash))!; - const { epochNumber: epoch } = swapResult; - const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, withdrawLeaf, withdrawReceipt.txHash))!; + // check weth balance of owner on L2 (we first bridged `wethAmountToBridge` into L2 and now withdrew it!) + await wethCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, 0n); + + // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch + // before we can ask the witness helper for a covering root. + const minedReceipt = await aztecNode.getTxReceipt(withdrawReceipt.txHash); + if (minedReceipt.epochNumber === undefined) { + throw new Error('Withdraw tx is not yet in an epoch'); + } + await cheatCodes.rollup.advanceToEpoch(EpochNumber(minedReceipt.epochNumber + 1)); + await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); + + // Fetch the outbox roots once and share them between the two witnesses in this epoch. + const epochRoots = await wethCrossChainHarness.outboxContract.getRoots(minedReceipt.epochNumber); + const swapResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, swapPublicLeaf, minedReceipt))!; + const { epochNumber: epoch, numCheckpointsInEpoch } = swapResult; + const withdrawResult = (await computeL2ToL1MembershipWitness(aztecNode, epochRoots, withdrawLeaf, minedReceipt))!; const swapPublicL2MessageIndex = swapResult.leafIndex; const swapPublicSiblingPath = swapResult.siblingPath; @@ -987,6 +1011,7 @@ export const uniswapL1L2TestSuite = ( const withdrawMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(withdrawResult.numCheckpointsInEpoch), _leafIndex: BigInt(withdrawL2MessageIndex), _path: withdrawSiblingPath .toBufferArray() @@ -995,19 +1020,13 @@ export const uniswapL1L2TestSuite = ( const swapPublicMessageMetadata = { _epoch: BigInt(epoch), + _numCheckpointsInEpoch: BigInt(numCheckpointsInEpoch), _leafIndex: BigInt(swapPublicL2MessageIndex), _path: swapPublicSiblingPath .toBufferArray() .map((buf: Buffer) => `0x${buf.toString('hex')}`) as readonly `0x${string}`[], }; - // check weth balance of owner on L2 (we first bridged `wethAmountToBridge` into L2 and now withdrew it!) - await wethCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, 0n); - - // Since the outbox is only consumable when the epoch is proven, we need to advance to the next epoch. - await cheatCodes.rollup.advanceToEpoch(EpochNumber(epoch + 1)); - await waitForProven(aztecNode, withdrawReceipt, { provenTimeout: 300 }); - // Call swap_private on L1 logger.info('Execute withdraw and swap on the uniswapPortal!'); diff --git a/yarn-project/ethereum/src/contracts/outbox.ts b/yarn-project/ethereum/src/contracts/outbox.ts index 698c4fa40665..3042078a7369 100644 --- a/yarn-project/ethereum/src/contracts/outbox.ts +++ b/yarn-project/ethereum/src/contracts/outbox.ts @@ -1,4 +1,5 @@ import type { EpochNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { OutboxAbi } from '@aztec/l1-artifacts/OutboxAbi'; @@ -42,8 +43,20 @@ export class OutboxContract { return new OutboxContract(client, address); } - static getEpochRootStorageSlot(epoch: EpochNumber) { - return hexToBigInt(keccak256(encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [BigInt(epoch), 0n]))); + /** + * Storage slot of `epochs[epoch].roots[numCheckpointsInEpoch - 1]` in the Outbox contract. + * + * The Outbox lays out storage as `mapping(Epoch => EpochData) internal epochs` at base slot 0 + * (ROLLUP and VERSION are immutable and do not occupy slots). For a mapping at slot `b`, the + * value for key `k` begins at `keccak256(abi.encode(k, b))`. `EpochData` starts with a fixed- + * size `bytes32[MAX_CHECKPOINTS_PER_EPOCH] roots` array, so `roots[i]` sits at the EpochData's + * base slot plus `i`. `numCheckpointsInEpoch` is 1-indexed (matches `Outbox.insert`/`consume`). + */ + static getEpochRootStorageSlot(epoch: EpochNumber, numCheckpointsInEpoch: number) { + const epochDataSlot = hexToBigInt( + keccak256(encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [BigInt(epoch), 0n])), + ); + return epochDataSlot + BigInt(numCheckpointsInEpoch - 1); } constructor( @@ -68,13 +81,29 @@ export class OutboxContract { return this.outbox.read.hasMessageBeenConsumedAtEpoch([BigInt(epoch), leafId]); } - public getRootData(epoch: EpochNumber) { - return this.outbox.read.getRootData([BigInt(epoch)]); + public getRootData(epoch: EpochNumber, numCheckpointsInEpoch: number) { + return this.outbox.read.getRootData([BigInt(epoch), BigInt(numCheckpointsInEpoch)]); } - public consume(message: ViemL2ToL1Msg, epoch: EpochNumber, leafIndex: bigint, path: Hex[]) { + /** + * Returns every root stored for `epoch`. Slot `i` of the returned array holds the root inserted + * for `numCheckpointsInEpoch = i + 1`, or `Fr.ZERO` if no proof of that depth has been inserted + * yet. The array length is always `MAX_CHECKPOINTS_PER_EPOCH`. + */ + public async getRoots(epoch: EpochNumber): Promise { + const raw = await this.outbox.read.getRoots([BigInt(epoch)]); + return raw.map(hex => Fr.fromString(hex)); + } + + public consume( + message: ViemL2ToL1Msg, + epoch: EpochNumber, + numCheckpointsInEpoch: number, + leafIndex: bigint, + path: Hex[], + ) { const wallet = this.assertWallet(); - return wallet.write.consume([message, BigInt(epoch), leafIndex, path]); + return wallet.write.consume([message, BigInt(epoch), BigInt(numCheckpointsInEpoch), leafIndex, path]); } public async getMessageConsumedEvents( diff --git a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts index 61448cc37d93..f43b39f54821 100644 --- a/yarn-project/ethereum/src/test/rollup_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/rollup_cheat_codes.ts @@ -270,12 +270,14 @@ export class RollupCheatCodes { }); } - public insertOutbox(epoch: EpochNumber, outHash: bigint) { + public insertOutbox(epoch: EpochNumber, numCheckpointsInEpoch: number, outHash: bigint) { return this.ethCheatCodes.execWithPausedAnvil(async () => { const outboxAddress = await this.rollup.read.getOutbox(); - const epochRootSlot = OutboxContract.getEpochRootStorageSlot(epoch); + const epochRootSlot = OutboxContract.getEpochRootStorageSlot(epoch, numCheckpointsInEpoch); await this.ethCheatCodes.store(EthAddress.fromString(outboxAddress), epochRootSlot, outHash); - this.logger.warn(`Advanced outbox to epoch ${epoch} with out hash ${outHash}`); + this.logger.warn( + `Advanced outbox to epoch ${epoch} numCheckpointsInEpoch ${numCheckpointsInEpoch} with out hash ${outHash}`, + ); }); } diff --git a/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts b/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts index 7dbc9eb5c439..6115aca5d017 100644 --- a/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts +++ b/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts @@ -1,10 +1,25 @@ -import { OUT_HASH_TREE_LEAF_COUNT } from '@aztec/constants'; +import { MAX_CHECKPOINTS_PER_EPOCH, OUT_HASH_TREE_LEAF_COUNT } from '@aztec/constants'; import type { EpochNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { SiblingPath, UnbalancedMerkleTreeCalculator, computeUnbalancedShaRoot } from '@aztec/foundation/trees'; import type { AztecNode } from '../interfaces/aztec-node.js'; import { TxHash } from '../tx/tx_hash.js'; +import { TxReceipt } from '../tx/tx_receipt.js'; + +/** + * Provides access to the L1 Outbox's per-epoch roots so the witness helper can pick the smallest + * partial-proof root that covers a tx's checkpoint. Implemented by `OutboxContract` in the + * ethereum package. + */ +export interface OutboxRootsReader { + /** + * Returns the array of roots stored for `epoch`. Slot `i` holds the root inserted for + * `numCheckpointsInEpoch = i + 1`, or `Fr.ZERO` if no proof of that depth has landed yet. The + * returned array length is `MAX_CHECKPOINTS_PER_EPOCH`. + */ + getRoots(epoch: EpochNumber): Promise; +} /** * # L2-to-L1 Message Tree Structure and Leaf IDs @@ -96,32 +111,50 @@ export function getL2ToL1MessageLeafId( } export type L2ToL1MembershipWitness = { + epochNumber: EpochNumber; + /** + * The number of checkpoints covered by the partial-proof root this witness was built against + * (1-indexed; equal to `roots-array-index + 1` on the Outbox). Pass this through to + * `Outbox.consume` so the contract reads the matching root slot. + */ + numCheckpointsInEpoch: number; root: Fr; leafIndex: bigint; siblingPath: SiblingPath; - epochNumber: EpochNumber; }; /** * Computes the L2 to L1 membership witness for a given message in a transaction. * + * Queries the L1 Outbox to find the smallest partial-proof root that covers the tx's checkpoint, + * then builds the witness against that root by including only the first `numCheckpointsInEpoch` + * checkpoints of the epoch in the tree (the remaining slots are zero-padded, matching the shape + * the rollup proved). Returns `undefined` if the tx is not yet in a block/epoch or if the Outbox + * holds no root yet that covers the tx's checkpoint. + * * @param node - The Aztec node to query for block/tx/epoch data. + * @param outboxOrRoots - Either an `OutboxRootsReader` (the helper will fetch the per-epoch roots), + * or an already-resolved roots array of length `MAX_CHECKPOINTS_PER_EPOCH`. Pass the array when + * you already read the outbox (e.g. inside a tight loop that resolves witnesses for many + * messages in the same epoch) to avoid redundant L1 reads. * @param message - The L2 to L1 message hash to prove membership of. - * @param txHash - The hash of the transaction that emitted the message. + * @param txHashOrReceipt - Either the tx hash, or the already-fetched `TxReceipt`. Passing the + * receipt skips an internal `getTxReceipt` call. * @param messageIndexInTx - Optional index of the message within the transaction's L2-to-L1 messages. * If not provided, the message is found by scanning the tx's messages (throws if duplicates exist). - * @returns The membership witness and epoch number, or undefined if the tx is not yet in a block/epoch. */ export async function computeL2ToL1MembershipWitness( node: Pick< AztecNode, 'getL2ToL1Messages' | 'getTxReceipt' | 'getTxEffect' | 'getBlock' | 'getCheckpointsDataForEpoch' >, + outboxOrRoots: OutboxRootsReader | Fr[], message: Fr, - txHash: TxHash, + txHashOrReceipt: TxHash | Pick, messageIndexInTx?: number, ): Promise { - const { epochNumber, blockNumber } = await node.getTxReceipt(txHash); + const receipt = 'txHash' in txHashOrReceipt ? txHashOrReceipt : await node.getTxReceipt(txHashOrReceipt); + const { txHash, epochNumber, blockNumber } = receipt; if (epochNumber === undefined || blockNumber === undefined) { return undefined; } @@ -145,15 +178,57 @@ export async function computeL2ToL1MembershipWitness( const blockIndex = block.indexWithinCheckpoint; const txIndex = txEffect.txIndexInBlock; + // Pick the smallest partial-proof root on the Outbox that covers checkpointIndex. The Outbox + // stores roots keyed by `numCheckpointsInEpoch - 1`, so to cover a tx in checkpoint at index + // `checkpointIndex` we need a non-zero entry at array index >= checkpointIndex. + const roots = Array.isArray(outboxOrRoots) + ? (outboxOrRoots as Fr[]) + : await (outboxOrRoots as OutboxRootsReader).getRoots(epochNumber); + const numCheckpointsInEpoch = findSmallestCoveringRootCount(roots, checkpointIndex); + if (numCheckpointsInEpoch === undefined) { + return undefined; + } + + // Build the witness against the first `numCheckpointsInEpoch` checkpoints. The inner builder + // pads to OUT_HASH_TREE_LEAF_COUNT internally, so slicing the outer array narrows the real-leaf + // prefix and grows the zero suffix — exactly the shape the rollup proved against. + const messagesInPartialEpoch = messagesInEpoch.slice(0, numCheckpointsInEpoch); + const { root, leafIndex, siblingPath } = computeL2ToL1MembershipWitnessFromMessagesInEpoch( - messagesInEpoch, + messagesInPartialEpoch, message, checkpointIndex, blockIndex, txIndex, messageIndexInTx, ); - return { epochNumber, root, leafIndex, siblingPath }; + + // Cross-check: the recomputed root must equal the root the Outbox is holding for this depth. + // A mismatch means the node and L1 disagree about the epoch's contents; fail loud rather than + // return a witness that will revert on chain. + const expected = roots[numCheckpointsInEpoch - 1]; + if (!root.equals(expected)) { + throw new Error( + `Local epoch out-hash does not match Outbox at epoch ${epochNumber} numCheckpointsInEpoch ` + + `${numCheckpointsInEpoch}: local=${root.toString()} outbox=${expected.toString()}`, + ); + } + + return { epochNumber, numCheckpointsInEpoch, root, leafIndex, siblingPath }; +} + +/** + * Returns the smallest `numCheckpointsInEpoch` (1-indexed) for which the Outbox holds a root that + * covers the message at `checkpointIndex` (0-indexed). Returns `undefined` if no covering root has + * been inserted yet. + */ +function findSmallestCoveringRootCount(roots: Fr[], checkpointIndex: number): number | undefined { + for (let i = checkpointIndex; i < Math.min(roots.length, MAX_CHECKPOINTS_PER_EPOCH); i++) { + if (!roots[i].isZero()) { + return i + 1; + } + } + return undefined; } /**