diff --git a/yarn-project/canary/package.json b/yarn-project/canary/package.json
index d621c6008e87..a4119bc5b1b5 100644
--- a/yarn-project/canary/package.json
+++ b/yarn-project/canary/package.json
@@ -25,6 +25,7 @@
     "@aztec/circuits.js": "workspace:^",
     "@aztec/cli": "workspace:^",
     "@aztec/end-to-end": "workspace:^",
+    "@aztec/foundation": "workspace:^",
     "@aztec/l1-artifacts": "workspace:^",
     "@aztec/noir-contracts": "workspace:^",
     "@jest/globals": "^29.5.0",
diff --git a/yarn-project/canary/src/uniswap_trade_on_l1_from_l2.test.ts b/yarn-project/canary/src/uniswap_trade_on_l1_from_l2.test.ts
index a478d20a908c..8adab2db4f70 100644
--- a/yarn-project/canary/src/uniswap_trade_on_l1_from_l2.test.ts
+++ b/yarn-project/canary/src/uniswap_trade_on_l1_from_l2.test.ts
@@ -1,365 +1,203 @@
 import {
   AccountWallet,
   AztecAddress,
+  DebugLogger,
   EthAddress,
   Fr,
-  NotePreimage,
-  TxHash,
+  PXE,
   TxStatus,
   computeAuthWitMessageHash,
-  computeMessageSecretHash,
   createDebugLogger,
   createPXEClient,
-  getL1ContractAddresses,
+  sleep as delay,
   getSandboxAccountsWallets,
-  sleep,
   waitForSandbox,
 } from '@aztec/aztec.js';
 import { UniswapPortalAbi, UniswapPortalBytecode } from '@aztec/l1-artifacts';
-import { TokenBridgeContract, TokenContract, UniswapContract } from '@aztec/noir-contracts/types';
+import { UniswapContract } from '@aztec/noir-contracts/types';
 
-import {
-  HDAccount,
-  HttpTransport,
-  PublicClient,
-  WalletClient,
-  createPublicClient,
-  createWalletClient,
-  getContract,
-  http,
-  parseEther,
-} from 'viem';
+import { jest } from '@jest/globals';
+import { createPublicClient, createWalletClient, getContract, http, parseEther } from 'viem';
 import { mnemonicToAccount } from 'viem/accounts';
-import { Chain, foundry } from 'viem/chains';
-
-import { deployAndInitializeTokenAndBridgeContracts, deployL1Contract } from './utils.js';
+import { foundry } from 'viem/chains';
 
-const logger = createDebugLogger('aztec:canary');
+import { CrossChainTestHarness, deployL1Contract } from './utils.js';
 
 const { PXE_URL = 'http://localhost:8080', ETHEREUM_HOST = 'http://localhost:8545' } = process.env;
 
 export const MNEMONIC = 'test test test test test test test test test test test junk';
-
-const WETH9_ADDRESS = EthAddress.fromString('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
-const DAI_ADDRESS = EthAddress.fromString('0x6B175474E89094C44Da98b954EedeAC495271d0F');
+const hdAccount = mnemonicToAccount(MNEMONIC);
 
 const EXPECTED_FORKED_BLOCK = 17514288;
 
-const pxeRpcUrl = PXE_URL;
-const ethRpcUrl = ETHEREUM_HOST;
-
-const hdAccount = mnemonicToAccount(MNEMONIC);
-
-const pxe = createPXEClient(pxeRpcUrl);
-
-const wethAmountToBridge: bigint = parseEther('1');
-const uniswapFeeTier = 3000;
-const minimumOutputAmount = 0n;
-const deadline = 2 ** 32 - 1; // max uint32 - 1
-
-/**
- * Deploys all l1 / l2 contracts
- * @param owner - Owner address.
- */
-async function deployAllContracts(
-  ownerWallet: AccountWallet,
-  ownerAddress: AztecAddress,
-  publicClient: PublicClient<HttpTransport, Chain>,
-  walletClient: WalletClient<HttpTransport, Chain, HDAccount>,
-) {
-  const l1ContractsAddresses = await getL1ContractAddresses(pxeRpcUrl);
-  logger('Deploying DAI Portal, initializing and deploying l2 contract...');
-  const daiContracts = await deployAndInitializeTokenAndBridgeContracts(
-    ownerWallet,
-    walletClient,
-    publicClient,
-    l1ContractsAddresses!.registryAddress,
-    ownerAddress,
-    DAI_ADDRESS,
-  );
-  const daiL2Contract = daiContracts.token;
-  const daiL2Bridge = daiContracts.bridge;
-  const daiContract = daiContracts.underlyingERC20;
-  const daiTokenPortalAddress = daiContracts.tokenPortalAddress;
-
-  logger('Deploying WETH Portal, initializing and deploying l2 contract...');
-  const wethContracts = await deployAndInitializeTokenAndBridgeContracts(
-    ownerWallet,
-    walletClient,
-    publicClient,
-    l1ContractsAddresses!.registryAddress,
-    ownerAddress,
-    WETH9_ADDRESS,
-  );
-  const wethL2Contract = wethContracts.token;
-  const wethL2Bridge = wethContracts.bridge;
-  const wethContract = wethContracts.underlyingERC20;
-  const wethTokenPortal = wethContracts.tokenPortal;
-  const wethTokenPortalAddress = wethContracts.tokenPortalAddress;
-
-  logger('Deploy Uniswap portal on L1 and L2...');
-  const uniswapPortalAddress = await deployL1Contract(
-    walletClient,
-    publicClient,
-    UniswapPortalAbi,
-    UniswapPortalBytecode,
-  );
-  const uniswapPortal = getContract({
-    address: uniswapPortalAddress.toString(),
-    abi: UniswapPortalAbi,
-    walletClient,
-    publicClient,
-  });
+const TIMEOUT = 90_000;
+describe('uniswap_trade_on_l1_from_l2', () => {
+  jest.setTimeout(TIMEOUT);
+  const WETH9_ADDRESS: EthAddress = EthAddress.fromString('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
+  const DAI_ADDRESS: EthAddress = EthAddress.fromString('0x6B175474E89094C44Da98b954EedeAC495271d0F');
 
-  // deploy l2 uniswap contract and attach to portal
-  const uniswapL2Contract = await UniswapContract.deploy(ownerWallet)
-    .send({ portalContract: uniswapPortalAddress })
-    .deployed();
-
-  await uniswapPortal.write.initialize(
-    [l1ContractsAddresses!.registryAddress.toString(), uniswapL2Contract.address.toString()],
-    {} as any,
-  );
-
-  return {
-    daiL2Contract,
-    daiL2Bridge,
-    daiContract,
-    daiTokenPortalAddress,
-    wethL2Contract,
-    wethL2Bridge,
-    wethContract,
-    wethTokenPortal,
-    wethTokenPortalAddress,
-    uniswapL2Contract,
-    uniswapPortal,
-    uniswapPortalAddress,
-  };
-}
-
-const getL2PrivateBalanceOf = async (owner: AztecAddress, l2Contract: TokenContract) => {
-  return await l2Contract.methods.balance_of_private(owner).view({ from: owner });
-};
-
-const getL2PublicBalanceOf = async (owner: AztecAddress, l2Contract: TokenContract) => {
-  return await l2Contract.methods.balance_of_public(owner).view();
-};
-
-const expectPrivateBalanceOnL2 = async (owner: AztecAddress, expectedBalance: bigint, l2Contract: TokenContract) => {
-  const balance = await getL2PrivateBalanceOf(owner, l2Contract);
-  logger(`Account ${owner} balance: ${balance}. Expected to be: ${expectedBalance}`);
-  expect(balance).toBe(expectedBalance);
-};
-
-const expectPublicBalanceOnL2 = async (owner: AztecAddress, expectedBalance: bigint, l2Contract: TokenContract) => {
-  const balance = await getL2PublicBalanceOf(owner, l2Contract);
-  logger(`Account ${owner} balance: ${balance}. Expected to be: ${expectedBalance}`);
-  expect(balance).toBe(expectedBalance);
-};
-
-const generateClaimSecret = async () => {
-  const secret = Fr.random();
-  const secretHash = await computeMessageSecretHash(secret);
-  return [secret, secretHash];
-};
-
-const transferWethOnL2 = async (
-  wethL2Contract: TokenContract,
-  ownerAddress: AztecAddress,
-  receiver: AztecAddress,
-  transferAmount: bigint,
-) => {
-  const transferTx = wethL2Contract.methods.transfer_public(ownerAddress, receiver, transferAmount, 0).send();
-  const receipt = await transferTx.wait();
-  expect(receipt.status).toBe(TxStatus.MINED);
-};
-
-const consumeMessageOnAztecAndMintSecretly = async (
-  l2Bridge: TokenBridgeContract,
-  bridgeAmount: bigint,
-  secretHashForRedeemingMintedNotes: Fr,
-  canceller: EthAddress,
-  messageKey: Fr,
-  secretForL2MessageConsumption: Fr,
-) => {
-  logger('Consuming messages on L2 secretively');
-  // Call the mint tokens function on the Aztec.nr contract
-  const consumptionTx = l2Bridge.methods
-    .claim_private(
-      bridgeAmount,
-      secretHashForRedeemingMintedNotes,
-      canceller,
-      messageKey,
-      secretForL2MessageConsumption,
-    )
-    .send();
-  const consumptionReceipt = await consumptionTx.wait();
-  expect(consumptionReceipt.status).toBe(TxStatus.MINED);
-  return consumptionReceipt.txHash;
-};
-
-const redeemShieldPrivatelyOnL2 = async (
-  l2Contract: TokenContract,
-  to: AztecAddress,
-  shieldAmount: bigint,
-  secret: Fr,
-  secretHash: Fr,
-  txHash: TxHash,
-) => {
-  // Add the note to the pxe.
-  const storageSlot = new Fr(5);
-  const preimage = new NotePreimage([new Fr(shieldAmount), secretHash]);
-  await pxe.addNote(to, l2Contract.address, storageSlot, preimage, txHash);
-
-  logger('Spending commitment in private call');
-  const privateTx = l2Contract.methods.redeem_shield(to, shieldAmount, secret).send();
-  const privateReceipt = await privateTx.wait();
-  expect(privateReceipt.status).toBe(TxStatus.MINED);
-};
+  let pxe: PXE;
+  let logger: DebugLogger;
+  let walletClient: any;
 
-describe('uniswap_trade_on_l1_from_l2', () => {
-  let publicClient: PublicClient<HttpTransport, Chain>;
-  let walletClient: WalletClient<HttpTransport, Chain, HDAccount>;
   let ownerWallet: AccountWallet;
   let ownerAddress: AztecAddress;
   let ownerEthAddress: EthAddress;
+  // does transactions on behalf of owner on Aztec:
+  let sponsorWallet: AccountWallet;
+  let sponsorAddress: AztecAddress;
+
+  let daiCrossChainHarness: CrossChainTestHarness;
+  let wethCrossChainHarness: CrossChainTestHarness;
+
+  let uniswapPortal: any;
+  let uniswapPortalAddress: EthAddress;
+  let uniswapL2Contract: UniswapContract;
+
+  const wethAmountToBridge = parseEther('1');
+  const uniswapFeeTier = 3000n;
+  const minimumOutputAmount = 0n;
+  const deadlineForDepositingSwappedDai = BigInt(2 ** 32 - 1); // max uint32 - 1
 
   beforeAll(async () => {
+    logger = createDebugLogger('aztec:canary');
+    pxe = createPXEClient(PXE_URL);
     await waitForSandbox(pxe);
 
     walletClient = createWalletClient({
       account: hdAccount,
       chain: foundry,
-      transport: http(ethRpcUrl),
+      transport: http(ETHEREUM_HOST),
     });
-    publicClient = createPublicClient({
+    const publicClient = createPublicClient({
       chain: foundry,
-      transport: http(ethRpcUrl),
+      transport: http(ETHEREUM_HOST),
     });
 
     if (Number(await publicClient.getBlockNumber()) < EXPECTED_FORKED_BLOCK) {
       throw new Error('This test must be run on a fork of mainnet with the expected fork block');
     }
 
+    [ownerWallet, sponsorWallet] = await getSandboxAccountsWallets(pxe);
+    ownerAddress = ownerWallet.getAddress();
+    sponsorAddress = sponsorWallet.getAddress();
     ownerEthAddress = EthAddress.fromString((await walletClient.getAddresses())[0]);
-  }, 60_000);
-  it('should uniswap trade on L1 from L2 funds privately (swaps WETH -> DAI)', async () => {
-    logger('Running L1/L2 messaging test on HTTP interface.');
-
-    [ownerWallet] = await getSandboxAccountsWallets(pxe);
-    const accounts = await ownerWallet.getRegisteredAccounts();
-    ownerAddress = accounts[0].address;
-    const receiver = accounts[1].address;
-
-    const result = await deployAllContracts(ownerWallet, ownerAddress, publicClient, walletClient);
-    const {
-      daiL2Contract,
-      daiL2Bridge,
-      daiContract,
-      daiTokenPortalAddress,
-      wethL2Contract,
-      wethL2Bridge,
-      wethContract,
-      wethTokenPortal,
-      wethTokenPortalAddress,
-      uniswapL2Contract,
-      uniswapPortal,
-    } = result;
-
-    const ownerInitialBalance = await getL2PrivateBalanceOf(ownerAddress, wethL2Contract);
-    logger(`Owner's initial L2 WETH balance: ${ownerInitialBalance}`);
 
+    logger('Deploying DAI Portal, initializing and deploying l2 contract...');
+    daiCrossChainHarness = await CrossChainTestHarness.new(
+      pxe,
+      publicClient,
+      walletClient,
+      ownerWallet,
+      logger,
+      DAI_ADDRESS,
+    );
+
+    logger('Deploying WETH Portal, initializing and deploying l2 contract...');
+    wethCrossChainHarness = await CrossChainTestHarness.new(
+      pxe,
+      publicClient,
+      walletClient,
+      ownerWallet,
+      logger,
+      WETH9_ADDRESS,
+    );
+
+    logger('Deploy Uniswap portal on L1 and L2...');
+    uniswapPortalAddress = await deployL1Contract(walletClient, publicClient, UniswapPortalAbi, UniswapPortalBytecode);
+    uniswapPortal = getContract({
+      address: uniswapPortalAddress.toString(),
+      abi: UniswapPortalAbi,
+      walletClient,
+      publicClient,
+    });
+    // deploy l2 uniswap contract and attach to portal
+    uniswapL2Contract = await UniswapContract.deploy(ownerWallet)
+      .send({ portalContract: uniswapPortalAddress })
+      .deployed();
+
+    const registryAddress = (await pxe.getNodeInfo()).l1ContractAddresses.registryAddress;
+    await uniswapPortal.write.initialize([registryAddress.toString(), uniswapL2Contract.address.toString()], {} as any);
+  });
+
+  beforeEach(async () => {
     // Give me some WETH so I can deposit to L2 and do the swap...
     logger('Getting some weth');
-    await walletClient.sendTransaction({
-      to: WETH9_ADDRESS.toString(),
-      value: parseEther('1'),
-    });
+    await walletClient.sendTransaction({ to: WETH9_ADDRESS.toString(), value: parseEther('1') });
+  });
 
-    const wethL1BeforeBalance = await wethContract.read.balanceOf([ownerEthAddress.toString()]);
-    // 1. Approve weth to be bridged
-    await wethContract.write.approve([wethTokenPortalAddress.toString(), wethAmountToBridge], {} as any);
+  it('should uniswap trade on L1 from L2 funds privately (swaps WETH -> DAI)', async () => {
+    const wethL1BeforeBalance = await wethCrossChainHarness.getL1BalanceOf(ownerEthAddress);
 
-    // 2. Deposit weth into the portal and move to L2
-    // generate secret
-    const [secretForMintingWeth, secretHashForMintingWeth] = await generateClaimSecret();
-    const [secretForRedeemingWeth, secretHashForRedeemingWeth] = await generateClaimSecret();
-    logger('Sending messages to L1 portal');
-    const args = [
-      wethAmountToBridge,
-      secretHashForRedeemingWeth.toString(true),
-      ownerEthAddress.toString(),
-      deadline,
-      secretHashForMintingWeth.toString(true),
-    ] as const;
-    const { result: messageKeyHex } = await wethTokenPortal.simulate.depositToAztecPrivate(args, {
-      account: ownerEthAddress.toString(),
-    } as any);
-    await wethTokenPortal.write.depositToAztecPrivate(args, {} as any);
+    // 1. Approve and deposit weth to the portal and move to L2
+    const [secretForMintingWeth, secretHashForMintingWeth] = await wethCrossChainHarness.generateClaimSecret();
+    const [secretForRedeemingWeth, secretHashForRedeemingWeth] = await wethCrossChainHarness.generateClaimSecret();
 
-    const currentL1Balance = await wethContract.read.balanceOf([ownerEthAddress.toString()]);
-    logger(`Initial Balance: ${currentL1Balance}. Should be: ${wethL1BeforeBalance - wethAmountToBridge}`);
-    expect(currentL1Balance).toBe(wethL1BeforeBalance - wethAmountToBridge);
-    const messageKey = Fr.fromString(messageKeyHex);
+    const messageKey = await wethCrossChainHarness.sendTokensToPortalPrivate(
+      wethAmountToBridge,
+      secretHashForMintingWeth,
+      secretHashForRedeemingWeth,
+    );
+    // funds transferred from owner to token portal
+    expect(await wethCrossChainHarness.getL1BalanceOf(ownerEthAddress)).toBe(wethL1BeforeBalance - wethAmountToBridge);
+    expect(await wethCrossChainHarness.getL1BalanceOf(wethCrossChainHarness.tokenPortalAddress)).toBe(
+      wethAmountToBridge,
+    );
 
     // Wait for the archiver to process the message
-    await sleep(5000);
-    // send a transfer tx to force through rollup with the message included
-    await transferWethOnL2(wethL2Contract, ownerAddress, receiver, 0n);
+    await delay(5000);
+
+    // Perform an unrelated transaction on L2 to progress the rollup. Here we mint public tokens.
+    await wethCrossChainHarness.mintTokensPublicOnL2(0n);
 
-    // 3. Claim WETH on L2
+    // 2. Claim WETH on L2
     logger('Minting weth on L2');
-    const redeemingWethTxHash = await consumeMessageOnAztecAndMintSecretly(
-      wethL2Bridge,
+    await wethCrossChainHarness.consumeMessageOnAztecAndMintSecretly(
       wethAmountToBridge,
       secretHashForRedeemingWeth,
-      ownerEthAddress,
       messageKey,
       secretForMintingWeth,
     );
-    await redeemShieldPrivatelyOnL2(
-      wethL2Contract,
-      ownerAddress,
-      wethAmountToBridge,
-      secretForRedeemingWeth,
-      secretHashForRedeemingWeth,
-      redeemingWethTxHash,
-    );
-    await expectPrivateBalanceOnL2(ownerAddress, wethAmountToBridge + BigInt(ownerInitialBalance), wethL2Contract);
+    await wethCrossChainHarness.redeemShieldPrivatelyOnL2(wethAmountToBridge, secretForRedeemingWeth);
+    await wethCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, wethAmountToBridge);
 
     // Store balances
-    const wethL2BalanceBeforeSwap = await getL2PrivateBalanceOf(ownerAddress, wethL2Contract);
-    const daiL2BalanceBeforeSwap = await getL2PrivateBalanceOf(ownerAddress, daiL2Contract);
+    const wethL2BalanceBeforeSwap = await wethCrossChainHarness.getL2PrivateBalanceOf(ownerAddress);
+    const daiL2BalanceBeforeSwap = await daiCrossChainHarness.getL2PrivateBalanceOf(ownerAddress);
+
+    // before swap - check nonce_for_burn_approval stored on uniswap
+    // (which is used by uniswap to approve the bridge to burn funds on its behalf to exit to L1)
+    const nonceForBurnApprovalBeforeSwap = await uniswapL2Contract.methods.nonce_for_burn_approval().view();
 
-    // 4. Owner gives uniswap approval to unshield funds to self on its behalf
+    // 3. Owner gives uniswap approval to unshield funds to self on its behalf
     logger('Approving uniswap to unshield funds to self on my behalf');
-    const nonceForWETHUnshieldApproval = new Fr(2n);
+    const nonceForWETHUnshieldApproval = new Fr(1n);
     const unshieldToUniswapMessageHash = await computeAuthWitMessageHash(
       uniswapL2Contract.address,
-      wethL2Contract.methods
+      wethCrossChainHarness.l2Token.methods
         .unshield(ownerAddress, uniswapL2Contract.address, wethAmountToBridge, nonceForWETHUnshieldApproval)
         .request(),
     );
     await ownerWallet.createAuthWitness(Fr.fromBuffer(unshieldToUniswapMessageHash));
 
-    // 5. Swap on L1 - sends L2 to L1 message to withdraw WETH to L1 and another message to swap assets.
+    // 4. Swap on L1 - sends L2 to L1 message to withdraw WETH to L1 and another message to swap assets.
     logger('Withdrawing weth to L1 and sending message to swap to dai');
-
-    const [secretForDepositingSwappedDai, secretHashForDepositingSwappedDai] = await generateClaimSecret();
-    const [secretForRedeemingDai, secretHashForRedeemingDai] = await generateClaimSecret();
+    const [secretForDepositingSwappedDai, secretHashForDepositingSwappedDai] =
+      await daiCrossChainHarness.generateClaimSecret();
+    const [secretForRedeemingDai, secretHashForRedeemingDai] = await daiCrossChainHarness.generateClaimSecret();
 
     const withdrawReceipt = await uniswapL2Contract.methods
       .swap_private(
-        wethL2Contract.address,
-        wethL2Bridge.address,
+        wethCrossChainHarness.l2Token.address,
+        wethCrossChainHarness.l2Bridge.address,
         wethAmountToBridge,
-        daiL2Bridge.address,
+        daiCrossChainHarness.l2Bridge.address,
         nonceForWETHUnshieldApproval,
         uniswapFeeTier,
         minimumOutputAmount,
         secretHashForRedeemingDai,
         secretHashForDepositingSwappedDai,
-        deadline,
+        deadlineForDepositingSwappedDai,
         ownerEthAddress,
         ownerEthAddress,
       )
@@ -367,22 +205,27 @@ describe('uniswap_trade_on_l1_from_l2', () => {
       .wait();
     expect(withdrawReceipt.status).toBe(TxStatus.MINED);
     // ensure that user's funds were burnt
-    await expectPrivateBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge, wethL2Contract);
+    await wethCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge);
     // ensure that uniswap contract didn't eat the funds.
-    await expectPublicBalanceOnL2(uniswapL2Contract.address, 0n, wethL2Contract);
+    await wethCrossChainHarness.expectPublicBalanceOnL2(uniswapL2Contract.address, 0n);
+    // check burn approval nonce incremented:
+    const nonceForBurnApprovalAfterSwap = await uniswapL2Contract.methods.nonce_for_burn_approval().view();
+    expect(nonceForBurnApprovalAfterSwap).toBe(nonceForBurnApprovalBeforeSwap + 1n);
 
-    // 6. Consume L2 to L1 message by calling uniswapPortal.swap_private()
+    // 5. Consume L2 to L1 message by calling uniswapPortal.swap_private()
     logger('Execute withdraw and swap on the uniswapPortal!');
-    const daiL1BalanceOfPortalBeforeSwap = await daiContract.read.balanceOf([daiTokenPortalAddress.toString()]);
+    const daiL1BalanceOfPortalBeforeSwap = await daiCrossChainHarness.getL1BalanceOf(
+      daiCrossChainHarness.tokenPortalAddress,
+    );
     const swapArgs = [
-      wethTokenPortalAddress.toString(),
+      wethCrossChainHarness.tokenPortalAddress.toString(),
       wethAmountToBridge,
       uniswapFeeTier,
-      daiTokenPortalAddress.toString(),
+      daiCrossChainHarness.tokenPortalAddress.toString(),
       minimumOutputAmount,
       secretHashForRedeemingDai.toString(true),
       secretHashForDepositingSwappedDai.toString(true),
-      deadline,
+      deadlineForDepositingSwappedDai,
       ownerEthAddress.toString(),
       true,
     ] as const;
@@ -395,45 +238,176 @@ describe('uniswap_trade_on_l1_from_l2', () => {
     const depositDaiMessageKey = Fr.fromString(depositDaiMessageKeyHex);
 
     // weth was swapped to dai and send to portal
-    const daiL1BalanceOfPortalAfter = await daiContract.read.balanceOf([daiTokenPortalAddress.toString()]);
-    logger(
-      `DAI balance in Portal: balance after (${daiL1BalanceOfPortalAfter}) should be bigger than balance before (${daiL1BalanceOfPortalBeforeSwap})`,
+    const daiL1BalanceOfPortalAfter = await daiCrossChainHarness.getL1BalanceOf(
+      daiCrossChainHarness.tokenPortalAddress,
     );
     expect(daiL1BalanceOfPortalAfter).toBeGreaterThan(daiL1BalanceOfPortalBeforeSwap);
     const daiAmountToBridge = BigInt(daiL1BalanceOfPortalAfter - daiL1BalanceOfPortalBeforeSwap);
 
     // Wait for the archiver to process the message
-    await sleep(5000);
+    await delay(5000);
     // send a transfer tx to force through rollup with the message included
-    await transferWethOnL2(wethL2Contract, ownerAddress, receiver, 0n);
+    await wethCrossChainHarness.mintTokensPublicOnL2(0n);
 
-    // 7. claim dai on L2
+    // 6. claim dai on L2
     logger('Consuming messages to mint dai on L2');
-    const redeemingDaiTxHash = await consumeMessageOnAztecAndMintSecretly(
-      daiL2Bridge,
+    await daiCrossChainHarness.consumeMessageOnAztecAndMintSecretly(
       daiAmountToBridge,
       secretHashForRedeemingDai,
-      ownerEthAddress,
       depositDaiMessageKey,
       secretForDepositingSwappedDai,
     );
-    await redeemShieldPrivatelyOnL2(
-      daiL2Contract,
-      ownerAddress,
-      daiAmountToBridge,
-      secretForRedeemingDai,
-      secretHashForRedeemingDai,
-      redeemingDaiTxHash,
-    );
-    await expectPrivateBalanceOnL2(ownerAddress, daiL2BalanceBeforeSwap + daiAmountToBridge, daiL2Contract);
+    await daiCrossChainHarness.redeemShieldPrivatelyOnL2(daiAmountToBridge, secretForRedeemingDai);
+    await daiCrossChainHarness.expectPrivateBalanceOnL2(ownerAddress, daiL2BalanceBeforeSwap + daiAmountToBridge);
 
-    const wethL2BalanceAfterSwap = await getL2PrivateBalanceOf(ownerAddress, wethL2Contract);
-    const daiL2BalanceAfterSwap = await getL2PrivateBalanceOf(ownerAddress, daiL2Contract);
+    const wethL2BalanceAfterSwap = await wethCrossChainHarness.getL2PrivateBalanceOf(ownerAddress);
+    const daiL2BalanceAfterSwap = await daiCrossChainHarness.getL2PrivateBalanceOf(ownerAddress);
 
     logger('WETH balance before swap: ' + wethL2BalanceBeforeSwap.toString());
     logger('DAI balance before swap  : ' + daiL2BalanceBeforeSwap.toString());
     logger('***** 🧚‍♀️ SWAP L2 assets on L1 Uniswap 🧚‍♀️ *****');
-    logger('WETH balance after swap : ' + wethL2BalanceAfterSwap.toString());
-    logger('DAI balance after swap  : ' + daiL2BalanceAfterSwap.toString());
-  }, 140_000);
+    logger('WETH balance after swap : ', wethL2BalanceAfterSwap.toString());
+    logger('DAI balance after swap  : ', daiL2BalanceAfterSwap.toString());
+  });
+
+  it('should uniswap trade on L1 from L2 funds publicly (swaps WETH -> DAI)', async () => {
+    const wethL1BeforeBalance = await wethCrossChainHarness.getL1BalanceOf(ownerEthAddress);
+
+    // 1. Approve and deposit weth to the portal and move to L2
+    const [secretForMintingWeth, secretHashForMintingWeth] = await wethCrossChainHarness.generateClaimSecret();
+
+    const messageKey = await wethCrossChainHarness.sendTokensToPortalPublic(
+      wethAmountToBridge,
+      secretHashForMintingWeth,
+    );
+    // funds transferred from owner to token portal
+    expect(await wethCrossChainHarness.getL1BalanceOf(ownerEthAddress)).toBe(wethL1BeforeBalance - wethAmountToBridge);
+    expect(await wethCrossChainHarness.getL1BalanceOf(wethCrossChainHarness.tokenPortalAddress)).toBe(
+      wethAmountToBridge,
+    );
+
+    // Wait for the archiver to process the message
+    await delay(5000);
+
+    // Perform an unrelated transaction on L2 to progress the rollup. Here we transfer 0 tokens
+    await wethCrossChainHarness.mintTokensPublicOnL2(0n);
+
+    // 2. Claim WETH on L2
+    logger('Minting weth on L2');
+    await wethCrossChainHarness.consumeMessageOnAztecAndMintPublicly(
+      wethAmountToBridge,
+      messageKey,
+      secretForMintingWeth,
+    );
+    await wethCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, wethAmountToBridge);
+
+    // Store balances
+    const wethL2BalanceBeforeSwap = await wethCrossChainHarness.getL2PublicBalanceOf(ownerAddress);
+    const daiL2BalanceBeforeSwap = await daiCrossChainHarness.getL2PublicBalanceOf(ownerAddress);
+
+    // 3. Owner gives uniswap approval to transfer funds on its behalf
+    const nonceForWETHTransferApproval = new Fr(1n);
+    const transferMessageHash = await computeAuthWitMessageHash(
+      uniswapL2Contract.address,
+      wethCrossChainHarness.l2Token.methods
+        .transfer_public(ownerAddress, uniswapL2Contract.address, wethAmountToBridge, nonceForWETHTransferApproval)
+        .request(),
+    );
+    await ownerWallet.setPublicAuth(transferMessageHash, true).send().wait();
+
+    // before swap - check nonce_for_burn_approval stored on uniswap
+    // (which is used by uniswap to approve the bridge to burn funds on its behalf to exit to L1)
+    const nonceForBurnApprovalBeforeSwap = await uniswapL2Contract.methods.nonce_for_burn_approval().view();
+
+    // 4. Swap on L1 - sends L2 to L1 message to withdraw WETH to L1 and another message to swap assets.
+    const [secretForDepositingSwappedDai, secretHashForDepositingSwappedDai] =
+      await daiCrossChainHarness.generateClaimSecret();
+
+    // 4.1 Owner approves user to swap on their behalf:
+    const nonceForSwap = new Fr(3n);
+    const action = uniswapL2Contract
+      .withWallet(sponsorWallet)
+      .methods.swap_public(
+        ownerAddress,
+        wethCrossChainHarness.l2Bridge.address,
+        wethAmountToBridge,
+        daiCrossChainHarness.l2Bridge.address,
+        nonceForWETHTransferApproval,
+        uniswapFeeTier,
+        minimumOutputAmount,
+        ownerAddress,
+        secretHashForDepositingSwappedDai,
+        deadlineForDepositingSwappedDai,
+        ownerEthAddress,
+        ownerEthAddress,
+        nonceForSwap,
+      );
+    const swapMessageHash = await computeAuthWitMessageHash(sponsorAddress, action.request());
+    await ownerWallet.setPublicAuth(swapMessageHash, true).send().wait();
+
+    // 4.2 Call swap_public from user2 on behalf of owner
+    const withdrawReceipt = await action.send().wait();
+    expect(withdrawReceipt.status).toBe(TxStatus.MINED);
+
+    // check weth balance of owner on L2 (we first bridged `wethAmountToBridge` into L2 and now withdrew it!)
+    await wethCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, wethL2BalanceBeforeSwap - wethAmountToBridge);
+
+    // check burn approval nonce incremented:
+    const nonceForBurnApprovalAfterSwap = await uniswapL2Contract.methods.nonce_for_burn_approval().view();
+    expect(nonceForBurnApprovalAfterSwap).toBe(nonceForBurnApprovalBeforeSwap + 1n);
+
+    // 5. Perform the swap on L1 with the `uniswapPortal.swap_private()` (consuming L2 to L1 messages)
+    logger('Execute withdraw and swap on the uniswapPortal!');
+    const daiL1BalanceOfPortalBeforeSwap = await daiCrossChainHarness.getL1BalanceOf(
+      daiCrossChainHarness.tokenPortalAddress,
+    );
+    const swapArgs = [
+      wethCrossChainHarness.tokenPortalAddress.toString(),
+      wethAmountToBridge,
+      uniswapFeeTier,
+      daiCrossChainHarness.tokenPortalAddress.toString(),
+      minimumOutputAmount,
+      ownerAddress.toString(),
+      secretHashForDepositingSwappedDai.toString(true),
+      deadlineForDepositingSwappedDai,
+      ownerEthAddress.toString(),
+      true,
+    ] as const;
+    const { result: depositDaiMessageKeyHex } = await uniswapPortal.simulate.swapPublic(swapArgs, {
+      account: ownerEthAddress.toString(),
+    } as any);
+
+    // this should also insert a message into the inbox.
+    await uniswapPortal.write.swapPublic(swapArgs, {} as any);
+    const depositDaiMessageKey = Fr.fromString(depositDaiMessageKeyHex);
+    // weth was swapped to dai and send to portal
+    const daiL1BalanceOfPortalAfter = await daiCrossChainHarness.getL1BalanceOf(
+      daiCrossChainHarness.tokenPortalAddress,
+    );
+    expect(daiL1BalanceOfPortalAfter).toBeGreaterThan(daiL1BalanceOfPortalBeforeSwap);
+    const daiAmountToBridge = BigInt(daiL1BalanceOfPortalAfter - daiL1BalanceOfPortalBeforeSwap);
+
+    // Wait for the archiver to process the message
+    await delay(5000);
+    // send a transfer tx to force through rollup with the message included
+    await wethCrossChainHarness.mintTokensPublicOnL2(0n);
+
+    // 6. claim dai on L2
+    logger('Consuming messages to mint dai on L2');
+    await daiCrossChainHarness.consumeMessageOnAztecAndMintPublicly(
+      daiAmountToBridge,
+      depositDaiMessageKey,
+      secretForDepositingSwappedDai,
+    );
+    await daiCrossChainHarness.expectPublicBalanceOnL2(ownerAddress, daiL2BalanceBeforeSwap + daiAmountToBridge);
+
+    const wethL2BalanceAfterSwap = await wethCrossChainHarness.getL2PublicBalanceOf(ownerAddress);
+    const daiL2BalanceAfterSwap = await daiCrossChainHarness.getL2PublicBalanceOf(ownerAddress);
+
+    logger('WETH balance before swap: ', wethL2BalanceBeforeSwap.toString());
+    logger('DAI balance before swap  : ', daiL2BalanceBeforeSwap.toString());
+    logger('***** 🧚‍♀️ SWAP L2 assets on L1 Uniswap 🧚‍♀️ *****');
+    logger('WETH balance after swap : ', wethL2BalanceAfterSwap.toString());
+    logger('DAI balance after swap  : ', daiL2BalanceAfterSwap.toString());
+  });
 });
diff --git a/yarn-project/canary/src/utils.ts b/yarn-project/canary/src/utils.ts
index 4a05c6713f5f..ce24a41f5174 100644
--- a/yarn-project/canary/src/utils.ts
+++ b/yarn-project/canary/src/utils.ts
@@ -1,5 +1,23 @@
-import { AztecAddress, EthAddress, TxStatus, Wallet } from '@aztec/aztec.js';
-import { PortalERC20Abi, PortalERC20Bytecode, TokenPortalAbi, TokenPortalBytecode } from '@aztec/l1-artifacts';
+import {
+  AztecAddress,
+  DebugLogger,
+  EthAddress,
+  Fr,
+  NotePreimage,
+  PXE,
+  TxHash,
+  TxStatus,
+  Wallet,
+  computeMessageSecretHash,
+} from '@aztec/aztec.js';
+import { sha256ToField } from '@aztec/foundation/crypto';
+import {
+  OutboxAbi,
+  PortalERC20Abi,
+  PortalERC20Bytecode,
+  TokenPortalAbi,
+  TokenPortalBytecode,
+} from '@aztec/l1-artifacts';
 import { TokenBridgeContract, TokenContract } from '@aztec/noir-contracts/types';
 
 import type { Abi, Narrow } from 'abitype';
@@ -129,3 +147,317 @@ export async function deployL1Contract(
 
   return EthAddress.fromString(receipt.contractAddress!);
 }
+
+/**
+ * A Class for testing cross chain interactions, contains common interactions
+ * shared between cross chain tests.
+ */
+export class CrossChainTestHarness {
+  static async new(
+    pxeService: PXE,
+    publicClient: PublicClient<HttpTransport, Chain>,
+    walletClient: any,
+    wallet: Wallet,
+    logger: DebugLogger,
+    underlyingERC20Address?: EthAddress,
+  ): Promise<CrossChainTestHarness> {
+    const ethAccount = EthAddress.fromString((await walletClient.getAddresses())[0]);
+    const owner = wallet.getCompleteAddress();
+    const l1ContractAddresses = (await pxeService.getNodeInfo()).l1ContractAddresses;
+
+    const outbox = getContract({
+      address: l1ContractAddresses.outboxAddress!.toString(),
+      abi: OutboxAbi,
+      publicClient,
+    });
+
+    // Deploy and initialize all required contracts
+    logger('Deploying and initializing token, portal and its bridge...');
+    const { token, bridge, tokenPortalAddress, tokenPortal, underlyingERC20 } =
+      await deployAndInitializeTokenAndBridgeContracts(
+        wallet,
+        walletClient,
+        publicClient,
+        l1ContractAddresses.registryAddress,
+        owner.address,
+        underlyingERC20Address,
+      );
+    logger('Deployed and initialized token, portal and its bridge.');
+
+    return new CrossChainTestHarness(
+      pxeService,
+      logger,
+      token,
+      bridge,
+      ethAccount,
+      tokenPortalAddress,
+      tokenPortal,
+      underlyingERC20,
+      outbox,
+      publicClient,
+      walletClient,
+      owner.address,
+    );
+  }
+
+  constructor(
+    /** Private eXecution Environment (PXE). */
+    public pxeService: PXE,
+    /** Logger. */
+    public logger: DebugLogger,
+
+    /** L2 Token contract. */
+    public l2Token: TokenContract,
+    /** L2 Token bridge contract. */
+    public l2Bridge: TokenBridgeContract,
+
+    /** Eth account to interact with. */
+    public ethAccount: EthAddress,
+
+    /** Portal address. */
+    public tokenPortalAddress: EthAddress,
+    /** Token portal instance. */
+    public tokenPortal: any,
+    /** Underlying token for portal tests. */
+    public underlyingERC20: any,
+    /** Message Bridge Outbox. */
+    public outbox: any,
+    /** Viem Public client instance. */
+    public publicClient: PublicClient<HttpTransport, Chain>,
+    /** Viem Wallet Client instance. */
+    public walletClient: any,
+
+    /** Aztec address to use in tests. */
+    public ownerAddress: AztecAddress,
+  ) {}
+
+  async generateClaimSecret(): Promise<[Fr, Fr]> {
+    this.logger("Generating a claim secret using pedersen's hash function");
+    const secret = Fr.random();
+    const secretHash = await computeMessageSecretHash(secret);
+    this.logger('Generated claim secret: ' + secretHash.toString(true));
+    return [secret, secretHash];
+  }
+
+  async mintTokensOnL1(amount: bigint) {
+    this.logger('Minting tokens on L1');
+    await this.underlyingERC20.write.mint([this.ethAccount.toString(), amount], {} as any);
+    expect(await this.underlyingERC20.read.balanceOf([this.ethAccount.toString()])).toBe(amount);
+  }
+
+  async getL1BalanceOf(address: EthAddress) {
+    return await this.underlyingERC20.read.balanceOf([address.toString()]);
+  }
+
+  async sendTokensToPortalPublic(bridgeAmount: bigint, secretHash: Fr) {
+    await this.underlyingERC20.write.approve([this.tokenPortalAddress.toString(), bridgeAmount], {} as any);
+
+    // Deposit tokens to the TokenPortal
+    const deadline = 2 ** 32 - 1; // max uint32 - 1
+
+    this.logger('Sending messages to L1 portal to be consumed publicly');
+    const args = [
+      bridgeAmount,
+      this.ownerAddress.toString(),
+      this.ethAccount.toString(),
+      deadline,
+      secretHash.toString(true),
+    ] as const;
+    const { result: messageKeyHex } = await this.tokenPortal.simulate.depositToAztecPublic(args, {
+      account: this.ethAccount.toString(),
+    } as any);
+    await this.tokenPortal.write.depositToAztecPublic(args, {} as any);
+
+    return Fr.fromString(messageKeyHex);
+  }
+
+  async sendTokensToPortalPrivate(
+    bridgeAmount: bigint,
+    secretHashForL2MessageConsumption: Fr,
+    secretHashForRedeemingMintedNotes: Fr,
+  ) {
+    await this.underlyingERC20.write.approve([this.tokenPortalAddress.toString(), bridgeAmount], {} as any);
+
+    // Deposit tokens to the TokenPortal
+    const deadline = 2 ** 32 - 1; // max uint32 - 1
+
+    this.logger('Sending messages to L1 portal to be consumed privately');
+    const args = [
+      bridgeAmount,
+      secretHashForRedeemingMintedNotes.toString(true),
+      this.ethAccount.toString(),
+      deadline,
+      secretHashForL2MessageConsumption.toString(true),
+    ] as const;
+    const { result: messageKeyHex } = await this.tokenPortal.simulate.depositToAztecPrivate(args, {
+      account: this.ethAccount.toString(),
+    } as any);
+    await this.tokenPortal.write.depositToAztecPrivate(args, {} as any);
+
+    return Fr.fromString(messageKeyHex);
+  }
+
+  async mintTokensPublicOnL2(amount: bigint) {
+    this.logger('Minting tokens on L2 publicly');
+    const tx = this.l2Token.methods.mint_public(this.ownerAddress, amount).send();
+    const receipt = await tx.wait();
+    expect(receipt.status).toBe(TxStatus.MINED);
+  }
+
+  async mintTokensPrivateOnL2(amount: bigint, secretHash: Fr) {
+    const tx = this.l2Token.methods.mint_private(amount, secretHash).send();
+    const receipt = await tx.wait();
+    expect(receipt.status).toBe(TxStatus.MINED);
+    await this.addPendingShieldNoteToPXE(amount, secretHash, receipt.txHash);
+  }
+
+  async performL2Transfer(transferAmount: bigint, receiverAddress: AztecAddress) {
+    // send a transfer tx to force through rollup with the message included
+    const transferTx = this.l2Token.methods
+      .transfer_public(this.ownerAddress, receiverAddress, transferAmount, 0)
+      .send();
+    const receipt = await transferTx.wait();
+    expect(receipt.status).toBe(TxStatus.MINED);
+  }
+
+  async consumeMessageOnAztecAndMintSecretly(
+    bridgeAmount: bigint,
+    secretHashForRedeemingMintedNotes: Fr,
+    messageKey: Fr,
+    secretForL2MessageConsumption: Fr,
+  ) {
+    this.logger('Consuming messages on L2 secretively');
+    // Call the mint tokens function on the Aztec.nr contract
+    const consumptionTx = this.l2Bridge.methods
+      .claim_private(
+        bridgeAmount,
+        secretHashForRedeemingMintedNotes,
+        this.ethAccount,
+        messageKey,
+        secretForL2MessageConsumption,
+      )
+      .send();
+    const consumptionReceipt = await consumptionTx.wait();
+    expect(consumptionReceipt.status).toBe(TxStatus.MINED);
+
+    await this.addPendingShieldNoteToPXE(bridgeAmount, secretHashForRedeemingMintedNotes, consumptionReceipt.txHash);
+  }
+
+  async consumeMessageOnAztecAndMintPublicly(bridgeAmount: bigint, messageKey: Fr, secret: Fr) {
+    this.logger('Consuming messages on L2 Publicly');
+    // Call the mint tokens function on the Aztec.nr contract
+    const tx = this.l2Bridge.methods
+      .claim_public(this.ownerAddress, bridgeAmount, this.ethAccount, messageKey, secret)
+      .send();
+    const receipt = await tx.wait();
+    expect(receipt.status).toBe(TxStatus.MINED);
+  }
+
+  async withdrawPrivateFromAztecToL1(withdrawAmount: bigint, nonce: Fr = Fr.ZERO) {
+    const withdrawTx = this.l2Bridge.methods
+      .exit_to_l1_private(this.ethAccount, this.l2Token.address, withdrawAmount, EthAddress.ZERO, nonce)
+      .send();
+    const withdrawReceipt = await withdrawTx.wait();
+    expect(withdrawReceipt.status).toBe(TxStatus.MINED);
+  }
+
+  async withdrawPublicFromAztecToL1(withdrawAmount: bigint, nonce: Fr = Fr.ZERO) {
+    const withdrawTx = this.l2Bridge.methods
+      .exit_to_l1_public(this.ethAccount, withdrawAmount, EthAddress.ZERO, nonce)
+      .send();
+    const withdrawReceipt = await withdrawTx.wait();
+    expect(withdrawReceipt.status).toBe(TxStatus.MINED);
+  }
+
+  async getL2PrivateBalanceOf(owner: AztecAddress) {
+    return await this.l2Token.methods.balance_of_private(owner).view({ from: owner });
+  }
+
+  async expectPrivateBalanceOnL2(owner: AztecAddress, expectedBalance: bigint) {
+    const balance = await this.getL2PrivateBalanceOf(owner);
+    this.logger(`Account ${owner} balance: ${balance}`);
+    expect(balance).toBe(expectedBalance);
+  }
+
+  async getL2PublicBalanceOf(owner: AztecAddress) {
+    return await this.l2Token.methods.balance_of_public(owner).view();
+  }
+
+  async expectPublicBalanceOnL2(owner: AztecAddress, expectedBalance: bigint) {
+    const balance = await this.getL2PublicBalanceOf(owner);
+    expect(balance).toBe(expectedBalance);
+  }
+
+  async checkEntryIsNotInOutbox(withdrawAmount: bigint, callerOnL1: EthAddress = EthAddress.ZERO): Promise<Fr> {
+    this.logger('Ensure that the entry is not in outbox yet');
+    // 0xb460af94, selector for "withdraw(uint256,address,address)"
+    const content = sha256ToField(
+      Buffer.concat([
+        Buffer.from([0xb4, 0x60, 0xaf, 0x94]),
+        new Fr(withdrawAmount).toBuffer(),
+        this.ethAccount.toBuffer32(),
+        callerOnL1.toBuffer32(),
+      ]),
+    );
+    const entryKey = sha256ToField(
+      Buffer.concat([
+        this.l2Bridge.address.toBuffer(),
+        new Fr(1).toBuffer(), // aztec version
+        this.tokenPortalAddress.toBuffer32() ?? Buffer.alloc(32, 0),
+        new Fr(this.publicClient.chain.id).toBuffer(), // chain id
+        content.toBuffer(),
+      ]),
+    );
+    expect(await this.outbox.read.contains([entryKey.toString(true)])).toBeFalsy();
+
+    return entryKey;
+  }
+
+  async withdrawFundsFromBridgeOnL1(withdrawAmount: bigint, entryKey: Fr) {
+    this.logger('Send L1 tx to consume entry and withdraw funds');
+    // Call function on L1 contract to consume the message
+    const { request: withdrawRequest, result: withdrawEntryKey } = await this.tokenPortal.simulate.withdraw([
+      withdrawAmount,
+      this.ethAccount.toString(),
+      false,
+    ]);
+
+    expect(withdrawEntryKey).toBe(entryKey.toString(true));
+    expect(await this.outbox.read.contains([withdrawEntryKey])).toBeTruthy();
+
+    await this.walletClient.writeContract(withdrawRequest);
+    return withdrawEntryKey;
+  }
+
+  async shieldFundsOnL2(shieldAmount: bigint, secretHash: Fr) {
+    this.logger('Shielding funds on L2');
+    const shieldTx = this.l2Token.methods.shield(this.ownerAddress, shieldAmount, secretHash, 0).send();
+    const shieldReceipt = await shieldTx.wait();
+    expect(shieldReceipt.status).toBe(TxStatus.MINED);
+
+    await this.addPendingShieldNoteToPXE(shieldAmount, secretHash, shieldReceipt.txHash);
+  }
+
+  async addPendingShieldNoteToPXE(shieldAmount: bigint, secretHash: Fr, txHash: TxHash) {
+    this.logger('Adding note to PXE');
+    const storageSlot = new Fr(5);
+    const preimage = new NotePreimage([new Fr(shieldAmount), secretHash]);
+    await this.pxeService.addNote(this.ownerAddress, this.l2Token.address, storageSlot, preimage, txHash);
+  }
+
+  async redeemShieldPrivatelyOnL2(shieldAmount: bigint, secret: Fr) {
+    this.logger('Spending commitment in private call');
+    const privateTx = this.l2Token.methods.redeem_shield(this.ownerAddress, shieldAmount, secret).send();
+    const privateReceipt = await privateTx.wait();
+    expect(privateReceipt.status).toBe(TxStatus.MINED);
+  }
+
+  async unshieldTokensOnL2(unshieldAmount: bigint, nonce = Fr.ZERO) {
+    this.logger('Unshielding tokens');
+    const unshieldTx = this.l2Token.methods
+      .unshield(this.ownerAddress, this.ownerAddress, unshieldAmount, nonce)
+      .send();
+    const unshieldReceipt = await unshieldTx.wait();
+    expect(unshieldReceipt.status).toBe(TxStatus.MINED);
+  }
+}
diff --git a/yarn-project/canary/tsconfig.json b/yarn-project/canary/tsconfig.json
index d02659b19cdd..36e240e97259 100644
--- a/yarn-project/canary/tsconfig.json
+++ b/yarn-project/canary/tsconfig.json
@@ -31,6 +31,9 @@
     {
       "path": "../end-to-end"
     },
+    {
+      "path": "../foundation"
+    },
     {
       "path": "../l1-artifacts"
     },
diff --git a/yarn-project/end-to-end/src/uniswap_trade_on_l1_from_l2.test.ts b/yarn-project/end-to-end/src/uniswap_trade_on_l1_from_l2.test.ts
index 8e26fb3a8173..bc084357caf1 100644
--- a/yarn-project/end-to-end/src/uniswap_trade_on_l1_from_l2.test.ts
+++ b/yarn-project/end-to-end/src/uniswap_trade_on_l1_from_l2.test.ts
@@ -264,7 +264,7 @@ describe('uniswap_trade_on_l1_from_l2', () => {
     // Wait for the archiver to process the message
     await delay(5000);
     // send a transfer tx to force through rollup with the message included
-    await wethCrossChainHarness.performL2Transfer(0n);
+    await wethCrossChainHarness.mintTokensPublicOnL2(0n);
 
     // 6. claim dai on L2
     logger('Consuming messages to mint dai on L2');
@@ -407,7 +407,7 @@ describe('uniswap_trade_on_l1_from_l2', () => {
     // Wait for the archiver to process the message
     await delay(5000);
     // send a transfer tx to force through rollup with the message included
-    await wethCrossChainHarness.performL2Transfer(0n);
+    await wethCrossChainHarness.mintTokensPublicOnL2(0n);
 
     // 6. claim dai on L2
     logger('Consuming messages to mint dai on L2');
diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock
index 6efd2976c7a6..88fb58d9cabd 100644
--- a/yarn-project/yarn.lock
+++ b/yarn-project/yarn.lock
@@ -262,6 +262,7 @@ __metadata:
     "@aztec/circuits.js": "workspace:^"
     "@aztec/cli": "workspace:^"
     "@aztec/end-to-end": "workspace:^"
+    "@aztec/foundation": "workspace:^"
     "@aztec/l1-artifacts": "workspace:^"
     "@aztec/noir-contracts": "workspace:^"
     "@jest/globals": ^29.5.0