aere-contracts/test/spoke-pool.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

396 lines
20 KiB
JavaScript

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