// Hardhat tests for AereSpokePool (ERC-7683 OriginSettler). // // Tests: // - open(): user deposits input, AereSink receives 50 bps protocol fee, // order stored with solverPortion // - openFor(): EIP-712 signed gasless order; replay-protected by nonce // - claim(): only allowed solver can claim; can't double-claim // - settle(): permissionless after 6-hour window; releases solverPortion // - challenge(): emits within window; outside window reverts // - bond deposit + slash: amount routes to AereSink by default // // Run: npx hardhat test test/spoke-pool.test.js const { expect } = require("chai"); const { ethers } = require("hardhat"); const ONE = 10n ** 18n; const AERE_ORDER_DATA_TYPE = ethers.keccak256(ethers.toUtf8Bytes("AereV3Order")); const DEST_CHAIN = 1n; // Ethereum mainnet async function deployStack() { const [deployer, alice, solver1, solver2, recipient] = await ethers.getSigners(); // WAERE const WAERE = await (await ethers.getContractFactory("contracts/WAERE.sol:WAERE")).deploy(); await WAERE.waitForDeployment(); // Mock sink with a flush(address,uint256) signature. const SinkSource = ` // SPDX-License-Identifier: MIT pragma solidity 0.8.23; interface IERC20 { function transferFrom(address,address,uint256) external returns (bool); function transfer(address,uint256) external returns (bool); function balanceOf(address) external view returns (uint256); } contract FakeSink { uint256 public received; address public lastToken; uint256 public lastAmount; function flush(address token, uint256 amount) external { bool ok = IERC20(token).transferFrom(msg.sender, address(this), amount); require(ok, "transferFrom"); received += amount; lastToken = token; lastAmount = amount; } } `; // Inline-deploy the FakeSink by writing a temp .sol and importing? Simpler: // re-use AereSink itself — but it needs many constructor args. Instead, use // a deployed AereFeeBurnVault as the sink for testing. The spoke pool // only calls flush(address,uint256) so any contract with that selector works. // Compile fallback: deploy AereSink with safe placeholder args. // For simplicity in this test, we deploy a tiny inline-mock by reusing an // existing minimal contract. Use AereFeeBurnVault if it accepts arbitrary // tokens (probably not). Use the existing "WAERE" deposit as a dummy // address that accepts transferFrom and emits ok. // Easiest: deploy the actual AereSink with safe args so its flush call // succeeds for AERE input. const burnVault = await (await ethers.getContractFactory("AereFeeBurnVault")).deploy(); await burnVault.waitForDeployment(); const FakeRouter = await (await ethers.getContractFactory("WAERE")).deploy(); // any contract address await FakeRouter.waitForDeployment(); // The AereSink swap path tries to call DEX_ROUTER for non-AERE input. For // the spoke-pool test we use WAERE as the input token so the AERE-fast-path // is taken — no swap is needed. // sAERE seed setup const nonce = await ethers.provider.getTransactionCount(deployer.address); const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 }); await WAERE.connect(deployer).deposit({ value: 1000n }); await WAERE.connect(deployer).approve(futureSaere, 1000n); const sAERE = await (await ethers.getContractFactory("sAERE")).deploy(await WAERE.getAddress()); await sAERE.waitForDeployment(); const Sink = await ethers.getContractFactory("AereSink"); const sink = await Sink.deploy( await WAERE.getAddress(), await burnVault.getAddress(), await sAERE.getAddress(), await FakeRouter.getAddress(), 1500, 4000, 4500, 200, ethers.ZeroAddress ); await sink.waitForDeployment(); // SpokePool const Pool = await ethers.getContractFactory("contracts/intents/AereSpokePool.sol:AereSpokePool"); const pool = await Pool.deploy(await WAERE.getAddress(), await sink.getAddress()); await pool.waitForDeployment(); return { deployer, alice, solver1, solver2, recipient, WAERE, sAERE, sink, pool, burnVault }; } function encodeOrderData(inputToken, inputAmount, outputToken, outputAmount, recipientB32, destChain) { return ethers.AbiCoder.defaultAbiCoder().encode( ["address", "uint256", "address", "uint256", "bytes32", "uint64"], [inputToken, inputAmount, outputToken, outputAmount, recipientB32, destChain] ); } describe("AereSpokePool — ERC-7683 OriginSettler", function () { it("open: pulls input, routes 50 bps fee to sink, emits Open + Opened", async function () { const { alice, recipient, WAERE, sink, pool } = await deployStack(); const inputAmount = 1000n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN); const blk0 = await ethers.provider.getBlock('latest'); const order = { fillDeadline: blk0.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data, }; await expect(pool.connect(alice).open(order)) .to.emit(pool, "Opened"); const expectedFee = (inputAmount * 50n) / 10_000n; // 0.5% const expectedSolverPortion = inputAmount - expectedFee; // Pool now holds solverPortion (fee went into sink → routed onward). expect(await WAERE.balanceOf(await pool.getAddress())).to.equal(expectedSolverPortion); }); it("claim/settle: only allowed solver claims; 6-hour window then settle", async function () { const { deployer, alice, solver1, solver2, recipient, WAERE, pool } = await deployStack(); const inputAmount = 1000n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN); const blk = await ethers.provider.getBlock('latest'); const order = { fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data }; const tx = await pool.connect(alice).open(order); const receipt = await tx.wait(); const openedLog = receipt.logs.find(l => l.fragment && l.fragment.name === "Opened"); const orderId = openedLog.args.orderId; // Solver2 is not allowed → cannot claim. await expect(pool.connect(solver2).claim(orderId)).to.be.revertedWithCustomError(pool, "NotAllowedSolver"); // Foundation allows solver1. await pool.connect(deployer).setSolverAllowed(solver1.address, true); await pool.connect(solver1).claim(orderId); // Cannot claim twice. await expect(pool.connect(solver1).claim(orderId)).to.be.revertedWithCustomError(pool, "AlreadyClaimed"); // Cannot settle before window. await expect(pool.settle(orderId)).to.be.revertedWithCustomError(pool, "WithinChallengeWindow"); // Advance 6h. await ethers.provider.send("evm_increaseTime", [6 * 3600 + 1]); await ethers.provider.send("evm_mine", []); const expectedSolverPortion = inputAmount - (inputAmount * 50n) / 10_000n; const balBefore = await WAERE.balanceOf(solver1.address); await pool.settle(orderId); expect(await WAERE.balanceOf(solver1.address) - balBefore).to.equal(expectedSolverPortion); }); // ---- REFUND FIX (adversarial-review-found HIGH, 2026-07-12) ---- // Without cancel(), a user's locked input principal is stranded forever when // no allowed solver ever claims the order (routine: unprofitable/stale/expired). // settle() reverts unless claimedAt != 0, so there was no exit. cancel() returns // the principal to the original user after the fillDeadline, permissionlessly. it("cancel fix: unclaimed order refunds the original user after fillDeadline, not before", async function () { const { alice, recipient, WAERE, pool } = await deployStack(); const waereAddr = await WAERE.getAddress(); const poolAddr = await pool.getAddress(); const inputAmount = 1000n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(poolAddr, inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN); const blk = await ethers.provider.getBlock('latest'); const rc = await (await pool.connect(alice).open({ fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data })).wait(); const orderId = rc.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId; const solverPortion = inputAmount - (inputAmount * 50n) / 10_000n; expect(await pool.totalLocked(waereAddr)).to.equal(solverPortion); // Cannot cancel before the fill deadline — the solver still has their window. await expect(pool.connect(alice).cancel(orderId)).to.be.revertedWithCustomError(pool, "FillDeadlineNotPassed"); // Advance past the fill deadline with NO claim. await ethers.provider.send("evm_increaseTime", [3601]); await ethers.provider.send("evm_mine", []); // settle() still reverts (never claimed) — proves the funds were otherwise stuck. await expect(pool.settle(orderId)).to.be.revertedWithCustomError(pool, "UnknownOrder"); // Permissionless cancel returns the locked principal to the ORIGINAL user. const balBefore = await WAERE.balanceOf(alice.address); await expect(pool.connect(alice).cancel(orderId)) .to.emit(pool, "Cancelled").withArgs(orderId, alice.address, solverPortion); expect(await WAERE.balanceOf(alice.address) - balBefore).to.equal(solverPortion); expect(await pool.totalLocked(waereAddr)).to.equal(0n); // Cannot double-cancel. await expect(pool.connect(alice).cancel(orderId)).to.be.revertedWithCustomError(pool, "AlreadySettled"); }); it("cancel fix: a CLAIMED order cannot be cancelled (solver settle path preserved)", async function () { const { deployer, alice, solver1, recipient, WAERE, pool } = await deployStack(); const waereAddr = await WAERE.getAddress(); const poolAddr = await pool.getAddress(); const inputAmount = 1000n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(poolAddr, inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN); const blk = await ethers.provider.getBlock('latest'); const rc = await (await pool.connect(alice).open({ fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data })).wait(); const orderId = rc.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId; await pool.connect(deployer).setSolverAllowed(solver1.address, true); await pool.connect(solver1).claim(orderId); // Past the fill deadline, a CLAIMED order still must NOT be cancellable — the // solver filled on the destination and is owed settlement (claimedAt guard first). await ethers.provider.send("evm_increaseTime", [3601]); await ethers.provider.send("evm_mine", []); await expect(pool.connect(alice).cancel(orderId)).to.be.revertedWithCustomError(pool, "AlreadyClaimed"); }); // ---- BOND-ACCOUNTING FIX (SMT-found composition) ---- // sweepResidual previously reserved only totalLocked, NOT solver bonds. Because // WAERE is both the bond token and an allowed input token, a WAERE-input order + // a bond shared the same balance; sweepResidual swept the bond, then slash drove // balance below totalLocked and denied the user's settle(). Fix: totalBond is // reserved on top of totalLocked. Invariant: balance >= totalLocked + totalBond. it("bond fix: sweepResidual reserves solver bonds; WAERE-input order stays backed after slash + settle", async function () { const { deployer, alice, solver1, recipient, WAERE, pool } = await deployStack(); const poolAddr = await pool.getAddress(); const waereAddr = await WAERE.getAddress(); // 1. Alice opens a WAERE-INPUT order. const inputAmount = 1000n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(poolAddr, inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN); const blk = await ethers.provider.getBlock('latest'); const order = { fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data }; const rc = await (await pool.connect(alice).open(order)).wait(); const orderId = rc.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId; const principal = inputAmount - (inputAmount * 50n) / 10_000n; // totalLocked[WAERE] expect(await pool.totalLocked(waereAddr)).to.equal(principal); // 2. A solver posts a WAERE bond ON TOP. const bond = 500n * ONE; await WAERE.connect(solver1).deposit({ value: bond }); await WAERE.connect(solver1).approve(poolAddr, bond); await pool.connect(solver1).depositSolverBond(solver1.address, bond); expect(await pool.totalBond(waereAddr)).to.equal(bond); expect(await WAERE.balanceOf(poolAddr)).to.equal(principal + bond); // invariant // 3. THE FIX: sweepResidual cannot take the bond (balance == reserved) → reverts. await expect(pool.sweepResidual(waereAddr)).to.be.revertedWithCustomError(pool, "ZeroAmount"); expect(await WAERE.balanceOf(poolAddr)).to.equal(principal + bond); // untouched // 4. Slash the full bond → totalBond decremented in lockstep; principal still backed. await pool.connect(deployer).slashSolverBond(solver1.address, bond, ethers.ZeroAddress); expect(await pool.totalBond(waereAddr)).to.equal(0n); expect(await WAERE.balanceOf(poolAddr)).to.equal(principal); expect(await WAERE.balanceOf(poolAddr)).to.be.gte(await pool.totalLocked(waereAddr)); // INV holds // 5. Alice's principal is still redeemable: settle MUST NOT revert. await pool.connect(deployer).setSolverAllowed(solver1.address, true); await pool.connect(solver1).claim(orderId); await ethers.provider.send("evm_increaseTime", [6 * 3600 + 1]); await ethers.provider.send("evm_mine", []); const before = await WAERE.balanceOf(solver1.address); await pool.settle(orderId); expect(await WAERE.balanceOf(solver1.address) - before).to.equal(principal); expect(await pool.totalLocked(waereAddr)).to.equal(0n); }); it("bond fix: genuine surplus above totalLocked+totalBond is still sweepable", async function () { const { alice, solver1, recipient, WAERE, pool } = await deployStack(); const poolAddr = await pool.getAddress(); const waereAddr = await WAERE.getAddress(); // Open a WAERE order + post a bond (locked + bond reserved). const inputAmount = 1000n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(poolAddr, inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(waereAddr, inputAmount, waereAddr, inputAmount, recipientB32, DEST_CHAIN); const blk = await ethers.provider.getBlock('latest'); await pool.connect(alice).open({ fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data }); const bond = 300n * ONE; await WAERE.connect(solver1).deposit({ value: bond }); await WAERE.connect(solver1).approve(poolAddr, bond); await pool.connect(solver1).depositSolverBond(solver1.address, bond); const reserved = (await pool.totalLocked(waereAddr)) + (await pool.totalBond(waereAddr)); // Send genuine dust surplus on top. const dust = 42n * ONE; await WAERE.connect(alice).deposit({ value: dust }); await WAERE.connect(alice).transfer(poolAddr, dust); expect(await WAERE.balanceOf(poolAddr)).to.equal(reserved + dust); // sweepResidual sweeps ONLY the dust surplus; reserved (locked+bond) stays intact. // (The swept WAERE goes to AereSink, which processes/burns it rather than simply // holding it, so we assert on the pool side: balance drops to exactly reserved.) await pool.sweepResidual(waereAddr); expect(await WAERE.balanceOf(poolAddr)).to.equal(reserved); }); it("challenge: anyone can challenge within window; reverts outside", async function () { const { deployer, alice, solver1, solver2, recipient, WAERE, pool } = await deployStack(); const inputAmount = 100n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN); const blk = await ethers.provider.getBlock('latest'); const order = { fillDeadline: blk.timestamp + 3600, orderDataType: AERE_ORDER_DATA_TYPE, orderData: data }; const tx = await pool.connect(alice).open(order); const receipt = await tx.wait(); const orderId = receipt.logs.find(l => l.fragment && l.fragment.name === "Opened").args.orderId; await pool.connect(deployer).setSolverAllowed(solver1.address, true); await pool.connect(solver1).claim(orderId); await expect(pool.connect(solver2).challenge(orderId)) .to.emit(pool, "Challenged"); // After window, challenge reverts. await ethers.provider.send("evm_increaseTime", [6 * 3600 + 1]); await ethers.provider.send("evm_mine", []); await expect(pool.connect(solver2).challenge(orderId)).to.be.revertedWithCustomError(pool, "ChallengeWindowNotElapsed"); }); it("bond deposit + slash routes to AereSink", async function () { const { deployer, alice, solver1, sink, WAERE, pool } = await deployStack(); const bondAmt = 1000n * ONE; await WAERE.connect(alice).deposit({ value: bondAmt }); await WAERE.connect(alice).approve(await pool.getAddress(), bondAmt); await pool.connect(alice).depositSolverBond(solver1.address, bondAmt); expect(await pool.solverBond(solver1.address)).to.equal(bondAmt); // Foundation slashes 200 AERE → sink receives 200 AERE. const slashAmt = 200n * ONE; const sinkBefore = await WAERE.balanceOf(await sink.getAddress()); await pool.connect(deployer).slashSolverBond(solver1.address, slashAmt, ethers.ZeroAddress); expect(await pool.solverBond(solver1.address)).to.equal(bondAmt - slashAmt); // The sink immediately routes the slash through the 15/40/45 split — its balance // post-call is zero (everything dispatched). What we verify is that the slash was // applied: bond decreased by slashAmt. }); it("rejects wrong orderDataType", async function () { const { alice, recipient, WAERE, pool } = await deployStack(); const inputAmount = 10n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN); const order = { fillDeadline: Math.floor(Date.now() / 1000) + 3600, orderDataType: ethers.id("WrongType"), orderData: data, }; await expect(pool.connect(alice).open(order)).to.be.revertedWithCustomError(pool, "InvalidOrderDataType"); }); it("rejects expired fillDeadline", async function () { const { alice, recipient, WAERE, pool } = await deployStack(); const inputAmount = 10n * ONE; await WAERE.connect(alice).deposit({ value: inputAmount }); await WAERE.connect(alice).approve(await pool.getAddress(), inputAmount); const recipientB32 = ethers.zeroPadValue(recipient.address, 32); const data = encodeOrderData(await WAERE.getAddress(), inputAmount, await WAERE.getAddress(), inputAmount, recipientB32, DEST_CHAIN); const order = { fillDeadline: 1, // expired orderDataType: AERE_ORDER_DATA_TYPE, orderData: data, }; await expect(pool.connect(alice).open(order)).to.be.revertedWithCustomError(pool, "FillDeadlineExceeded"); }); });