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.
547 lines
28 KiB
JavaScript
547 lines
28 KiB
JavaScript
// =============================================================================
|
|
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
|
|
// USDC.e Hyperlane v3 Warp Route on the AERE side (chain 2800).
|
|
//
|
|
// Source under test (matched to the live addresses in sdk-js/src/addresses.ts):
|
|
// contracts/bridge/AereWarpRouteV3.sol -> AereWarpRouteV3 0x1f44573684aB6bC617e7200A19b940b05e4EE098
|
|
// contracts/stablecoins/USDCe.sol -> the synthetic USDC.e bridged token
|
|
//
|
|
// MESSAGING LAYER: the REAL contracts/bridge/AereMailboxV3.sol (+ AereIGPV3),
|
|
// driven end-to-end with a real ECDSA validator signature through process().
|
|
// We deliberately DO NOT use the toy MockWarpMailboxV3 (which has no replay
|
|
// guard) for the inbound path, because the "cannot be processed twice" and
|
|
// "only the configured mailbox can deliver" invariants live in the mailbox and
|
|
// must be exercised against the real code. Any finding therefore cannot be
|
|
// blamed on a toy relayer. The ONLY mock is a second AereMailboxV3 instance
|
|
// used to prove a non-configured mailbox cannot drive the router, plus the
|
|
// plain MockERC20Lending token used for the Lock mode collateral. These are
|
|
// disclosed honestly here.
|
|
//
|
|
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT Foundry:
|
|
// `forge` is not installed; the repo builds through hardhat with OZ 4.9.6 +
|
|
// viaIR). Every random choice comes from mulberry32(seed); the base seed is
|
|
// printed at suite start and pinnable via FUZZ_SEED. On any break the failing
|
|
// (mode, campaign, step, actor, action, values) is embedded in the assertion
|
|
// message for a minimal repro.
|
|
//
|
|
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
|
|
// unrelated ML-DSA PQC KAT tests):
|
|
// npx hardhat test test/warp-route-v3-bridge-conservation-invariant-property.test.js
|
|
// No deploy to any live node, no contract-logic change, one new test file only.
|
|
//
|
|
// INVARIANTS FUZZED (exactly the brief):
|
|
// * BRIDGE CONSERVATION: a synthetic mint happens ONLY via handle() driven by
|
|
// a mailbox/validator-verified inbound message; locked collateral is
|
|
// released ONLY via such a message. Modelled: totalReceivedIn == messagedIn,
|
|
// totalSentOut == messagedOut, and net backing holds:
|
|
// MintBurn: USDC.e.totalSupply == messagedIn - messagedOut
|
|
// Lock: router token balance == messagedOut - messagedIn (>= 0)
|
|
// * NO UNAUTHORIZED MINT/RELEASE: a direct handle() from any non-mailbox
|
|
// caller reverts; a message delivered by a DIFFERENT (non-configured)
|
|
// mailbox reverts; supply/collateral unchanged.
|
|
// * NO DOUBLE-PROCESS: replaying an already-delivered message reverts
|
|
// ("already delivered") and mints/releases nothing a second time.
|
|
// * SENDER AUTH: an otherwise-valid, validator-signed message whose sender is
|
|
// not the enrolled remote router reverts (UnknownRouter); nothing minted.
|
|
// * CONFIG THEFT: a non-owner cannot enroll/unenroll a remote router; the
|
|
// mailbox / token / mode are immutable and cannot be repointed by anyone.
|
|
//
|
|
// A real invariant violation is a valuable result: it is reported as the exact
|
|
// minimal failing (mode, campaign, step, action, values), never papered over.
|
|
//
|
|
// No em-dashes anywhere in this file.
|
|
// =============================================================================
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
|
|
/* ------------------------------- seeded PRNG ------------------------------ */
|
|
function mulberry32(a) {
|
|
return function () {
|
|
a |= 0;
|
|
a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x11AE7C;
|
|
function rngFor(tag, campaign) {
|
|
let h = BASE_SEED ^ (campaign * 0x9e3779b1);
|
|
for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0;
|
|
return mulberry32(h >>> 0);
|
|
}
|
|
const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive
|
|
const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
|
|
const chance = (rng, p) => rng() < p;
|
|
|
|
/* ------------------------------- misc helpers ----------------------------- */
|
|
const SIX = 10n ** 6n;
|
|
const U = (n) => BigInt(Math.round(Number(n) * 1000)) * (SIX / 1000n); // 6dp USDC-ish
|
|
const b32 = (addr) => ethers.zeroPadValue(addr, 32);
|
|
const MAXU = ethers.MaxUint256;
|
|
|
|
const AERE_DOMAIN = 2800; // local (this side)
|
|
const ETH_DOMAIN = 1; // remote (the sister collateral chain)
|
|
|
|
// Deterministic validator key for the mailbox (this account signs inbound
|
|
// messages; it never sends a transaction).
|
|
const VALIDATOR_PK = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
|
|
// Mode enum on AereWarpRouteV3: 0 = Lock, 1 = MintBurn.
|
|
const MODE = { Lock: 0, MintBurn: 1 };
|
|
|
|
const N = {
|
|
CAMPAIGNS: Number(process.env.WARP_CAMPAIGNS || 5),
|
|
STEPS: Number(process.env.WARP_STEPS || 70),
|
|
};
|
|
|
|
function packBody(recipientB32, amount) {
|
|
// Hyperlane HypERC20 convention: body = bytes32 recipient || uint256 amount.
|
|
return ethers.solidityPacked(["bytes32", "uint256"], [recipientB32, amount]);
|
|
}
|
|
|
|
// Full Hyperlane v3 wire message the real AereMailboxV3 parses:
|
|
// version(1) || nonce(4) || origin(4) || sender(32) || destination(4) || recipient(32) || body
|
|
function packMessage(nonce, origin, senderB32, destination, recipientB32, body) {
|
|
return ethers.solidityPacked(
|
|
["uint8", "uint32", "uint32", "bytes32", "uint32", "bytes32", "bytes"],
|
|
[3, nonce, origin, senderB32, destination, recipientB32, body]
|
|
);
|
|
}
|
|
|
|
describe("INVARIANT: AereWarpRouteV3 (USDC.e Hyperlane v3 warp route) bridge conservation", function () {
|
|
this.timeout(0);
|
|
|
|
let signers, validator;
|
|
let mailboxF, igpF, routerF, usdceF, erc20F;
|
|
|
|
before(async function () {
|
|
signers = await ethers.getSigners();
|
|
validator = new ethers.Wallet(VALIDATOR_PK, ethers.provider);
|
|
mailboxF = await ethers.getContractFactory("AereMailboxV3");
|
|
igpF = await ethers.getContractFactory("AereIGPV3");
|
|
routerF = await ethers.getContractFactory("AereWarpRouteV3");
|
|
usdceF = await ethers.getContractFactory("USDCe");
|
|
erc20F = await ethers.getContractFactory("MockERC20Lending");
|
|
console.log(
|
|
` [warp] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} randomized steps per mode`
|
|
);
|
|
});
|
|
|
|
// ----- deploy the real messaging layer + the router in a chosen mode -----
|
|
async function deployStack(mode) {
|
|
const deployer = signers[0];
|
|
|
|
// Real mailbox on the AERE side (localDomain 2800) + real IGP hook.
|
|
const mailbox = await mailboxF.connect(deployer).deploy(AERE_DOMAIN);
|
|
await mailbox.waitForDeployment();
|
|
const igp = await igpF.connect(deployer).deploy(deployer.address); // beneficiary
|
|
await igp.waitForDeployment();
|
|
await (await mailbox.setDefaultHook(await igp.getAddress())).wait();
|
|
// Price destination gas so quoteDispatch/dispatch work end to end.
|
|
await (
|
|
await igp.setDestinationGasConfigs([
|
|
{ remoteDomain: ETH_DOMAIN, config: { gasOverhead: 100000, gasPriceAereWei: 1_000_000_000n } },
|
|
])
|
|
).wait();
|
|
await (await mailbox.addValidator(validator.address)).wait(); // threshold already 1
|
|
|
|
// A SECOND, non-configured mailbox used only to prove a foreign mailbox
|
|
// cannot drive the router's handle().
|
|
const foreignMailbox = await mailboxF.connect(deployer).deploy(AERE_DOMAIN);
|
|
await foreignMailbox.waitForDeployment();
|
|
await (await foreignMailbox.setDefaultHook(await igp.getAddress())).wait();
|
|
await (await foreignMailbox.addValidator(validator.address)).wait();
|
|
|
|
let token, router;
|
|
if (mode === MODE.MintBurn) {
|
|
// USDC.e's BRIDGE is immutable, so predict the router address (deployer
|
|
// nonce + 1) and deploy USDC.e pointing at it, then the router.
|
|
const nonce = await ethers.provider.getTransactionCount(deployer.address);
|
|
const futureRouter = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 });
|
|
token = await usdceF.connect(deployer).deploy(futureRouter);
|
|
await token.waitForDeployment();
|
|
router = await routerF
|
|
.connect(deployer)
|
|
.deploy(await mailbox.getAddress(), await igp.getAddress(), await token.getAddress(), MODE.MintBurn);
|
|
await router.waitForDeployment();
|
|
expect((await router.getAddress()).toLowerCase()).to.equal(futureRouter.toLowerCase());
|
|
expect(await token.BRIDGE()).to.equal(await router.getAddress());
|
|
} else {
|
|
// Lock mode: a pre-existing token the router does NOT mint.
|
|
token = await erc20F.connect(deployer).deploy("Bridged USD Coin", "USDC.e", 6);
|
|
await token.waitForDeployment();
|
|
router = await routerF
|
|
.connect(deployer)
|
|
.deploy(await mailbox.getAddress(), await igp.getAddress(), await token.getAddress(), MODE.Lock);
|
|
await router.waitForDeployment();
|
|
}
|
|
|
|
// The Ethereum-side sister router (arbitrary address; enrolled as the only
|
|
// trusted remote for ETH_DOMAIN).
|
|
const remoteRouter = ethers.Wallet.createRandom().address;
|
|
await (await router.enrollRemoteRouter(ETH_DOMAIN, b32(remoteRouter))).wait();
|
|
|
|
return {
|
|
mailbox,
|
|
foreignMailbox,
|
|
igp,
|
|
router,
|
|
token,
|
|
routerAddr: await router.getAddress(),
|
|
remoteRouter,
|
|
remoteRouterB32: b32(remoteRouter),
|
|
};
|
|
}
|
|
|
|
// Deliver a verified inbound message through the REAL mailbox.process().
|
|
// Returns { tx, message } where message is the exact bytes (for replay tests).
|
|
async function deliverInbound(stack, mailbox, senderB32, recipientRouterAddr, nonce, recipientB32, amount) {
|
|
const body = packBody(recipientB32, amount);
|
|
const message = packMessage(nonce, ETH_DOMAIN, senderB32, AERE_DOMAIN, b32(recipientRouterAddr), body);
|
|
const messageId = ethers.keccak256(message);
|
|
const sig = await validator.signMessage(ethers.getBytes(messageId));
|
|
return { tx: mailbox.connect(signers[0]).process(sig, message), message, messageId };
|
|
}
|
|
|
|
// Re-submit an already-built message (byte-identical) for the replay guard.
|
|
async function reprocess(mailbox, message) {
|
|
return mailbox.connect(signers[0]).process(await validatorSig(message), message);
|
|
}
|
|
async function validatorSig(message) {
|
|
return validator.signMessage(ethers.getBytes(ethers.keccak256(message)));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// MAIN FUZZ, parametrized over both token modes.
|
|
// -------------------------------------------------------------------------
|
|
async function runMode(modeName, mode) {
|
|
let steps = 0, mints = 0, burns = 0, replays = 0, foreign = 0, badSender = 0, direct = 0;
|
|
|
|
for (let c = 0; c < N.CAMPAIGNS; c++) {
|
|
const rng = rngFor("main-" + modeName, c);
|
|
const stack = await deployStack(mode);
|
|
const { mailbox, foreignMailbox, router, token, routerAddr, remoteRouterB32 } = stack;
|
|
|
|
const users = [signers[1], signers[2], signers[3], signers[4]];
|
|
const recips = [signers[1], signers[2], signers[3], signers[4], signers[5]];
|
|
|
|
// In Lock mode, users hold the pre-existing token and approve the router.
|
|
if (mode === MODE.Lock) {
|
|
for (const u of users) {
|
|
await (await token.mint(u.address, U(100_000_000))).wait();
|
|
await (await token.connect(u).approve(routerAddr, MAXU)).wait();
|
|
}
|
|
}
|
|
|
|
// JS-side ledger of what the bridge messaging has authorized.
|
|
let messagedIn = 0n; // sum of verified inbound amounts (mints / releases)
|
|
let messagedOut = 0n; // sum of outbound amounts (burns / locks)
|
|
let inboundNonce = 0; // remote-side nonce, kept unique so messageIds differ
|
|
const deliveredMsgs = []; // prior valid message bytes, for replay tests
|
|
|
|
// net synthetic / locked bookkeeping model
|
|
const netBacked = () => messagedIn - messagedOut; // MintBurn: supply; Lock: locked
|
|
|
|
async function assertInvariants(ctx) {
|
|
const tOut = await router.totalSentOut();
|
|
const tIn = await router.totalReceivedIn();
|
|
expect(tOut, `[warp OUT-LEDGER] ${ctx} totalSentOut ${tOut} != messagedOut ${messagedOut}`).to.equal(messagedOut);
|
|
expect(tIn, `[warp IN-LEDGER] ${ctx} totalReceivedIn ${tIn} != messagedIn ${messagedIn}`).to.equal(messagedIn);
|
|
|
|
if (mode === MODE.MintBurn) {
|
|
const supply = await token.totalSupply();
|
|
// (1) net backing: every synthetic in circulation is backed by a net
|
|
// verified inbound message (minted-in minus burned-out).
|
|
expect(supply, `[warp BACKING] ${ctx} USDC.e supply ${supply} != messagedIn-messagedOut ${netBacked()}`)
|
|
.to.equal(netBacked());
|
|
// (2) no free synthetic: supply can never exceed cumulative verified mints.
|
|
expect(supply, `[warp NO-OVER-MINT] ${ctx} supply ${supply} > messagedIn ${messagedIn}`).to.be.lte(messagedIn);
|
|
expect(netBacked(), `[warp NONNEG-SUPPLY] ${ctx} net supply negative`).to.be.gte(0n);
|
|
} else {
|
|
const locked = await token.balanceOf(routerAddr);
|
|
const lockedView = await router.lockedSupply();
|
|
expect(lockedView, `[warp LOCKED-VIEW] ${ctx}`).to.equal(locked);
|
|
// (1) collateral conservation: router holds exactly locked-minus-released.
|
|
expect(locked, `[warp COLLATERAL] ${ctx} locked ${locked} != messagedOut-messagedIn ${messagedOut - messagedIn}`)
|
|
.to.equal(messagedOut - messagedIn);
|
|
// (2) never releases more than was locked.
|
|
expect(messagedOut - messagedIn, `[warp NO-OVER-RELEASE] ${ctx} released > locked`).to.be.gte(0n);
|
|
}
|
|
|
|
// (3) immutables never move (no config theft).
|
|
expect(await router.MAILBOX(), `[warp IMMUT-MAILBOX] ${ctx}`).to.equal(await mailbox.getAddress());
|
|
expect(await router.TOKEN(), `[warp IMMUT-TOKEN] ${ctx}`).to.equal(await token.getAddress());
|
|
expect(await router.MODE(), `[warp IMMUT-MODE] ${ctx}`).to.equal(BigInt(mode));
|
|
expect(await router.routers(ETH_DOMAIN), `[warp REMOTE-ROUTER] ${ctx}`).to.equal(remoteRouterB32);
|
|
}
|
|
|
|
await assertInvariants(`${modeName} c${c} init`);
|
|
|
|
for (let s = 0; s < N.STEPS; s++) {
|
|
// Weight bridge-in early so users have balance to bridge out.
|
|
const action = pick(rng, [
|
|
"bridgeIn", "bridgeIn", "bridgeIn", "bridgeIn",
|
|
"bridgeOut", "bridgeOut", "bridgeOut",
|
|
"replay", "foreignMailbox", "badSender", "directHandle", "unauthConfig",
|
|
]);
|
|
const ctx = `${modeName} c${c} s${s} action=${action}`;
|
|
|
|
try {
|
|
if (action === "bridgeIn") {
|
|
// A verified inbound message: mint (MintBurn) or release (Lock).
|
|
const recip = pick(rng, recips);
|
|
let amount = U(ri(rng, 1, 40_000)) + BigInt(ri(rng, 0, 999999));
|
|
if (mode === MODE.Lock) {
|
|
// cannot release more than currently locked.
|
|
const locked = messagedOut - messagedIn;
|
|
if (locked === 0n) { steps++; await assertInvariants(ctx); continue; }
|
|
if (amount > locked) amount = locked;
|
|
}
|
|
const nonce = inboundNonce++;
|
|
const supplyBefore = mode === MODE.MintBurn ? await token.totalSupply() : await token.balanceOf(routerAddr);
|
|
const recipBefore = await token.balanceOf(recip.address);
|
|
const { tx, message } = await deliverInbound(
|
|
stack, mailbox, remoteRouterB32, routerAddr, nonce, b32(recip.address), amount
|
|
);
|
|
await (await tx).wait();
|
|
deliveredMsgs.push(message);
|
|
messagedIn += amount;
|
|
mints++;
|
|
|
|
const recipAfter = await token.balanceOf(recip.address);
|
|
if (mode === MODE.MintBurn) {
|
|
expect(recipAfter - recipBefore, `[warp MINT-CREDIT] ${ctx}`).to.equal(amount);
|
|
expect((await token.totalSupply()) - supplyBefore, `[warp MINT-SUPPLY] ${ctx}`).to.equal(amount);
|
|
} else {
|
|
expect(recipAfter - recipBefore, `[warp RELEASE-CREDIT] ${ctx}`).to.equal(amount);
|
|
}
|
|
} else if (action === "bridgeOut") {
|
|
// transferRemote: burn (MintBurn) or lock (Lock) the caller's tokens.
|
|
const user = pick(rng, users);
|
|
const bal = await token.balanceOf(user.address);
|
|
if (bal === 0n) { steps++; await assertInvariants(ctx); continue; }
|
|
let amount = bal === 1n ? 1n : (U(ri(rng, 0, 20_000)) % bal) + 1n;
|
|
if (amount > bal) amount = bal;
|
|
const dest = pick(rng, recips).address;
|
|
const recipB32 = b32(dest);
|
|
const fee = await router.quoteTransferRemote(ETH_DOMAIN, recipB32, amount);
|
|
const overpay = chance(rng, 0.4) ? fee / 5n + 1n : 0n;
|
|
|
|
const userBalBefore = await token.balanceOf(user.address);
|
|
await (
|
|
await router.connect(user).transferRemote(ETH_DOMAIN, recipB32, amount, { value: fee + overpay })
|
|
).wait();
|
|
const userBalAfter = await token.balanceOf(user.address);
|
|
|
|
expect(userBalBefore - userBalAfter, `[warp OUT-DEBIT] ${ctx} debit != amount`).to.equal(amount);
|
|
messagedOut += amount;
|
|
burns++;
|
|
} else if (action === "replay") {
|
|
// Replaying any prior byte-identical message must revert (mailbox
|
|
// replay guard) and change nothing.
|
|
if (!deliveredMsgs.length) { steps++; await assertInvariants(ctx); continue; }
|
|
const msg = pick(rng, deliveredMsgs);
|
|
await expect(reprocess(mailbox, msg), `[warp NO-DOUBLE-PROCESS] ${ctx}`).to.be.reverted;
|
|
replays++;
|
|
} else if (action === "foreignMailbox") {
|
|
// A message delivered by a DIFFERENT (non-configured) mailbox must
|
|
// not be able to drive the router: router.handle reverts NotMailbox.
|
|
const recip = pick(rng, recips);
|
|
const amount = U(ri(rng, 1, 5_000));
|
|
const nonce = 900000 + inboundNonce++;
|
|
const { tx } = await deliverInbound(
|
|
stack, foreignMailbox, remoteRouterB32, routerAddr, nonce, b32(recip.address), amount
|
|
);
|
|
await expect(tx, `[warp FOREIGN-MAILBOX] ${ctx} foreign mailbox minted/released`).to.be.reverted;
|
|
foreign++;
|
|
} else if (action === "badSender") {
|
|
// Valid validator-signed message via the correct mailbox, but the
|
|
// sender is NOT the enrolled remote router -> UnknownRouter revert.
|
|
const recip = pick(rng, recips);
|
|
const amount = U(ri(rng, 1, 5_000));
|
|
const nonce = 800000 + inboundNonce++;
|
|
const evilSender = b32(ethers.Wallet.createRandom().address);
|
|
const { tx } = await deliverInbound(
|
|
stack, mailbox, evilSender, routerAddr, nonce, b32(recip.address), amount
|
|
);
|
|
await expect(tx, `[warp BAD-SENDER] ${ctx} unenrolled sender accepted`).to.be.reverted;
|
|
badSender++;
|
|
} else if (action === "directHandle") {
|
|
// A random EOA calling handle() directly must revert NotMailbox and
|
|
// mint/release nothing.
|
|
const caller = pick(rng, users);
|
|
const amount = U(ri(rng, 1, 5_000));
|
|
const body = packBody(b32(caller.address), amount);
|
|
await expect(
|
|
router.connect(caller).handle(ETH_DOMAIN, remoteRouterB32, body),
|
|
`[warp DIRECT-HANDLE] ${ctx} non-mailbox handle accepted`
|
|
).to.be.revertedWithCustomError(router, "NotMailbox");
|
|
direct++;
|
|
} else if (action === "unauthConfig") {
|
|
// A non-owner cannot repoint the remote router (config theft).
|
|
const attacker = pick(rng, users);
|
|
const evil = b32(attacker.address);
|
|
await expect(
|
|
router.connect(attacker).enrollRemoteRouter(ETH_DOMAIN, evil),
|
|
`[warp UNAUTH-ENROLL] ${ctx} non-owner enrolled`
|
|
).to.be.revertedWith("Ownable: caller is not the owner");
|
|
await expect(
|
|
router.connect(attacker).unenrollRemoteRouter(ETH_DOMAIN),
|
|
`[warp UNAUTH-UNENROLL] ${ctx} non-owner unenrolled`
|
|
).to.be.revertedWith("Ownable: caller is not the owner");
|
|
}
|
|
} catch (e) {
|
|
// Never swallow an invariant break (our tagged assertions).
|
|
if (/warp [A-Z]/.test(String(e.message))) throw e;
|
|
// Otherwise a legitimate revert on an edge action -> state unchanged;
|
|
// invariants are still asserted below.
|
|
}
|
|
|
|
await assertInvariants(ctx);
|
|
steps++;
|
|
}
|
|
|
|
// Finale: bridge every remaining user balance back out (MintBurn burns the
|
|
// supply to zero; Lock drains the router's collateral to zero), proving the
|
|
// net backing collapses to exactly zero with no dust left behind.
|
|
if (mode === MODE.MintBurn) {
|
|
// Every holder of minted USDC.e (any recipient, not just the outbound
|
|
// actors) burns its balance back out, collapsing supply to zero.
|
|
const holders = [...new Set(recips.map((r) => r.address))];
|
|
for (const addr of holders) {
|
|
const holder = signers.find((s) => s.address === addr);
|
|
const bal = await token.balanceOf(addr);
|
|
if (holder && bal > 0n) {
|
|
const fee = await router.quoteTransferRemote(ETH_DOMAIN, b32(addr), bal);
|
|
await (await router.connect(holder).transferRemote(ETH_DOMAIN, b32(addr), bal, { value: fee })).wait();
|
|
messagedOut += bal;
|
|
}
|
|
}
|
|
}
|
|
if (mode === MODE.Lock) {
|
|
// Release all remaining locked collateral through verified inbounds.
|
|
let locked = messagedOut - messagedIn;
|
|
while (locked > 0n) {
|
|
const amount = locked > U(30_000) ? U(30_000) : locked;
|
|
const nonce = inboundNonce++;
|
|
const { tx } = await deliverInbound(
|
|
stack, mailbox, remoteRouterB32, routerAddr, nonce, b32(signers[5].address), amount
|
|
);
|
|
await (await tx).wait();
|
|
messagedIn += amount;
|
|
locked = messagedOut - messagedIn;
|
|
}
|
|
}
|
|
|
|
await assertInvariants(`${modeName} c${c} finale`);
|
|
if (mode === MODE.MintBurn) {
|
|
expect(await token.totalSupply(), `[warp DRAIN] ${modeName} c${c} supply not drained`).to.equal(0n);
|
|
} else {
|
|
expect(await token.balanceOf(routerAddr), `[warp DRAIN] ${modeName} c${c} collateral not drained`).to.equal(0n);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
` [warp ${modeName}] steps=${steps} bridgeIn=${mints} bridgeOut=${burns} ` +
|
|
`replays=${replays} foreignMailbox=${foreign} badSender=${badSender} directHandle=${direct}`
|
|
);
|
|
expect(steps).to.be.greaterThan(200);
|
|
expect(mints).to.be.greaterThan(30);
|
|
expect(burns).to.be.greaterThan(15);
|
|
expect(replays + foreign + badSender + direct).to.be.greaterThan(20);
|
|
}
|
|
|
|
it("MintBurn: mint only on verified inbound, backing = messagedIn-messagedOut, no double-process, no config theft", async function () {
|
|
await runMode("MintBurn", MODE.MintBurn);
|
|
});
|
|
|
|
it("Lock: release only on verified inbound, collateral = messagedOut-messagedIn, no over-release, no config theft", async function () {
|
|
await runMode("Lock", MODE.Lock);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// TARGETED GUARDS: deterministic checks on the exact security boundaries,
|
|
// driven against the REAL mailbox so nothing hinges on a toy relayer.
|
|
// -------------------------------------------------------------------------
|
|
it("targeted guards: only-mailbox mint, replay-once, unknown-sender, foreign-mailbox, immutables, owner-gated config", async function () {
|
|
const [deployer, alice, bob, mallory] = signers;
|
|
const stack = await deployStack(MODE.MintBurn);
|
|
const { mailbox, foreignMailbox, router, token, routerAddr, remoteRouterB32 } = stack;
|
|
|
|
// A direct EOA handle() cannot mint.
|
|
await expect(
|
|
router.connect(mallory).handle(ETH_DOMAIN, remoteRouterB32, packBody(b32(mallory.address), U(1_000_000))),
|
|
"[warp G-DIRECT] direct handle minted"
|
|
).to.be.revertedWithCustomError(router, "NotMailbox");
|
|
expect(await token.totalSupply(), "[warp G-DIRECT-SUPPLY] supply moved").to.equal(0n);
|
|
|
|
// A verified inbound from the enrolled router DOES mint exactly once.
|
|
const { tx, message } = await deliverInbound(
|
|
stack, mailbox, remoteRouterB32, routerAddr, 1, b32(alice.address), U(5_000)
|
|
);
|
|
await (await tx).wait();
|
|
expect(await token.balanceOf(alice.address), "[warp G-MINT] alice not credited").to.equal(U(5_000));
|
|
expect(await token.totalSupply(), "[warp G-MINT-SUPPLY]").to.equal(U(5_000));
|
|
|
|
// Replaying that exact message reverts (mailbox replay guard) and mints nothing more.
|
|
await expect(reprocess(mailbox, message), "[warp G-REPLAY] replay accepted").to.be.revertedWith(
|
|
"AereMailboxV3: already delivered"
|
|
);
|
|
expect(await token.totalSupply(), "[warp G-REPLAY-SUPPLY] supply moved on replay").to.equal(U(5_000));
|
|
|
|
// A validator-signed message whose sender is not the enrolled remote router reverts.
|
|
const evilSender = b32(mallory.address);
|
|
const { tx: badTx } = await deliverInbound(
|
|
stack, mailbox, evilSender, routerAddr, 2, b32(bob.address), U(9_999)
|
|
);
|
|
await expect(badTx, "[warp G-BADSENDER] unenrolled sender minted").to.be.revertedWithCustomError(
|
|
router,
|
|
"UnknownRouter"
|
|
);
|
|
expect(await token.totalSupply(), "[warp G-BADSENDER-SUPPLY]").to.equal(U(5_000));
|
|
|
|
// The same message body delivered by a FOREIGN (non-configured) mailbox cannot mint.
|
|
const { tx: foreignTx } = await deliverInbound(
|
|
stack, foreignMailbox, remoteRouterB32, routerAddr, 3, b32(bob.address), U(7_777)
|
|
);
|
|
await expect(foreignTx, "[warp G-FOREIGN] foreign mailbox minted").to.be.revertedWithCustomError(
|
|
router,
|
|
"NotMailbox"
|
|
);
|
|
expect(await token.totalSupply(), "[warp G-FOREIGN-SUPPLY]").to.equal(U(5_000));
|
|
|
|
// Owner-gated config: only the owner can enroll/unenroll.
|
|
await expect(
|
|
router.connect(mallory).enrollRemoteRouter(ETH_DOMAIN, b32(mallory.address)),
|
|
"[warp G-ENROLL] non-owner enrolled"
|
|
).to.be.revertedWith("Ownable: caller is not the owner");
|
|
await expect(
|
|
router.connect(mallory).unenrollRemoteRouter(ETH_DOMAIN),
|
|
"[warp G-UNENROLL] non-owner unenrolled"
|
|
).to.be.revertedWith("Ownable: caller is not the owner");
|
|
|
|
// Immutables cannot be repointed by anyone (no setter exists at all).
|
|
expect(await router.MAILBOX(), "[warp G-IMMUT-MAILBOX]").to.equal(await mailbox.getAddress());
|
|
expect(await router.TOKEN(), "[warp G-IMMUT-TOKEN]").to.equal(await token.getAddress());
|
|
expect(await router.MODE(), "[warp G-IMMUT-MODE]").to.equal(1n);
|
|
|
|
// A user can bridge the minted balance back out (burn), and the supply falls
|
|
// by exactly that amount: mint/burn is symmetric and conservative.
|
|
const fee = await router.quoteTransferRemote(ETH_DOMAIN, b32(alice.address), U(5_000));
|
|
await (await router.connect(alice).transferRemote(ETH_DOMAIN, b32(alice.address), U(5_000), { value: fee })).wait();
|
|
expect(await token.totalSupply(), "[warp G-BURN] supply not burned to zero").to.equal(0n);
|
|
expect(await router.totalReceivedIn(), "[warp G-IN]").to.equal(U(5_000));
|
|
expect(await router.totalSentOut(), "[warp G-OUT]").to.equal(U(5_000));
|
|
|
|
// A transferRemote to an un-enrolled destination reverts (no silent burn).
|
|
await expect(
|
|
router.connect(alice).transferRemote(9999, b32(alice.address), U(1), { value: 0 }),
|
|
"[warp G-NO-ROUTER] burn to un-enrolled destination"
|
|
).to.be.revertedWithCustomError(router, "NoRouterEnrolled");
|
|
|
|
console.log(" [warp guards] all mint/release security boundaries held against the real mailbox");
|
|
});
|
|
});
|