// End-to-end test of the USDC.e Hyperlane Warp Route: // Ethereum (chain 1) → Lock native USDC in AereHypERC20Collateral // → MockMailbox dispatch → MockMailbox sister handle // → AereHypERC20Synthetic mints USDC.e to recipient on AERE (chain 2800). // // Round-trip: User on AERE burns USDC.e via transferRemote // → reverse-direction message → AereHypERC20Collateral releases native USDC. // // Mocks the Mailbox via MockHypMailbox (in-tx delivery, no relayer needed). // // Run: npx hardhat test test/usdce-warp-route.test.js const { expect } = require("chai"); const { ethers } = require("hardhat"); const ETH_DOMAIN = 1; // Hyperlane mainnet domain id const AERE_DOMAIN = 2800; // we use chainId as domain id (matches actual Hyperlane convention for AERE deploy) const SIX_DECIMALS = 10n ** 6n; function addrToBytes32(addr) { return ethers.zeroPadValue(addr, 32); } describe("USDC.e Hyperlane Warp Route — full round trip", function () { let deployer, alice, bob; let ethMailbox, aereMailbox; let usdcNative; // native USDC on Ethereum (mock) let collateral; // AereHypERC20Collateral on Ethereum let usdce; // USDC.e ERC-20 on AERE let synthetic; // AereHypERC20Synthetic on AERE beforeEach(async function () { [deployer, alice, bob] = await ethers.getSigners(); // 1) Two mock Mailboxes (one per chain). const Mailbox = await ethers.getContractFactory("MockHypMailbox"); ethMailbox = await Mailbox.deploy(ETH_DOMAIN); await ethMailbox.waitForDeployment(); aereMailbox = await Mailbox.deploy(AERE_DOMAIN); await aereMailbox.waitForDeployment(); // 2) Native USDC mock on Ethereum, with seed balance for Alice. usdcNative = await (await ethers.getContractFactory("MockUSDC")).deploy(); await usdcNative.waitForDeployment(); await usdcNative.mint(alice.address, 1_000_000n * SIX_DECIMALS); // Liquidity for the Ethereum-side collateral lockbox to release on inbound. // (In production, the collateral starts empty and accrues with deposits.) // 3) Collateral lockbox on Ethereum. collateral = await (await ethers.getContractFactory("AereHypERC20Collateral")).deploy( await ethMailbox.getAddress(), await usdcNative.getAddress() ); await collateral.waitForDeployment(); // 4) USDC.e on AERE — Synthetic deploys AFTER it (synthetic is the BRIDGE). // Compute the synthetic's future address so we can pass it into USDC.e's constructor. const nonce = await ethers.provider.getTransactionCount(deployer.address); const futureSyntheticAddr = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 }); usdce = await (await ethers.getContractFactory("USDCe")).deploy(futureSyntheticAddr); await usdce.waitForDeployment(); synthetic = await (await ethers.getContractFactory("AereHypERC20Synthetic")).deploy( await aereMailbox.getAddress(), await usdce.getAddress(), 6 // AUDIT FIX (HIGH #17): expectedDecimals (USDC.e has 6) ); await synthetic.waitForDeployment(); expect((await synthetic.getAddress()).toLowerCase()).to.equal(futureSyntheticAddr.toLowerCase()); expect(await usdce.BRIDGE()).to.equal(await synthetic.getAddress()); // 5) Enroll trusted routers (each side knows the other's contract address). await collateral.enrollRouter(AERE_DOMAIN, addrToBytes32(await synthetic.getAddress())); // AUDIT FIX (HIGH #16): synthetic uses 7-day timelock now. await synthetic.proposeEnrollRouter(ETH_DOMAIN, addrToBytes32(await collateral.getAddress())); await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await synthetic.finalizeEnrollRouter(ETH_DOMAIN); // 6) Pair the mock Mailboxes. Each Mailbox knows its sister AND the // local-side application contract that receives inbound .handle() // calls. The sister Mailbox is the one that performs the delivery // so the recipient sees msg.sender == local Mailbox (matching // production Hyperlane semantics). await ethMailbox.setSister(await aereMailbox.getAddress(), await collateral.getAddress()); await aereMailbox.setSister(await ethMailbox.getAddress(), await synthetic.getAddress()); }); it("deposit (Ethereum → AERE): lock USDC, mint USDC.e", async function () { const amount = 100n * SIX_DECIMALS; // 100 USDC // Alice approves the collateral and bridges 100 USDC to bob's address on AERE. await usdcNative.connect(alice).approve(await collateral.getAddress(), amount); const fee = await ethMailbox.fee(); const tx = await collateral.connect(alice).transferRemote( AERE_DOMAIN, addrToBytes32(bob.address), amount, { value: fee } ); const receipt = await tx.wait(); // Collateral locked. expect(await usdcNative.balanceOf(await collateral.getAddress())).to.equal(amount); expect(await usdcNative.balanceOf(alice.address)).to.equal(1_000_000n * SIX_DECIMALS - amount); // USDC.e minted to bob on AERE. expect(await usdce.balanceOf(bob.address)).to.equal(amount); expect(await usdce.totalSupply()).to.equal(amount); // ReceivedTransferRemote emitted on AERE side. const aereLogs = await aereMailbox.queryFilter(aereMailbox.filters.Handled()); expect(aereLogs.length).to.equal(1); }); it("withdrawal (AERE → Ethereum): burn USDC.e, release USDC", async function () { // First bridge 200 USDC over so Bob has USDC.e to send back. const amount = 200n * SIX_DECIMALS; await usdcNative.connect(alice).approve(await collateral.getAddress(), amount); const fee = await ethMailbox.fee(); await collateral.connect(alice).transferRemote(AERE_DOMAIN, addrToBytes32(bob.address), amount, { value: fee }); expect(await usdce.balanceOf(bob.address)).to.equal(amount); expect(await usdcNative.balanceOf(await collateral.getAddress())).to.equal(amount); // Now Bob bridges 80 USDC.e back to Alice on Ethereum. const back = 80n * SIX_DECIMALS; const feeBack = await aereMailbox.fee(); await synthetic.connect(bob).transferRemote( ETH_DOMAIN, addrToBytes32(alice.address), back, { value: feeBack } ); // Bob's USDC.e burned. expect(await usdce.balanceOf(bob.address)).to.equal(amount - back); // Alice received 80 USDC on Ethereum. expect(await usdcNative.balanceOf(alice.address)).to.equal(1_000_000n * SIX_DECIMALS - amount + back); // Collateral lockbox decreased by 80 USDC. expect(await usdcNative.balanceOf(await collateral.getAddress())).to.equal(amount - back); }); it("reserve invariant: collateral.lockedSupply() == usdce.totalSupply()", async function () { const amount = 500n * SIX_DECIMALS; await usdcNative.connect(alice).approve(await collateral.getAddress(), amount); const fee = await ethMailbox.fee(); await collateral.connect(alice).transferRemote(AERE_DOMAIN, addrToBytes32(bob.address), amount, { value: fee }); expect(await collateral.lockedSupply()).to.equal(await usdce.totalSupply()); // Half round-trip: const back = 200n * SIX_DECIMALS; const feeBack = await aereMailbox.fee(); await synthetic.connect(bob).transferRemote(ETH_DOMAIN, addrToBytes32(alice.address), back, { value: feeBack }); expect(await collateral.lockedSupply()).to.equal(await usdce.totalSupply()); }); it("rejects mint from non-bridge caller", async function () { await expect(usdce.connect(alice).mint(alice.address, 1n)).to.be.revertedWithCustomError(usdce, "OnlyBridge"); await expect(usdce.connect(bob).burnFrom(bob.address, 1n)).to.be.revertedWithCustomError(usdce, "OnlyBridge"); }); it("rejects handle() from non-mailbox caller", async function () { await expect( synthetic.connect(alice).handle( ETH_DOMAIN, addrToBytes32(await collateral.getAddress()), ethers.zeroPadValue("0x00", 64) ) ).to.be.revertedWithCustomError(synthetic, "NotMailbox"); }); it("rejects message from unknown router (replay-protection across domains)", async function () { // De-enroll the AERE synthetic on collateral, then simulate inbound to collateral // from AereMailbox. await collateral.unenrollRouter(AERE_DOMAIN); // Re-pair the mocks so dispatching from AereMailbox reaches collateral. // (Already set in beforeEach; we just call the path.) const feeBack = await aereMailbox.fee(); // Try a transfer Aere → Eth — collateral has no router enrolled for AERE_DOMAIN, so // the inbound handle() on collateral reverts UnknownRouter. await usdcNative.connect(alice).approve(await collateral.getAddress(), 100n * SIX_DECIMALS); await collateral.enrollRouter(AERE_DOMAIN, addrToBytes32(await synthetic.getAddress())); // re-enroll for first leg await collateral.connect(alice).transferRemote(AERE_DOMAIN, addrToBytes32(bob.address), 100n * SIX_DECIMALS, { value: feeBack }); // De-enroll AGAIN to simulate a router rotation that breaks reverse leg. await collateral.unenrollRouter(AERE_DOMAIN); await expect( synthetic.connect(bob).transferRemote(ETH_DOMAIN, addrToBytes32(alice.address), 50n * SIX_DECIMALS, { value: feeBack }) ).to.be.revertedWithCustomError(collateral, "UnknownRouter"); }); it("requires sufficient IGP fee on outbound", async function () { const amount = 1n * SIX_DECIMALS; await usdcNative.connect(alice).approve(await collateral.getAddress(), amount); const fee = await ethMailbox.fee(); await expect( collateral.connect(alice).transferRemote( AERE_DOMAIN, addrToBytes32(bob.address), amount, { value: fee - 1n } ) ).to.be.revertedWithCustomError(collateral, "InsufficientIgpPayment"); }); it("refunds overpayment of IGP fee", async function () { const amount = 50n * SIX_DECIMALS; await usdcNative.connect(alice).approve(await collateral.getAddress(), amount); const fee = await ethMailbox.fee(); const overpay = fee + 5n * 10n ** 15n; // fee + 0.005 ETH const balBefore = await ethers.provider.getBalance(alice.address); const tx = await collateral.connect(alice).transferRemote( AERE_DOMAIN, addrToBytes32(bob.address), amount, { value: overpay } ); const receipt = await tx.wait(); const gasCost = receipt.gasUsed * receipt.gasPrice; const balAfter = await ethers.provider.getBalance(alice.address); // Alice paid only fee + gas, refund of (overpay - fee) returned. expect(balBefore - balAfter - gasCost).to.equal(fee); }); });