aere-contracts/test/usdce-warp-route.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

235 lines
10 KiB
JavaScript

// 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);
});
});