diff --git a/.test_patterns.yml b/.test_patterns.yml index e4800e12b11c..d98d911ae988 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -126,19 +126,19 @@ tests: - regex: "src/single-node/cross-chain/l1_to_l2" owners: - *palla - - regex: "src/single-node/cross-chain/token_bridge_private" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Claim secret is enough to consume the message" owners: - *lasse - - regex: "src/single-node/cross-chain/token_bridge_public" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Someone else can mint funds to me on my behalf" owners: - *lasse - - regex: "src/single-node/cross-chain/token_bridge_public" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Publicly deposit funds" owners: - *lasse - - regex: "src/single-node/cross-chain/token_bridge_failure_cases" + - regex: "src/single-node/cross-chain/token_bridge" error_regex: "✕ Can't claim funds" owners: - *lasse diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 428646156342..973fe6b83cf1 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -73,8 +73,21 @@ function test_cmds { multi-node/governance/add_rollup) test_prefix="$prefix:TIMEOUT=20m" ;; - single-node/cross-chain/l1_to_l2.parallel) - test_prefix="$prefix:TIMEOUT=20m" + single-node/cross-chain/l1_to_l2) + # The duplicate-message scenario runs over private and public scope serially in one container. + test_prefix="$prefix:TIMEOUT=25m" + ;; + single-node/cross-chain/l1_to_l2_inbox_drift) + # The inbox-drift scenario runs over private and public scope serially in one container. + test_prefix="$prefix:TIMEOUT=25m" + ;; + single-node/cross-chain/l2_to_l1) + # Six message-shape / reorg its run serially in one container (was .parallel, one per it). + test_prefix="$prefix:TIMEOUT=25m" + ;; + single-node/cross-chain/token_bridge) + # Private + public round trips and the failure cases share one node and run serially. + test_prefix="$prefix:TIMEOUT=25m" ;; single-node/block-building/block_building) # Block-building covers the full multi-tx / reorg surface and dumps AVM circuit inputs diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts new file mode 100644 index 000000000000..958543569a5e --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.test.ts @@ -0,0 +1,130 @@ +import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { TestContract } from '@aztec/noir-test-contracts.js/Test'; + +import { jest } from '@jest/globals'; + +import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; +import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; +import { type L1ToL2MessageScope, createL1ToL2MessageHelpers } from './message_test_helpers.js'; + +jest.setTimeout(300_000); + +// L1→L2 messaging via Inbox: message readiness and duplicate messages. Uses CrossChainMessagingTest +// (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=1, +// aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with EpochTestSettler for auto-proving and +// CrossChainTestHarness for L1↔L2 token portal bridging. The duplicate-message scenario runs over +// private and public scope via it.each, all sharing one node stood up once in beforeAll. +describe('single-node/cross-chain/l1_to_l2', () => { + let t: CrossChainMessagingTest; + + let log: Logger; + let aztecNode: AztecNode; + let wallet: Wallet; + let user1Address: AztecAddress; + let testContract: TestContract; + + let sendMessageToL2: ReturnType['sendMessageToL2']; + let waitForMessageReady: ReturnType['waitForMessageReady']; + + // Marks the current pending tip proven on L1. The e2e fixture runs L1 on interval mining and nothing + // marks blocks proven automatically, so without these explicit calls L1's `aztecProofSubmissionEpochs` + // window expires mid-test and prunes in-flight wallet txs. + const markAsProven = () => t.cheatCodes.rollup.markAsProven(); + + beforeAll(async () => { + t = new CrossChainMessagingTest( + 'l1_to_l2', + // PIPELINING_SETUP_OPTS sets minTxsPerBlock=0; this test needs minTxsPerBlock=1 because it + // manually mines blocks one tx at a time and counts checkpoints, so empty pipelined checkpoints + // interleaving between txs would break the exact block-number assertions. + { ...PIPELINING_SETUP_OPTS, minTxsPerBlock: 1 }, + { aztecProofSubmissionEpochs: 2, aztecEpochDuration: 4 }, + // Anchor PXE to the checkpointed chain so that a proposed-chain invalidation cascade + // (e.g. a missed checkpoint publish that prunes the pipelined proposed chain) doesn't + // drop the wallet's in-flight tx via handlePrunedBlocks. + { syncChainTip: 'checkpointed' }, + // This suite only passes arbitrary L1→L2 messages to its own TestContract; it never bridges + // tokens, so skip the token+portal+bridge deploy and use the test's L1 handles directly. + { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX, deployTokenBridge: false }, + ); + await t.setup(); + + ({ logger: log, wallet, user1Address, aztecNode } = t); + ({ contract: testContract } = await TestContract.deploy(wallet).send({ from: user1Address })); + + ({ sendMessageToL2, waitForMessageReady } = createL1ToL2MessageHelpers({ + t, + aztecNode, + wallet, + user1Address, + log, + markAsProven, + })); + }, 300_000); + + afterAll(async () => { + await t.teardown(); + }); + + const getConsumeMethod = (scope: L1ToL2MessageScope) => + scope === 'private' + ? testContract.methods.consume_message_from_arbitrary_sender_private + : testContract.methods.consume_message_from_arbitrary_sender_public; + + // We register one portal address when deploying contract but that address is no-longer the only address + // allowed to send messages to the given contract. In the following test we'll test that it's really the case. + // We'll also test that we can send the same message content across the bridge multiple times. + const canSendMessageFromNonRegisteredPortal = async (scope: L1ToL2MessageScope) => { + // Generate and send the message to the L1 contract + const [secret, secretHash] = await generateClaimSecret(); + const message = { recipient: testContract.address, content: Fr.random(), secretHash }; + const { msgHash: message1Hash, globalLeafIndex: actualMessage1Index } = await sendMessageToL2(message); + + await waitForMessageReady(message1Hash, scope); + + const [message1Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message1Hash))!; + expect(actualMessage1Index.toBigInt()).toBe(message1Index); + + const sendConsumeMsgTx = async (index: Fr) => { + const call = getConsumeMethod(scope)(message.content, secret, t.ethAccount, index); + if (scope === 'public') { + await call.simulate({ from: user1Address }); + } + await call.send({ from: user1Address }); + }; + + // We consume the L1 to L2 message using the test contract either from private or public + await sendConsumeMsgTx(actualMessage1Index); + + // We send and consume the exact same message the second time to test that oracles correctly return the new + // non-nullified message + const { msgHash: message2Hash, globalLeafIndex: actualMessage2Index } = await sendMessageToL2(message); + + // We check that the duplicate message was correctly inserted by checking that its message index is defined + await waitForMessageReady(message2Hash, scope); + + const [message2Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message2Hash))!; + expect(message2Index).toBeDefined(); + expect(message2Index).toBeGreaterThan(actualMessage1Index.toBigInt()); + expect(actualMessage2Index.toBigInt()).toBe(message2Index); + + // Now we consume the message again. Everything should pass because oracle should return the duplicate message + // which is not nullified + await sendConsumeMsgTx(actualMessage2Index); + }; + + // Sends the same L1→L2 message content twice via a non-registered portal, waits for each to be + // ready, and consumes both. Verifies duplicate messages are indexed correctly and the second + // consumption uses the non-nullified duplicate leaf, from both private and public scope. + it.each(['private', 'public'] as const)( + 'can send an L1 to L2 message from a non-registered portal address consumed from %s repeatedly', + async scope => { + await canSendMessageFromNonRegisteredPortal(scope); + }, + ); +}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.parallel.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2_inbox_drift.test.ts similarity index 54% rename from yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.parallel.test.ts rename to yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2_inbox_drift.test.ts index d9c27d6be37c..a7164ada4d0a 100644 --- a/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/cross-chain/l1_to_l2_inbox_drift.test.ts @@ -1,39 +1,43 @@ -import { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; -import { isL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import type { AztecNode } from '@aztec/aztec.js/node'; import { TxExecutionResult } from '@aztec/aztec.js/tx'; import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; +import type { BlockNumber } from '@aztec/foundation/branded-types'; import { timesAsync } from '@aztec/foundation/collection'; import { retryUntil } from '@aztec/foundation/retry'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; -import { ExecutionPayload } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { sendL1ToL2Message } from '../../fixtures/l1_to_l2_messaging.js'; import { waitForBlockNumber } from '../../fixtures/wait_helpers.js'; import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; +import { type L1ToL2MessageScope, createL1ToL2MessageHelpers } from './message_test_helpers.js'; jest.setTimeout(300_000); -// L1→L2 messaging via Inbox: message readiness, duplicate messages, and inbox drift after a rollup -// reorg. Uses CrossChainMessagingTest (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, -// inboxLag=2, minTxsPerBlock=1, aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with -// EpochTestSettler for auto-proving and CrossChainTestHarness for L1↔L2 token portal bridging. -// Each it is run as an independent CI job (*.parallel.test.ts convention). -describe('single-node/cross-chain/l1_to_l2', () => { +// L1→L2 messaging via Inbox: inbox checkpoint drift after a rollup reorg. Uses CrossChainMessagingTest +// (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, inboxLag=2, minTxsPerBlock=1, +// aztecProofSubmissionEpochs=2, aztecEpochDuration=4) with EpochTestSettler for auto-proving and +// CrossChainTestHarness for L1↔L2 token portal bridging. The drift scenario runs over private and +// public scope via it.each, all sharing one node stood up once in beforeAll. +describe('single-node/cross-chain/l1_to_l2_inbox_drift', () => { let t: CrossChainMessagingTest; + let log: Logger; let aztecNode: AztecNode; let wallet: Wallet; let user1Address: AztecAddress; let testContract: TestContract; + let sendMessageToL2: ReturnType['sendMessageToL2']; + let advanceBlock: ReturnType['advanceBlock']; + let waitForMessageFetched: ReturnType['waitForMessageFetched']; + let waitForMessageReady: ReturnType['waitForMessageReady']; + // Whether explicit mark-as-proven calls are honored. The inbox-drift scenario flips this to // false to let the proposed chain drift and prune. let markProvenEnabled = true; @@ -48,10 +52,9 @@ describe('single-node/cross-chain/l1_to_l2', () => { await t.cheatCodes.rollup.markAsProven(); }; - beforeEach(async () => { - markProvenEnabled = true; + beforeAll(async () => { t = new CrossChainMessagingTest( - 'l1_to_l2', + 'l1_to_l2_inbox_drift', // PIPELINING_SETUP_OPTS sets minTxsPerBlock=0; this test needs minTxsPerBlock=1 because it // manually mines blocks one tx at a time via advanceBlock() and counts checkpoints, so empty // pipelined checkpoints interleaving between txs would break the exact block-number assertions. @@ -69,41 +72,31 @@ describe('single-node/cross-chain/l1_to_l2', () => { ({ logger: log, wallet, user1Address, aztecNode } = t); ({ contract: testContract } = await TestContract.deploy(wallet).send({ from: user1Address })); + + ({ sendMessageToL2, advanceBlock, waitForMessageFetched, waitForMessageReady } = createL1ToL2MessageHelpers({ + t, + aztecNode, + wallet, + user1Address, + log, + markAsProven, + })); }, 300_000); - afterEach(async () => { - await t.teardown(); + // Reset the proving gate before every it so a scenario that disabled it can't leak into the next. + beforeEach(() => { + markProvenEnabled = true; }); - // Sends an L1→L2 message from the harness L1 account. This suite skips the token bridge, so the - // message context is built from the test's L1 handles rather than a CrossChainTestHarness. - const sendMessageToL2 = (message: { recipient: AztecAddress; content: Fr; secretHash: Fr }) => - sendL1ToL2Message(message, { - l1Client: t.harnessL1Client, - l1ContractAddresses: t.deployL1ContractsValues.l1ContractAddresses, - }); + afterAll(async () => { + await t.teardown(); + }); - const getConsumeMethod = (scope: 'private' | 'public') => + const getConsumeMethod = (scope: L1ToL2MessageScope) => scope === 'private' ? testContract.methods.consume_message_from_arbitrary_sender_private : testContract.methods.consume_message_from_arbitrary_sender_public; - // Sends a tx to L2 to advance the block number by 1 - const advanceBlock = async () => { - const block = await aztecNode.getBlockNumber(); - log.warn(`Sending noop tx at block ${block}`); - await wallet.sendTx(ExecutionPayload.empty(), { from: user1Address }); - const newBlock = await aztecNode.getBlockNumber(); - log.warn(`Advanced to block ${newBlock}`); - if (newBlock === block) { - throw new Error(`Failed to advance block ${block}`); - } - // Keep the proof window from expiring mid-test (see `markAsProven` above). No-op once the drift - // scenario disables proving. - await markAsProven(); - return newBlock; - }; - const waitForBlockToCheckpoint = async (blockNumber: BlockNumber) => { await waitForBlockNumber(aztecNode, blockNumber, { tag: 'checkpointed', timeout: 60 }); const [checkpointedBlock] = await aztecNode.getBlocks(blockNumber, 1, { @@ -125,7 +118,7 @@ describe('single-node/cross-chain/l1_to_l2', () => { log.warn(`At checkpoint ${checkpoint}`); }; - // Same as above but ignores errors. Useful if we expect a prune. + // Same as advanceBlock but ignores errors. Useful if we expect a prune. const tryAdvanceBlock = async () => { try { await advanceBlock(); @@ -134,115 +127,11 @@ describe('single-node/cross-chain/l1_to_l2', () => { } }; - // Waits until the message is fetched by the archiver of the node and returns the msg target checkpoint. - // Advances a block on each retry because an L1->L2 message is only indexed once further L2 blocks build. - const waitForMessageFetched = async (msgHash: Fr) => { - log.warn(`Waiting until the message is fetched by the node`); - return await retryUntil( - async () => { - const checkpoint = await aztecNode.getL1ToL2MessageCheckpoint(msgHash); - if (checkpoint !== undefined) { - return checkpoint; - } - await advanceBlock(); - return undefined; - }, - 'get msg checkpoint', - 60, - ); - }; - - // Waits until the message is ready to be consumed on L2 as it's been added to the world state - const waitForMessageReady = async ( - msgHash: Fr, - scope: 'private' | 'public', - onNotReady?: (blockNumber: BlockNumber) => Promise, - ) => { - const msgCheckpoint = await waitForMessageFetched(msgHash); - log.warn( - `Waiting until L2 reaches the first block of msg checkpoint ${msgCheckpoint} (current is ${await aztecNode.getCheckpointNumber()})`, - ); - await retryUntil( - async () => { - const [blockNumber, checkpointNumber] = await Promise.all([ - aztecNode.getBlockNumber(), - aztecNode.getCheckpointNumber(), - ]); - const witness = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); - const isReady = await isL1ToL2MessageReady(aztecNode, msgHash); - log.info( - `Block is ${blockNumber}, checkpoint is ${checkpointNumber}. Message checkpoint is ${msgCheckpoint}. Witness ${!!witness}. Ready ${isReady}.`, - ); - if (!isReady) { - await (onNotReady ? onNotReady(blockNumber) : advanceBlock()); - } - return isReady; - }, - `wait for rollup to reach msg checkpoint ${msgCheckpoint}`, - 240, - ); - }; - - // We register one portal address when deploying contract but that address is no-longer the only address - // allowed to send messages to the given contract. In the following test we'll test that it's really the case. - // We'll also test that we can send the same message content across the bridge multiple times. - const canSendMessageFromNonRegisteredPortal = async (scope: 'private' | 'public') => { - // Generate and send the message to the L1 contract - const [secret, secretHash] = await generateClaimSecret(); - const message = { recipient: testContract.address, content: Fr.random(), secretHash }; - const { msgHash: message1Hash, globalLeafIndex: actualMessage1Index } = await sendMessageToL2(message); - - await waitForMessageReady(message1Hash, scope); - - const [message1Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message1Hash))!; - expect(actualMessage1Index.toBigInt()).toBe(message1Index); - - const sendConsumeMsgTx = async (index: Fr) => { - const call = getConsumeMethod(scope)(message.content, secret, t.ethAccount, index); - if (scope === 'public') { - await call.simulate({ from: user1Address }); - } - await call.send({ from: user1Address }); - }; - - // We consume the L1 to L2 message using the test contract either from private or public - await sendConsumeMsgTx(actualMessage1Index); - - // We send and consume the exact same message the second time to test that oracles correctly return the new - // non-nullified message - const { msgHash: message2Hash, globalLeafIndex: actualMessage2Index } = await sendMessageToL2(message); - - // We check that the duplicate message was correctly inserted by checking that its message index is defined - await waitForMessageReady(message2Hash, scope); - - const [message2Index] = (await aztecNode.getL1ToL2MessageMembershipWitness('latest', message2Hash))!; - expect(message2Index).toBeDefined(); - expect(message2Index).toBeGreaterThan(actualMessage1Index.toBigInt()); - expect(actualMessage2Index.toBigInt()).toBe(message2Index); - - // Now we consume the message again. Everything should pass because oracle should return the duplicate message - // which is not nullified - await sendConsumeMsgTx(actualMessage2Index); - }; - - // Sends the same L1→L2 message content twice via a non-registered portal, waits for each to be - // ready, and consumes both from private. Verifies duplicate messages are indexed correctly and - // the second consumption uses the non-nullified duplicate leaf. - it('can send an L1 to L2 message from a non-registered portal address consumed from private repeatedly', async () => { - await canSendMessageFromNonRegisteredPortal('private'); - }); - - // Same as above but the message is consumed from public state. Verifies the public consumption - // path handles duplicate messages and the oracle returns the correct non-nullified leaf index. - it('can send an L1 to L2 message from a non-registered portal address consumed from public repeatedly', async () => { - await canSendMessageFromNonRegisteredPortal('public'); - }); - // Inbox checkpoint number can drift on two scenarios: if the rollup reorgs and rolls back its own // checkpoint number, or if the inbox receives too many messages and they are inserted faster than // they are consumed. In this test, we mine several checkpoints without marking them as proven until // we can trigger a reorg, and then wait until the message can be processed to consume it. - const canConsumeMessageAfterInboxDrift = async (scope: 'private' | 'public') => { + const canConsumeMessageAfterInboxDrift = async (scope: L1ToL2MessageScope) => { // Stop the background epoch test settler so the drift scenario below can proceed without // an auto-prover racing it. await t.epochTestSettler?.stop(); @@ -333,15 +222,13 @@ describe('single-node/cross-chain/l1_to_l2', () => { }; // Mines four checkpoints without proving, inserting an L1→L2 message after the drift, then - // triggers a rollup prune back to the pre-drift block. Verifies the message can be consumed from - // private only after the chain re-syncs to the message's checkpoint, not before. - it('can consume L1 to L2 message in private after inbox drifts away from the rollup', async () => { - await canConsumeMessageAfterInboxDrift('private'); - }); - - // Same drift scenario but consuming from public. Uses a send+dontThrowOnRevert loop to probe when - // the message becomes consumable, then verifies the successful tx is in the message's checkpoint. - it('can consume L1 to L2 message in public after inbox drifts away from the rollup', async () => { - await canConsumeMessageAfterInboxDrift('public'); - }); + // triggers a rollup prune back to the pre-drift block. Verifies the message can be consumed only + // after the chain re-syncs to the message's checkpoint, not before, from both private and public + // scope (public uses a send+dontThrowOnRevert loop to probe when the message becomes consumable). + it.each(['private', 'public'] as const)( + 'can consume L1 to L2 message in %s after inbox drifts away from the rollup', + async scope => { + await canConsumeMessageAfterInboxDrift(scope); + }, + ); }); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.parallel.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.test.ts similarity index 79% rename from yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.parallel.test.ts rename to yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.test.ts index bcc4fb3c9f58..999477df5b1b 100644 --- a/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/cross-chain/l2_to_l1.test.ts @@ -28,9 +28,7 @@ describe('single-node/cross-chain/l2_to_l1', () => { // multi-tx flows exceed the default 300s per-test budget. jest.setTimeout(15 * 60 * 1000); - // This suite only passes arbitrary L2→L1 messages from its own TestContract; it never bridges - // tokens, so skip the token+portal+bridge deploy and use the test's L1 handles directly. - const t = new CrossChainMessagingTest('l2_to_l1', { startProverNode: true }, {}, {}, { deployTokenBridge: false }); + let t: CrossChainMessagingTest; let aztecNode: AztecNode; let aztecNodeAdmin: AztecNodeAdmin; @@ -44,6 +42,9 @@ describe('single-node/cross-chain/l2_to_l1', () => { let contract: TestContract; beforeAll(async () => { + // This suite only passes arbitrary L2→L1 messages from its own TestContract; it never bridges + // tokens, so skip the token+portal+bridge deploy and use the test's L1 handles directly. + t = new CrossChainMessagingTest('l2_to_l1', { startProverNode: true }, {}, {}, { deployTokenBridge: false }); await t.setup({ ...PIPELINING_SETUP_OPTS }, { syncChainTip: 'checkpointed' }); ({ aztecNode, aztecNodeAdmin, wallet, user1Address, rollup, outbox } = t); @@ -195,114 +196,67 @@ describe('single-node/cross-chain/l2_to_l1', () => { await expectConsumeMessageToSucceed(message, withMessageReceipt.txHash); }); - // Two txs with 3 and 4 messages respectively. Verifies the mixed-height subtree structure is - // built correctly and representative messages from each tx are consumable after epoch proving. - it('2 txs (balanced), one with 3 messages (unbalanced), one with 4 messages (balanced)', async () => { - // Force txs to be in the same block. - await aztecNodeAdmin!.setConfig({ minTxsPerBlock: 2 }); + // Multiple txs of differing message counts packed into a single block, exercising the balanced and + // unbalanced L2→L1 message subtree shapes. Verifies all txs land in the same block, the block + // contents match the recomputed leaves (for the two-tx case), and representative messages from each + // tx — chosen to span the different subtree heights — are consumable after epoch proving. The + // `consume` tuples are `[txIndex, messageIndex]` pairs. + it.each([ + { + name: '2 txs (balanced), one with 3 messages (unbalanced), one with 4 messages (balanced)', + messageCounts: [3, 4], + consume: [ + [0, 0], + [0, 2], + [1, 0], + ], + checkBlockContents: true, + }, + { + name: '3 txs (unbalanced), one with 3 messages (unbalanced), one with 1 message (the subtree root), one with 2 messages (balanced)', + messageCounts: [3, 1, 2], + consume: [ + [0, 0], + [0, 2], + [1, 0], + [2, 1], + ], + checkBlockContents: false, + }, + ])('$name', async ({ messageCounts, consume, checkBlockContents }) => { + // Force all txs into the same block. + await aztecNodeAdmin.setConfig({ minTxsPerBlock: messageCounts.length }); await waitForSequencerState(t.context.sequencer!.getSequencer(), SequencerState.IDLE); - const tx0 = generateMessages(3); - const tx1 = generateMessages(4); + const txs = messageCounts.map(count => generateMessages(count)); + const calls = txs.map(tx => createBatchCall(wallet, tx.recipients, tx.contents)); - const call0 = createBatchCall(wallet, tx0.recipients, tx0.contents); - const call1 = createBatchCall(wallet, tx1.recipients, tx1.contents); + const receipts = await Promise.all(calls.map(async call => (await call.send({ from: user1Address })).receipt)); - const [{ receipt: l2TxReceipt0 }, { receipt: l2TxReceipt1 }] = await Promise.all([ - call0.send({ from: user1Address }), - call1.send({ from: user1Address }), - ]); - - // Check that the 2 txs are in the same block. - const blockNumber = l2TxReceipt0.blockNumber!; - expect(l2TxReceipt1.blockNumber).toEqual(blockNumber); + // Check that all txs are in the same block. + const blockNumber = receipts[0].blockNumber!; + for (const receipt of receipts.slice(1)) { + expect(receipt.blockNumber).toEqual(blockNumber); + } - // Check that the block contains all the messages. - { + if (checkBlockContents) { + // Check that the block contains all the messages. const block = (await aztecNode.getBlock(blockNumber, { includeTransactions: true }))!; const messagesForAllTxs = block.body.txEffects.map(txEffect => txEffect.l2ToL1Msgs); - // We cannot guarantee the order of txs in a block, so we rearrange the leaves if call1 was rolled up first. - const [firstTx, secondTx] = messagesForAllTxs[0].length === 3 ? [tx0, tx1] : [tx1, tx0]; + // We cannot guarantee the order of txs in a block, so we rearrange the leaves if the second tx was rolled up first. + const [firstTx, secondTx] = + messagesForAllTxs[0].length === txs[0].messages.length ? [txs[0], txs[1]] : [txs[1], txs[0]]; const expectedLeaves = firstTx.messages.concat(secondTx.messages).map(msg => computeMessageLeaf(msg)); expect(messagesForAllTxs.flat()).toEqual(expectedLeaves); } // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. - await t.advanceToEpochProven(l2TxReceipt1); - - // Consume messages in tx0. - { - // Consume messages[0], which is in the subtree of height 2. - const msg = tx0.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - { - // Consume messages[2], which is in the subtree of height 1. - const msg = tx0.messages[2]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - - // Consume messages in tx1. - { - // Consume messages[2], which is in the subtree of height 2. - const msg = tx1.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt1.txHash); - } - }); - - // Three txs with 3, 1, and 2 messages. The 1-message tx's subtree root is the leaf itself; the - // 3-message tx is unbalanced. Verifies representative messages from each tx are consumable. - it('3 txs (unbalanced), one with 3 messages (unbalanced), one with 1 message (the subtree root), one with 2 messages (balanced)', async () => { - // Force txs to be in the same block. - await aztecNodeAdmin!.setConfig({ minTxsPerBlock: 3 }); - await waitForSequencerState(t.context.sequencer!.getSequencer(), SequencerState.IDLE); - - const tx0 = generateMessages(3); - const tx1 = generateMessages(1); - const tx2 = generateMessages(2); - - const call0 = createBatchCall(wallet, tx0.recipients, tx0.contents); - const call1 = createBatchCall(wallet, tx1.recipients, tx1.contents); - const call2 = createBatchCall(wallet, tx2.recipients, tx2.contents); - - const [{ receipt: l2TxReceipt0 }, { receipt: l2TxReceipt1 }, { receipt: l2TxReceipt2 }] = await Promise.all([ - call0.send({ from: user1Address }), - call1.send({ from: user1Address }), - call2.send({ from: user1Address }), - ]); - - // Check that all txs are in the same block. - const blockNumber = l2TxReceipt0.blockNumber!; - expect(l2TxReceipt1.blockNumber).toEqual(blockNumber); - expect(l2TxReceipt2.blockNumber).toEqual(blockNumber); - - // Advance the epoch until the tx is proven since the messages are inserted to the outbox when the epoch is proven. - await t.advanceToEpochProven(l2TxReceipt2); + await t.advanceToEpochProven(receipts[receipts.length - 1]); - // Consume messages in tx0. - { - // Consume messages[0], which is in the subtree of height 2. - const msg = tx0.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - { - // Consume messages[2], which is in the subtree of height 1. - const msg = tx0.messages[2]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt0.txHash); - } - - // Consume messages in tx1. - { - // Consume messages[0], which is the tx subtree root. - const msg = tx1.messages[0]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt1.txHash); - } - - // Consume messages in tx2. - { - // Consume messages[1], which is in the subtree of height 1. - const msg = tx2.messages[1]; - await expectConsumeMessageToSucceed(msg, l2TxReceipt2.txHash); + // Consume a representative message from each tx (spanning the different subtree heights) and + // assert each consume succeeds and cannot be replayed. + for (const [txIndex, messageIndex] of consume) { + await expectConsumeMessageToSucceed(txs[txIndex].messages[messageIndex], receipts[txIndex].txHash); } }); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts b/yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts new file mode 100644 index 000000000000..4e520ed7002a --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/cross-chain/message_test_helpers.ts @@ -0,0 +1,125 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import { isL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import type { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { ExecutionPayload } from '@aztec/stdlib/tx'; + +import { sendL1ToL2Message } from '../../fixtures/l1_to_l2_messaging.js'; +import type { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; + +/** Scope from which an L1→L2 message is consumed on L2. */ +export type L1ToL2MessageScope = 'private' | 'public'; + +/** Dependencies the L1→L2 message helpers close over. */ +export interface L1ToL2MessageHelperDeps { + t: CrossChainMessagingTest; + aztecNode: AztecNode; + wallet: Wallet; + user1Address: AztecAddress; + log: Logger; + /** Marks the current pending tip proven on L1, subject to the caller's proving policy. */ + markAsProven: () => Promise; +} + +/** Helpers for driving L1→L2 messages through the inbox, shared across the L1→L2 messaging suites. */ +export interface L1ToL2MessageHelpers { + sendMessageToL2(message: { + recipient: AztecAddress; + content: Fr; + secretHash: Fr; + }): ReturnType; + advanceBlock(): Promise; + waitForMessageFetched(msgHash: Fr): Promise; + waitForMessageReady( + msgHash: Fr, + scope: L1ToL2MessageScope, + onNotReady?: (blockNumber: BlockNumber) => Promise, + ): Promise; +} + +/** + * Builds the L1→L2 message helpers over a running {@link CrossChainMessagingTest}. The `markAsProven` + * dependency lets each suite plug in its own proving policy: suites that never pause proving pass an + * unconditional mark, while the inbox-drift suite gates it behind a flag it toggles mid-test. + */ +export function createL1ToL2MessageHelpers(deps: L1ToL2MessageHelperDeps): L1ToL2MessageHelpers { + const { t, aztecNode, wallet, user1Address, log, markAsProven } = deps; + + // Sends an L1→L2 message from the harness L1 account. This suite skips the token bridge, so the + // message context is built from the test's L1 handles rather than a CrossChainTestHarness. + const sendMessageToL2 = (message: { recipient: AztecAddress; content: Fr; secretHash: Fr }) => + sendL1ToL2Message(message, { + l1Client: t.harnessL1Client, + l1ContractAddresses: t.deployL1ContractsValues.l1ContractAddresses, + }); + + // Sends a tx to L2 to advance the block number by 1 + const advanceBlock = async () => { + const block = await aztecNode.getBlockNumber(); + log.warn(`Sending noop tx at block ${block}`); + await wallet.sendTx(ExecutionPayload.empty(), { from: user1Address }); + const newBlock = await aztecNode.getBlockNumber(); + log.warn(`Advanced to block ${newBlock}`); + if (newBlock === block) { + throw new Error(`Failed to advance block ${block}`); + } + // Keep the proof window from expiring mid-test. No-op once a drift scenario disables proving. + await markAsProven(); + return newBlock; + }; + + // Waits until the message is fetched by the archiver of the node and returns the msg target checkpoint. + // Advances a block on each retry because an L1->L2 message is only indexed once further L2 blocks build. + const waitForMessageFetched = async (msgHash: Fr) => { + log.warn(`Waiting until the message is fetched by the node`); + return await retryUntil( + async () => { + const checkpoint = await aztecNode.getL1ToL2MessageCheckpoint(msgHash); + if (checkpoint !== undefined) { + return checkpoint; + } + await advanceBlock(); + return undefined; + }, + 'get msg checkpoint', + 60, + ); + }; + + // Waits until the message is ready to be consumed on L2 as it's been added to the world state + const waitForMessageReady = async ( + msgHash: Fr, + scope: L1ToL2MessageScope, + onNotReady?: (blockNumber: BlockNumber) => Promise, + ) => { + const msgCheckpoint = await waitForMessageFetched(msgHash); + log.warn( + `Waiting until L2 reaches the first block of msg checkpoint ${msgCheckpoint} (current is ${await aztecNode.getCheckpointNumber()})`, + ); + await retryUntil( + async () => { + const [blockNumber, checkpointNumber] = await Promise.all([ + aztecNode.getBlockNumber(), + aztecNode.getCheckpointNumber(), + ]); + const witness = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + const isReady = await isL1ToL2MessageReady(aztecNode, msgHash); + log.info( + `Block is ${blockNumber}, checkpoint is ${checkpointNumber}. Message checkpoint is ${msgCheckpoint}. Witness ${!!witness}. Ready ${isReady}.`, + ); + if (!isReady) { + await (onNotReady ? onNotReady(blockNumber) : advanceBlock()); + } + return isReady; + }, + `wait for rollup to reach msg checkpoint ${msgCheckpoint}`, + 240, + ); + }; + + return { sendMessageToL2, advanceBlock, waitForMessageFetched, waitForMessageReady }; +} diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts new file mode 100644 index 000000000000..959bd3821bc7 --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge.test.ts @@ -0,0 +1,354 @@ +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/aztec.js/messaging'; +import { sha256ToField } from '@aztec/foundation/crypto/sha256'; + +import { jest } from '@jest/globals'; +import { toFunctionSelector } from 'viem'; + +import { + L1_DIRECT_WRITE_ACCOUNT_INDEX, + NO_L1_TO_L2_MSG_ERROR, + PIPELINING_SETUP_OPTS, +} from '../../fixtures/fixtures.js'; +import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; +import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; + +// TokenBridge L1<->L2 round trips (private + public deposits/withdrawals) and their failure cases, +// merged onto a single CrossChainMessagingTest harness with startProverNode=true (prod sequencer, +// pipelining preset: ethSlot=4s, aztecSlot=12s), a fake in-proc prover node, and CrossChainTestHarness +// for the full portal/bridge lifecycle. Epoch proving via advanceToEpochProven is required before L1 +// Outbox consumption. All its share one node, so balance assertions are expressed as deltas over the +// balance captured at the start of each it rather than absolute amounts. +describe('single-node/cross-chain/token_bridge', () => { + // Pipelining slows wall-clock chain progress (12s slots); waitForProven via advanceToEpochProven + // needs more than the default 300s per-test budget. + jest.setTimeout(15 * 60 * 1000); + + let t: CrossChainMessagingTest; + + let version = 1; + + beforeAll(async () => { + t = new CrossChainMessagingTest( + 'token_bridge', + { startProverNode: true }, + {}, + {}, + { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX }, + ); + await t.setup({ ...PIPELINING_SETUP_OPTS }); + version = Number(await t.rollup.getVersion()); + }, 300_000); + + afterAll(async () => { + await t.teardown(); + }); + + describe('private', () => { + // Full round-trip: mint tokens on L1, deposit privately via TokenPortal, wait for the message to be + // consumable, claim on L2 (minting private tokens), withdraw back to L1 with an authwit, advance the + // epoch until proven, then consume the Outbox message on L1 and verify the L1 balance is restored. + it('Privately deposit funds from L1 -> L2 and withdraw back to L1', async () => { + const { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet } = t; + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialPrivateBalance = await crossChainTestHarness.getL2PrivateBalanceOf(ownerAddress); + + // 1. Mint tokens on L1 + await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); + + // 2. Deposit tokens to the TokenPortal + const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // 3. Consume L1 -> L2 message and mint private tokens on L2 + await crossChainTestHarness.consumeMessageOnAztecAndMintPrivately(claim); + await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, initialPrivateBalance + bridgeAmount); + + // time to withdraw the funds again! + logger.info('Withdrawing funds from L2'); + + // 4. Give approval to bridge to burn owner's funds: + const withdrawAmount = 9n; + const authwitNonce = Fr.random(); + const burnAuthwit = await wallet.createAuthWit(ownerAddress, { + caller: l2Bridge.address, + action: l2Token.methods.burn_private(ownerAddress, withdrawAmount, authwitNonce), + }); + + // 5. Withdraw owner's funds from L2 to L1 + const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); + const l2TxReceipt = await crossChainTestHarness.withdrawPrivateFromAztecToL1( + withdrawAmount, + authwitNonce, + burnAuthwit, + ); + await crossChainTestHarness.expectPrivateBalanceOnL2( + ownerAddress, + initialPrivateBalance + bridgeAmount - withdrawAmount, + ); + + // 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 waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { + timeout: 60, + }); + + // Check balance before and after exit. + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + await crossChainTestHarness.withdrawFundsFromBridgeOnL1( + withdrawAmount, + l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, + l2ToL1MessageResult.leafIndex, + l2ToL1MessageResult.siblingPath, + ); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount + withdrawAmount, + ); + }); + + // This test checks that it's enough to have the claim secret to claim the funds to whoever we want. + // User2 (not the original depositor) uses the claim secret to call claim_private on behalf of owner. + // Asserts the funds land at ownerAddress (not user2), proving the secret-based authorization works. + it('Claim secret is enough to consume the message', async () => { + const { crossChainTestHarness, ethAccount, ownerAddress, l2Bridge, user2Address } = t; + const initialPublicBalance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialPrivateBalance = await crossChainTestHarness.getL2PrivateBalanceOf(ownerAddress); + + const bridgeAmount = 100n; + const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialPublicBalance - bridgeAmount); + + // Wait for the message to be available for consumption + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // send the right one - + await l2Bridge.methods + .claim_private(ownerAddress, bridgeAmount, claim.claimSecret, claim.messageLeafIndex) + .send({ from: user2Address }); + + await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, initialPrivateBalance + bridgeAmount); + }, 90_000); + }); + + describe('public', () => { + // Full round-trip: mint on L1, publicly deposit via TokenPortal, wait for message, claim_public on + // L2, authorize bridge to burn, withdraw to L1, advance to epoch proven, consume Outbox on L1. + // Asserts L1 balance is restored after the round-trip. + it('Publicly deposit funds from L1 -> L2 and withdraw back to L1', async () => { + const { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet } = t; + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialPublicBalance = await crossChainTestHarness.getL2PublicBalanceOf(ownerAddress); + + // 1. Mint tokens on L1 + logger.verbose(`1. Mint tokens on L1`); + await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); + + // 2. Deposit tokens to the TokenPortal + logger.verbose(`2. Deposit tokens to the TokenPortal`); + const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); + const msgHash = Fr.fromHexString(claim.messageHash); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + + // Wait for the message to be available for consumption + logger.verbose(`Wait for the message to be available for consumption`); + await crossChainTestHarness.makeMessageConsumable(msgHash); + + // Check message leaf index matches + const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + expect(maybeIndexAndPath).toBeDefined(); + const messageLeafIndex = maybeIndexAndPath![0]; + expect(messageLeafIndex).toEqual(claim.messageLeafIndex); + + // 3. Consume L1 -> L2 message and mint public tokens on L2 + logger.verbose('3. Consume L1 -> L2 message and mint public tokens on L2'); + await crossChainTestHarness.consumeMessageOnAztecAndMintPublicly(claim); + await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, initialPublicBalance + bridgeAmount); + + // Time to withdraw the funds again! + logger.info('Withdrawing funds from L2'); + + // 4. Give approval to bridge to burn owner's funds: + const withdrawAmount = 9n; + const authwitNonce = Fr.random(); + const validateActionInteraction = await wallet.setPublicAuthWit( + ownerAddress, + { + caller: l2Bridge.address, + action: l2Token.methods.burn_public(ownerAddress, withdrawAmount, authwitNonce), + }, + true, + ); + await validateActionInteraction.send(); + + // 5. Withdraw owner's funds from L2 to L1 + logger.verbose('5. Withdraw owner funds from L2 to L1'); + const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); + const l2TxReceipt = await crossChainTestHarness.withdrawPublicFromAztecToL1(withdrawAmount, authwitNonce); + await crossChainTestHarness.expectPublicBalanceOnL2( + ownerAddress, + initialPublicBalance + bridgeAmount - withdrawAmount, + ); + + // 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 waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { + timeout: 60, + }); + + // Check balance before and after exit. + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + await crossChainTestHarness.withdrawFundsFromBridgeOnL1( + withdrawAmount, + l2ToL1MessageResult.epochNumber, + l2ToL1MessageResult.numCheckpointsInEpoch, + l2ToL1MessageResult.leafIndex, + l2ToL1MessageResult.siblingPath, + ); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount + withdrawAmount, + ); + }, 900_000); + + // User2 tries to claim to their own address (fails), then correctly claims to ownerAddress. + // Asserts only ownerAddress receives the tokens, user2 gets nothing, and the message is consumed. + it('Someone else can mint funds to me on my behalf (publicly)', async () => { + const { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, user2Address } = t; + const l1TokenBalance = 1000000n; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + const initialOwnerPublicBalance = await crossChainTestHarness.getL2PublicBalanceOf(ownerAddress); + + await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); + const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); + const msgHash = Fr.fromHexString(claim.messageHash); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe( + initialL1Balance + l1TokenBalance - bridgeAmount, + ); + + await crossChainTestHarness.makeMessageConsumable(msgHash); + + // Check message leaf index matches + const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); + expect(maybeIndexAndPath).toBeDefined(); + const messageLeafIndex = maybeIndexAndPath![0]; + expect(messageLeafIndex).toEqual(claim.messageLeafIndex); + + // user2 tries to consume this message and minting to itself -> should fail since the message is intended to be consumed only by owner. + await expect( + l2Bridge.methods + .claim_public(user2Address, bridgeAmount, claim.claimSecret, messageLeafIndex) + .simulate({ from: user2Address }), + ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); + + // user2 consumes owner's L1-> L2 message on bridge contract and mints public tokens on L2 + logger.info("user2 consumes owner's message on L2 Publicly"); + await l2Bridge.methods + .claim_public(ownerAddress, bridgeAmount, claim.claimSecret, messageLeafIndex) + .send({ from: user2Address }); + + // ensure funds are gone to owner and not user2. + await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, initialOwnerPublicBalance + bridgeAmount); + await crossChainTestHarness.expectPublicBalanceOnL2(user2Address, 0n); + }, 90_000); + }); + + describe('failure cases', () => { + // Attempts to call exit_to_l1_public without granting an authwit to the bridge contract. + // Asserts the simulation reverts with "unauthorized". + it("Bridge can't withdraw my funds if I don't give approval", async () => { + const { crossChainTestHarness, ethAccount, l2Bridge, user1Address } = t; + const mintAmountToOwner = 100n; + await crossChainTestHarness.mintTokensPublicOnL2(mintAmountToOwner); + + const withdrawAmount = 9n; + const authwitNonce = Fr.random(); + // Should fail as owner has not given approval to bridge burn their funds. + await expect( + l2Bridge.methods + .exit_to_l1_public(ethAccount, withdrawAmount, EthAddress.ZERO, authwitNonce) + .simulate({ from: user1Address }), + ).rejects.toThrow(/unauthorized/); + }, 180_000); + + // Sends a public deposit to the portal, then tries to claim with a wrong bridge amount, producing + // a mismatched message hash. Asserts "No L1 to L2 message found" for the wrong hash. + it("Can't claim funds privately which were intended for public deposit from the token portal", async () => { + const { crossChainTestHarness, ethAccount, l2Bridge, ownerAddress, user2Address } = t; + const bridgeAmount = 100n; + + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + await crossChainTestHarness.mintTokensOnL1(bridgeAmount); + const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialL1Balance); + + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // Wrong message hash + const wrongBridgeAmount = bridgeAmount + 1n; + const wrongMessageContent = sha256ToField([ + Buffer.from(toFunctionSelector('mint_to_private(uint256)').substring(2), 'hex'), + new Fr(wrongBridgeAmount), + ]); + + const wrongMessage = new L1ToL2Message( + new L1Actor(crossChainTestHarness.tokenPortalAddress, crossChainTestHarness.l1Client.chain.id), + new L2Actor(l2Bridge.address, version), + wrongMessageContent, + claim.claimSecretHash, + new Fr(claim.messageLeafIndex), + ); + + // Sending wrong secret hashes should fail: + await expect( + l2Bridge.methods + .claim_private(ownerAddress, wrongBridgeAmount, claim.claimSecret, claim.messageLeafIndex) + .simulate({ from: user2Address }), + ).rejects.toThrow(`No L1 to L2 message found for message hash ${wrongMessage.hash().toString()}`); + }, 180_000); + + // Sends a private deposit to the portal, then tries to claim it publicly using claim_public. + // The message hash does not match the public-mint selector, so consumption fails with NO_L1_TO_L2_MSG_ERROR. + it("Can't claim funds publicly which were intended for private deposit from the token portal", async () => { + const { crossChainTestHarness, ethAccount, l2Bridge, ownerAddress } = t; + // 1. Mint tokens on L1 + const bridgeAmount = 100n; + const initialL1Balance = await crossChainTestHarness.getL1BalanceOf(ethAccount); + await crossChainTestHarness.mintTokensOnL1(bridgeAmount); + + // 2. Deposit tokens to the TokenPortal privately + const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); + expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialL1Balance); + + // Wait for the message to be available for consumption + await crossChainTestHarness.makeMessageConsumable(claim.messageHash); + + // 3. Consume L1 -> L2 message and try to mint publicly on L2 - should fail + await expect( + l2Bridge.methods + .claim_public(ownerAddress, bridgeAmount, Fr.random(), claim.messageLeafIndex) + .simulate({ from: ownerAddress }), + ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); + }); + }); +}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts deleted file mode 100644 index e26ff1757f91..000000000000 --- a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_failure_cases.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/aztec.js/messaging'; -import { sha256ToField } from '@aztec/foundation/crypto/sha256'; - -import { toFunctionSelector } from 'viem'; - -import { - L1_DIRECT_WRITE_ACCOUNT_INDEX, - NO_L1_TO_L2_MSG_ERROR, - PIPELINING_SETUP_OPTS, -} from '../../fixtures/fixtures.js'; -import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; - -// Token bridge failure scenarios: missing authwit, wrong secret hash, and wrong deposit direction. -// Uses CrossChainMessagingTest (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s, -// inboxLag=2, minTxsPerBlock=0), EpochTestSettler for auto-proving, and CrossChainTestHarness for -// L1↔L2 token portal bridging. -describe('single-node/cross-chain/token_bridge_failure_cases', () => { - const t = new CrossChainMessagingTest( - 'token_bridge_failure_cases', - {}, - {}, - {}, - { - l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX, - }, - ); - let version: number = 1; - - let { crossChainTestHarness, ethAccount, l2Bridge, ownerAddress, user1Address, user2Address, rollup } = t; - - beforeAll(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, user1Address, user2Address, ownerAddress, rollup } = t); - ethAccount = crossChainTestHarness.ethAccount; - l2Bridge = crossChainTestHarness.l2Bridge; - ownerAddress = crossChainTestHarness.ownerAddress; - version = Number(await rollup.getVersion()); - }, 300_000); - - afterAll(async () => { - await t.teardown(); - }); - - // Attempts to call exit_to_l1_public without granting an authwit to the bridge contract. - // Asserts the simulation reverts with "unauthorized". - it("Bridge can't withdraw my funds if I don't give approval", async () => { - const mintAmountToOwner = 100n; - await crossChainTestHarness.mintTokensPublicOnL2(mintAmountToOwner); - - const withdrawAmount = 9n; - const authwitNonce = Fr.random(); - // Should fail as owner has not given approval to bridge burn their funds. - await expect( - l2Bridge.methods - .exit_to_l1_public(ethAccount, withdrawAmount, EthAddress.ZERO, authwitNonce) - .simulate({ from: user1Address }), - ).rejects.toThrow(/unauthorized/); - }, 180_000); - - // Sends a public deposit to the portal, then tries to claim with a wrong bridge amount, producing - // a mismatched message hash. Asserts "No L1 to L2 message found" for the wrong hash. - it("Can't claim funds privately which were intended for public deposit from the token portal", async () => { - const bridgeAmount = 100n; - - await crossChainTestHarness.mintTokensOnL1(bridgeAmount); - const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(0n); - - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // Wrong message hash - const wrongBridgeAmount = bridgeAmount + 1n; - const wrongMessageContent = sha256ToField([ - Buffer.from(toFunctionSelector('mint_to_private(uint256)').substring(2), 'hex'), - new Fr(wrongBridgeAmount), - ]); - - const wrongMessage = new L1ToL2Message( - new L1Actor(crossChainTestHarness.tokenPortalAddress, crossChainTestHarness.l1Client.chain.id), - new L2Actor(l2Bridge.address, version), - wrongMessageContent, - claim.claimSecretHash, - new Fr(claim.messageLeafIndex), - ); - - // Sending wrong secret hashes should fail: - await expect( - l2Bridge.methods - .claim_private(ownerAddress, wrongBridgeAmount, claim.claimSecret, claim.messageLeafIndex) - .simulate({ from: user2Address }), - ).rejects.toThrow(`No L1 to L2 message found for message hash ${wrongMessage.hash().toString()}`); - }, 180_000); - - // Sends a private deposit to the portal, then tries to claim it publicly using claim_public. - // The message hash does not match the public-mint selector, so consumption fails with NO_L1_TO_L2_MSG_ERROR. - it("Can't claim funds publicly which were intended for private deposit from the token portal", async () => { - // 1. Mint tokens on L1 - const bridgeAmount = 100n; - await crossChainTestHarness.mintTokensOnL1(bridgeAmount); - - // 2. Deposit tokens to the TokenPortal privately - const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(0n); - - // Wait for the message to be available for consumption - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // 3. Consume L1 -> L2 message and try to mint publicly on L2 - should fail - await expect( - l2Bridge.methods - .claim_public(ownerAddress, bridgeAmount, Fr.random(), claim.messageLeafIndex) - .simulate({ from: ownerAddress }), - ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); - }); -}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts deleted file mode 100644 index c3f82763b21d..000000000000 --- a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_private.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { AztecAddress, 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 type { TokenContract } from '@aztec/noir-contracts.js/Token'; -import type { TokenBridgeContract } from '@aztec/noir-contracts.js/TokenBridge'; - -import { jest } from '@jest/globals'; - -import { L1_DIRECT_WRITE_ACCOUNT_INDEX, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; -import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; -import type { CrossChainTestHarness } from '../../shared/cross_chain_test_harness.js'; -import type { TestWallet } from '../../test-wallet/test_wallet.js'; -import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; - -// Private L1→L2 token deposit and L2→L1 withdrawal via the TokenBridge. Uses CrossChainMessagingTest -// with startProverNode=true (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s), fake -// in-proc prover node, and CrossChainTestHarness for full L1↔L2 portal/bridge lifecycle. -// Epoch proving via advanceToEpochProven is required before L1 Outbox consumption. -describe('single-node/cross-chain/token_bridge_private', () => { - // Pipelining slows wall-clock chain progress (12s slots); waitForProven via advanceToEpochProven - // needs more than the default 300s per-test budget. - jest.setTimeout(15 * 60 * 1000); - - const t = new CrossChainMessagingTest( - 'token_bridge_private', - { startProverNode: true }, - {}, - {}, - { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX }, - ); - - let crossChainTestHarness: CrossChainTestHarness; - let ethAccount: EthAddress; - let aztecNode: AztecNode; - let logger: Logger; - let ownerAddress: AztecAddress; - let l2Bridge: TokenBridgeContract; - let l2Token: TokenContract; - let wallet: TestWallet; - let user2Address: AztecAddress; - - beforeAll(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet, user2Address } = - t); - }, 300_000); - - afterAll(async () => { - await t.teardown(); - }); - - // Full round-trip: mint tokens on L1, deposit privately via TokenPortal, wait for the message to be - // consumable, claim on L2 (minting private tokens), withdraw back to L1 with an authwit, advance the - // epoch until proven, then consume the Outbox message on L1 and verify the L1 balance is restored. - it('Privately deposit funds from L1 -> L2 and withdraw back to L1', async () => { - // Generate a claim secret using pedersen - const l1TokenBalance = 1000000n; - const bridgeAmount = 100n; - - // 1. Mint tokens on L1 - await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); - - // 2. Deposit tokens to the TokenPortal - const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // 3. Consume L1 -> L2 message and mint private tokens on L2 - await crossChainTestHarness.consumeMessageOnAztecAndMintPrivately(claim); - await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, bridgeAmount); - - // time to withdraw the funds again! - logger.info('Withdrawing funds from L2'); - - // 4. Give approval to bridge to burn owner's funds: - const withdrawAmount = 9n; - const authwitNonce = Fr.random(); - const burnAuthwit = await wallet.createAuthWit(ownerAddress, { - caller: l2Bridge.address, - action: l2Token.methods.burn_private(ownerAddress, withdrawAmount, authwitNonce), - }); - - // 5. Withdraw owner's funds from L2 to L1 - const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); - const l2TxReceipt = await crossChainTestHarness.withdrawPrivateFromAztecToL1( - withdrawAmount, - authwitNonce, - burnAuthwit, - ); - await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, bridgeAmount - withdrawAmount); - - // 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 waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { - timeout: 60, - }); - - // 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, - ); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount + withdrawAmount); - }); - - // This test checks that it's enough to have the claim secret to claim the funds to whoever we want. - // User2 (not the original depositor) uses the claim secret to call claim_private on behalf of owner. - // Asserts the funds land at ownerAddress (not user2), proving the secret-based authorization works. - it('Claim secret is enough to consume the message', async () => { - const initialPublicBalance = await crossChainTestHarness.getL1BalanceOf(ethAccount); - const initialPrivateBalance = await crossChainTestHarness.getL2PrivateBalanceOf(ownerAddress); - - const bridgeAmount = 100n; - const claim = await crossChainTestHarness.sendTokensToPortalPrivate(bridgeAmount); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(initialPublicBalance - bridgeAmount); - - // Wait for the message to be available for consumption - await crossChainTestHarness.makeMessageConsumable(claim.messageHash); - - // send the right one - - await l2Bridge.methods - .claim_private(ownerAddress, bridgeAmount, claim.claimSecret, claim.messageLeafIndex) - .send({ from: user2Address }); - - await crossChainTestHarness.expectPrivateBalanceOnL2(ownerAddress, initialPrivateBalance + bridgeAmount); - }, 90_000); -}); diff --git a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts b/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts deleted file mode 100644 index 666fbf96a6d3..000000000000 --- a/yarn-project/end-to-end/src/single-node/cross-chain/token_bridge_public.parallel.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Fr } from '@aztec/aztec.js/fields'; - -import { jest } from '@jest/globals'; - -import { - L1_DIRECT_WRITE_ACCOUNT_INDEX, - NO_L1_TO_L2_MSG_ERROR, - PIPELINING_SETUP_OPTS, -} from '../../fixtures/fixtures.js'; -import { waitForL2ToL1Witness } from '../../fixtures/wait_helpers.js'; -import { CrossChainMessagingTest } from './cross_chain_messaging_test.js'; - -// Public L1→L2 token deposit and L2→L1 withdrawal via the TokenBridge. Uses CrossChainMessagingTest -// with startProverNode=true (prod sequencer, pipelining preset: ethSlot=4s, aztecSlot=12s), fake -// in-proc prover node, and CrossChainTestHarness for full L1↔L2 portal/bridge lifecycle. Setup and -// teardown happen per-test (beforeEach/afterEach) because the test creates fresh bridge state each run. -describe('single-node/cross-chain/token_bridge_public', () => { - // Pipelining slows wall-clock chain progress (12s slots); waitForProven via advanceToEpochProven - // needs more than the default 300s per-test budget. - jest.setTimeout(15 * 60 * 1000); - - const t = new CrossChainMessagingTest( - 'token_bridge_public', - { startProverNode: true }, - {}, - {}, - { l1HarnessAccountIndex: L1_DIRECT_WRITE_ACCOUNT_INDEX }, - ); - - let { crossChainTestHarness, ethAccount, aztecNode, logger, ownerAddress, l2Bridge, l2Token, wallet, user2Address } = - t; - - beforeEach(async () => { - await t.setup({ ...PIPELINING_SETUP_OPTS }); - // Have to destructure again to ensure we have latest refs. - ({ crossChainTestHarness, wallet, user2Address } = t); - - ethAccount = crossChainTestHarness.ethAccount; - aztecNode = crossChainTestHarness.aztecNode; - logger = crossChainTestHarness.logger; - ownerAddress = crossChainTestHarness.ownerAddress; - l2Bridge = crossChainTestHarness.l2Bridge; - l2Token = crossChainTestHarness.l2Token; - }, 300_000); - - afterEach(async () => { - await t.teardown(); - }); - - // Full round-trip: mint on L1, publicly deposit via TokenPortal, wait for message, claim_public on - // L2, authorize bridge to burn, withdraw to L1, advance to epoch proven, consume Outbox on L1. - // Asserts L1 balance is restored after the round-trip. - it('Publicly deposit funds from L1 -> L2 and withdraw back to L1', async () => { - const l1TokenBalance = 1000000n; - const bridgeAmount = 100n; - - // 1. Mint tokens on L1 - logger.verbose(`1. Mint tokens on L1`); - await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); - - // 2. Deposit tokens to the TokenPortal - logger.verbose(`2. Deposit tokens to the TokenPortal`); - const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); - const msgHash = Fr.fromHexString(claim.messageHash); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - - // Wait for the message to be available for consumption - logger.verbose(`Wait for the message to be available for consumption`); - await crossChainTestHarness.makeMessageConsumable(msgHash); - - // Check message leaf index matches - const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); - expect(maybeIndexAndPath).toBeDefined(); - const messageLeafIndex = maybeIndexAndPath![0]; - expect(messageLeafIndex).toEqual(claim.messageLeafIndex); - - // 3. Consume L1 -> L2 message and mint public tokens on L2 - logger.verbose('3. Consume L1 -> L2 message and mint public tokens on L2'); - await crossChainTestHarness.consumeMessageOnAztecAndMintPublicly(claim); - await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, bridgeAmount); - const afterBalance = bridgeAmount; - - // Time to withdraw the funds again! - logger.info('Withdrawing funds from L2'); - - // 4. Give approval to bridge to burn owner's funds: - const withdrawAmount = 9n; - const authwitNonce = Fr.random(); - const validateActionInteraction = await wallet.setPublicAuthWit( - ownerAddress, - { - caller: l2Bridge.address, - action: l2Token.methods.burn_public(ownerAddress, withdrawAmount, authwitNonce), - }, - true, - ); - await validateActionInteraction.send(); - - // 5. Withdraw owner's funds from L2 to L1 - logger.verbose('5. Withdraw owner funds from L2 to L1'); - const l2ToL1Message = await crossChainTestHarness.getL2ToL1MessageLeaf(withdrawAmount); - const l2TxReceipt = await crossChainTestHarness.withdrawPublicFromAztecToL1(withdrawAmount, authwitNonce); - await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, afterBalance - withdrawAmount); - - // 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 waitForL2ToL1Witness(aztecNode, l2TxReceipt.txHash, l2ToL1Message, { - timeout: 60, - }); - - // 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, - ); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount + withdrawAmount); - }, 900_000); - - // User2 tries to claim to their own address (fails), then correctly claims to ownerAddress. - // Asserts only ownerAddress receives the tokens, user2 gets nothing, and the message is consumed. - it('Someone else can mint funds to me on my behalf (publicly)', async () => { - const l1TokenBalance = 1000000n; - const bridgeAmount = 100n; - - await crossChainTestHarness.mintTokensOnL1(l1TokenBalance); - const claim = await crossChainTestHarness.sendTokensToPortalPublic(bridgeAmount); - const msgHash = Fr.fromHexString(claim.messageHash); - expect(await crossChainTestHarness.getL1BalanceOf(ethAccount)).toBe(l1TokenBalance - bridgeAmount); - - await crossChainTestHarness.makeMessageConsumable(msgHash); - - // Check message leaf index matches - const maybeIndexAndPath = await aztecNode.getL1ToL2MessageMembershipWitness('latest', msgHash); - expect(maybeIndexAndPath).toBeDefined(); - const messageLeafIndex = maybeIndexAndPath![0]; - expect(messageLeafIndex).toEqual(claim.messageLeafIndex); - - // user2 tries to consume this message and minting to itself -> should fail since the message is intended to be consumed only by owner. - await expect( - l2Bridge.methods - .claim_public(user2Address, bridgeAmount, claim.claimSecret, messageLeafIndex) - .simulate({ from: user2Address }), - ).rejects.toThrow(NO_L1_TO_L2_MSG_ERROR); - - // user2 consumes owner's L1-> L2 message on bridge contract and mints public tokens on L2 - logger.info("user2 consumes owner's message on L2 Publicly"); - await l2Bridge.methods - .claim_public(ownerAddress, bridgeAmount, claim.claimSecret, messageLeafIndex) - .send({ from: user2Address }); - - // ensure funds are gone to owner and not user2. - await crossChainTestHarness.expectPublicBalanceOnL2(ownerAddress, bridgeAmount); - await crossChainTestHarness.expectPublicBalanceOnL2(user2Address, 0n); - }, 90_000); -});