aere-contracts/test/spokepool-intents-invariant-property.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

816 lines
42 KiB
JavaScript

// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereSpokePool + ERC-7683 cross-chain intent stack.
//
// Source under test (matched to the live addresses in sdk-js/src/addresses.ts):
// contracts/bridge/AereSpokePool.sol -> 0xCAB1DBA5f6F06198000C20a974d675f1B3181AbD
// contracts/bridge/AereERC7683.sol -> 0x67Fb9830e3a2BC06cEb641cfF3beD87b273ccb29
// Messaging layer: the REAL contracts/bridge/AereMessenger.sol (Hyperlane-style
// ECDSA-multisig). We drive settlement end-to-end through it with a real
// validator signature -- NO messenger mock -- so any finding cannot be blamed
// on a toy relayer.
//
// 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
// (campaign, step, actor, action, values) is embedded in the assertion message.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
// npx hardhat test test/spokepool-intents-invariant-property.test.js
// No deploy to any live node, no contract-logic change, one new test file only.
//
// INVARIANTS FUZZED (exactly the brief):
// * NO DOUBLE-SPEND: a deposit/order is fillable at most once; a second fill
// of the same relay reverts; a settled deposit cannot be settled again.
// * REPAYMENT == inputAmount (minus the spread the solver already conceded on
// the output leg); a settlement credits the solver the locked input exactly
// once, never more.
// * REFUND only after fillDeadline AND only if still unresolved (not settled,
// not already refunded).
// * NO repayment without a valid fill (the security boundary of `handle`).
// * CONSERVATION: the pool's token balance always equals the sum of still-open
// deposit inputs plus the sum of unclaimed solver settlements. Funds are
// never minted.
//
// A real invariant violation is a valuable result: the FINDING test at the
// bottom is a deterministic minimal repro, not papered over.
//
// No em-dashes anywhere in this file.
// =============================================================================
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
/* ------------------------------- 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) : 0x5B0CE7;
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 AbiCoder = ethers.AbiCoder.defaultAbiCoder();
const U = (n, d = 6) => BigInt(Math.round(Number(n) * 10 ** 3)) * 10n ** BigInt(d - 3); // USDC-ish 6dp
const b32 = (addr) => ethers.zeroPadValue(addr, 32);
const MAXU = ethers.MaxUint256;
const O_DOMAIN = 2800; // AERE origin
const D_DOMAIN = 8453; // Base-like destination
// Deterministic validator key for the messenger (this account signs inbound
// settlement messages; it never sends a transaction).
const VALIDATOR_PK = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const N = {
CAMPAIGNS: Number(process.env.SP_CAMPAIGNS || 6),
STEPS: Number(process.env.SP_STEPS || 60),
};
async function latestTs() {
return Number((await ethers.provider.getBlock("latest")).timestamp);
}
// =============================================================================
describe("INVARIANT: AereSpokePool + ERC-7683 cross-chain intents", function () {
this.timeout(0);
let signers, messengerF, spokeF, erc20F, settlerF;
let validator;
let msgNonce = 0; // monotonically-increasing inbound nonce so the messenger never self-rejects on replay
before(async function () {
signers = await ethers.getSigners();
messengerF = await ethers.getContractFactory("AereMessenger");
spokeF = await ethers.getContractFactory("contracts/bridge/AereSpokePool.sol:AereSpokePool");
settlerF = await ethers.getContractFactory("AereERC7683");
erc20F = await ethers.getContractFactory("MockERC20Lending");
validator = new ethers.Wallet(VALIDATOR_PK, ethers.provider);
console.log(
` [spoke] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} randomized steps`
);
});
// Two SpokePools facing each other across two domains, each behind a real
// AereMessenger. spokeO (origin) is the one that holds and pays out locked
// input. spokeD (destination) is where solvers deliver output + emit the fill.
async function deployStack() {
const deployer = signers[0];
const messengerO = await messengerF.connect(deployer).deploy(O_DOMAIN);
await messengerO.waitForDeployment();
const messengerD = await messengerF.connect(deployer).deploy(D_DOMAIN);
await messengerD.waitForDeployment();
const spokeO = await spokeF.connect(deployer).deploy(await messengerO.getAddress());
await spokeO.waitForDeployment();
const spokeD = await spokeF.connect(deployer).deploy(await messengerD.getAddress());
await spokeD.waitForDeployment();
const spokeOAddr = await spokeO.getAddress();
const spokeDAddr = await spokeD.getAddress();
// Cross-enroll the two spokes.
await (await spokeO.enrollRemoteSpokePool(D_DOMAIN, b32(spokeDAddr))).wait();
await (await spokeD.enrollRemoteSpokePool(O_DOMAIN, b32(spokeOAddr))).wait();
// The origin messenger accepts settlement messages from our validator.
await (await messengerO.addValidator(validator.address)).wait();
// threshold already 1
// Input token lives on origin; output token lives on destination.
const usdc = await erc20F.deploy("USD Coin", "USDC", 6);
await usdc.waitForDeployment();
const usdd = await erc20F.deploy("Dest USD", "USDd", 6);
await usdd.waitForDeployment();
return {
messengerO, messengerD, spokeO, spokeD, spokeOAddr, spokeDAddr,
usdc, usdd, usdcAddr: await usdc.getAddress(), usddAddr: await usdd.getAddress(),
};
}
async function fund(usdc, usdd, spokeOAddr, spokeDAddr, users, solvers) {
for (const u of users) {
await (await usdc.mint(u.address, U(100_000_000))).wait();
await (await usdc.connect(u).approve(spokeOAddr, MAXU)).wait();
}
for (const s of solvers) {
await (await usdd.mint(s.address, U(100_000_000))).wait();
await (await usdd.connect(s).approve(spokeDAddr, MAXU)).wait();
}
}
// Build the RelayData tuple fillRelay expects.
function relayData(o) {
return [
O_DOMAIN, o.depositId, o.depositor, o.inputToken, o.inputAmount,
o.outputToken, o.outputAmount, o.recipient, o.fillDeadline, o.solverRepaymentAddress,
];
}
// Faithful cross-chain relay of the settlement message that spokeD.fillRelay
// dispatched: reconstruct the exact body, get the validator to sign the
// Hyperlane-style messageId, and process() it on the origin messenger, which
// invokes spokeO.handle. Returns the tx (so callers can assert reverts).
async function relaySettlement(ctx, stack, body, opts = {}) {
const { messengerO, spokeOAddr, spokeDAddr } = stack;
const nonce = opts.nonce !== undefined ? opts.nonce : msgNonce++;
const sender = b32(spokeDAddr);
const recipientB32 = b32(spokeOAddr);
const messageId = ethers.keccak256(
AbiCoder.encode(
["uint32", "uint32", "uint32", "bytes32", "bytes32", "bytes"],
[D_DOMAIN, O_DOMAIN, nonce, sender, recipientB32, body]
)
);
const sig = await validator.signMessage(ethers.getBytes(messageId));
return messengerO.process(sig, D_DOMAIN, sender, spokeOAddr, body, nonce);
}
// The corrected settlement payload commits to the OUTPUT the solver actually
// delivered (outputToken / outputAmount / recipient) alongside the input leg,
// so the origin can verify the fill satisfied the deposit order before it
// releases any locked principal. This mirrors AereSpokePool.fillRelay's
// dispatched body exactly, so relaying it reproduces the real cross-chain path.
function settlementBody(depositId, inputToken, inputAmount, outputToken, outputAmount, recipient, solver, solverRepayment) {
return AbiCoder.encode(
["uint32", "address", "uint256", "address", "uint256", "address", "address", "address"],
[depositId, inputToken, inputAmount, outputToken, outputAmount, recipient, solver, solverRepayment]
);
}
// -------------------------------------------------------------------------
// MAIN FUZZ: honest multi-actor interleaving. Every invariant below must hold
// after every single step.
// -------------------------------------------------------------------------
it("randomized multi-actor: conservation, repayment-exact-once, refund-only-if-unfilled-and-expired", async function () {
let steps = 0, deps = 0, fills = 0, settles = 0, refunds = 0, claims = 0, expired = 0;
for (let c = 0; c < N.CAMPAIGNS; c++) {
const rng = rngFor("main", c);
const stack = await deployStack();
const { spokeO, spokeD, spokeOAddr, usdc, usdcAddr, usddAddr } = stack;
const users = [signers[1], signers[2], signers[3], signers[4]];
const solvers = [signers[5], signers[6], signers[7]];
await fund(usdc, stack.usdd, spokeOAddr, stack.spokeDAddr, users, solvers);
// Oracle: mirror of every deposit's lifecycle + the JS-side settlement ledger.
const dq = new Map(); // depositId -> record
const pending = new Map(); // payoutAddr -> unclaimed bigint (input token)
const addPending = (a, v) => pending.set(a, (pending.get(a) || 0n) + v);
// Master invariant battery, run after every step.
async function assertInvariants(ctx) {
// (1) CONSERVATION: pool balance == open deposit inputs + unclaimed settlements.
let openSum = 0n;
for (const d of dq.values()) if (d.status === "OPEN") openSum += d.inputAmount;
let pendSum = 0n;
for (const [addr, v] of pending) {
pendSum += v;
// cross-check the JS ledger against the on-chain accumulator.
const onchain = await spokeO.pendingSettlement(addr, usdcAddr);
expect(onchain, `[spoke PENDING-XCHK] ${ctx} addr=${addr.slice(0, 8)}`).to.equal(v);
}
const bal = await usdc.balanceOf(spokeOAddr);
expect(bal, `[spoke CONSERVE] balance != openDeposits(${openSum}) + pending(${pendSum}) ${ctx}`)
.to.equal(openSum + pendSum);
// (2) never minted: pool cannot hold more than the input that ever entered.
expect(bal, `[spoke NO-MINT] ${ctx}`).to.be.lte(openSum + pendSum);
// (3) per-deposit repayment-exact-once: a SETTLED deposit credited its
// solver exactly inputAmount, and could only reach SETTLED via a fill.
for (const d of dq.values()) {
if (d.status === "SETTLED") {
expect(d.credited, `[spoke REPAY-EXACT] id=${d.id} credited ${d.credited} != input ${d.inputAmount} ${ctx}`)
.to.equal(d.inputAmount);
expect(d.genuinelyFilled, `[spoke NO-FREE-REPAY] settled id=${d.id} was never filled ${ctx}`).to.equal(true);
}
}
}
await assertInvariants(`c${c} init`);
for (let s = 0; s < N.STEPS; s++) {
const action = pick(rng, [
"deposit", "deposit", "deposit",
"fillAndSettle", "fillAndSettle", "fillAndSettle",
"refund", "refund", "claim", "advance", "advance",
]);
const ctx = `c${c} s${s} action=${action}`;
try {
if (action === "deposit") {
const user = pick(rng, users);
const inAmt = U(ri(rng, 1, 50_000)) + BigInt(ri(rng, 0, 999999));
const spread = U(ri(rng, 0, 200)); // solver's fee = input - output value
const outAmt = inAmt > spread ? inAmt - spread : 1n;
const now = await latestTs();
const life = ri(rng, 120, 5 * 24 * 3600);
const deadline = now + life;
const recipient = pick(rng, users).address;
await (await spokeO.connect(user).deposit(
usdcAddr, inAmt, usddAddr, outAmt, D_DOMAIN, recipient, deadline
)).wait();
const id = Number(await spokeO.depositCounter());
dq.set(id, {
id, depositor: user, inputAmount: inAmt, outputAmount: outAmt,
recipient, deadline, status: "OPEN", genuinelyFilled: false, credited: 0n,
});
deps++;
} else if (action === "fillAndSettle") {
// pick an OPEN deposit still inside its fill window.
const now = await latestTs();
const cands = [...dq.values()].filter((d) => d.status === "OPEN" && d.deadline > now + 2);
if (!cands.length) { steps++; continue; }
const d = pick(rng, cands);
const solver = pick(rng, solvers);
const repayTo = chance(rng, 0.3) ? pick(rng, solvers).address : solver.address;
// HONEST fill: deliver the exact outputAmount to the exact recipient.
const rd = relayData({
depositId: d.id, depositor: d.depositor.address, inputToken: usdcAddr,
inputAmount: d.inputAmount, outputToken: usddAddr, outputAmount: d.outputAmount,
recipient: d.recipient, fillDeadline: d.deadline, solverRepaymentAddress: repayTo,
});
const recvBefore = await stack.usdd.balanceOf(d.recipient);
await (await spokeD.connect(solver).fillRelay(rd)).wait();
const recvAfter = await stack.usdd.balanceOf(d.recipient);
expect(recvAfter - recvBefore, `[spoke FILL-DELIVERS] id=${d.id} ${ctx}`).to.equal(d.outputAmount);
d.genuinelyFilled = true;
fills++;
// double-fill of the identical relay must revert.
await expect(spokeD.connect(solver).fillRelay(rd), `[spoke NO-DOUBLE-FILL] id=${d.id} ${ctx}`)
.to.be.reverted;
// relay the settlement to origin (unless we randomly defer it).
if (chance(rng, 0.85)) {
// The relayed body commits to the output the solver actually
// delivered above (the honest outputToken / outputAmount / recipient).
const body = settlementBody(d.id, usdcAddr, d.inputAmount, usddAddr, d.outputAmount, d.recipient, solver.address, repayTo);
await (await relaySettlement(ctx, stack, body)).wait();
d.status = "SETTLED";
d.credited = d.inputAmount;
addPending(repayTo, d.inputAmount);
settles++;
// replay the SAME settlement (fresh messenger nonce so the messenger
// itself does not reject it): spokeO.handle must reject on `settled`.
const body2 = settlementBody(d.id, usdcAddr, d.inputAmount, usddAddr, d.outputAmount, d.recipient, solver.address, repayTo);
await expect(relaySettlement(ctx, stack, body2), `[spoke NO-DOUBLE-SETTLE] id=${d.id} ${ctx}`)
.to.be.reverted;
}
} else if (action === "refund") {
const now = await latestTs();
const open = [...dq.values()].filter((d) => d.status === "OPEN");
if (!open.length) { steps++; continue; }
const d = pick(rng, open);
if (d.deadline >= now) {
// refund before the deadline must revert (invariant: refund only after expiry).
await expect(spokeO.connect(d.depositor).claimRefund(d.id), `[spoke NO-EARLY-REFUND] id=${d.id} ${ctx}`)
.to.be.reverted;
steps++;
await assertInvariants(ctx);
continue;
}
const before = await usdc.balanceOf(d.depositor.address);
await (await spokeO.connect(d.depositor).claimRefund(d.id)).wait();
const got = (await usdc.balanceOf(d.depositor.address)) - before;
expect(got, `[spoke REFUND-EXACT] id=${d.id} got ${got} != input ${d.inputAmount} ${ctx}`)
.to.equal(d.inputAmount);
d.status = "REFUNDED";
refunds++;
expired++;
} else if (action === "claim") {
// a solver withdraws its accumulated repayment.
const holders = [...pending.entries()].filter(([, v]) => v > 0n);
if (!holders.length) { steps++; continue; }
const [addr, amt] = pick(rng, holders);
const signer = signers.find((s) => s.address === addr);
if (!signer) { steps++; continue; }
const before = await usdc.balanceOf(addr);
await (await spokeO.connect(signer).claimSettlement(usdcAddr)).wait();
const got = (await usdc.balanceOf(addr)) - before;
expect(got, `[spoke CLAIM-EXACT] addr=${addr.slice(0, 8)} got ${got} != pending ${amt} ${ctx}`)
.to.equal(amt);
pending.set(addr, 0n);
claims++;
} else if (action === "advance") {
await time.increase(ri(rng, 60, 3 * 24 * 3600));
await network.provider.send("evm_mine");
}
} catch (e) {
if (/spoke [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break
// otherwise a legitimate revert on an edge action -> state unchanged, invariants still checked.
}
await assertInvariants(ctx);
steps++;
}
// Finale: expire everything, refund every still-open deposit, claim every
// pending settlement, and prove the pool drains to exactly zero.
await time.increase(10 * 24 * 3600);
await network.provider.send("evm_mine");
for (const d of dq.values()) {
if (d.status === "OPEN") {
await (await spokeO.connect(d.depositor).claimRefund(d.id)).wait();
d.status = "REFUNDED";
}
}
for (const [addr, v] of [...pending.entries()]) {
if (v > 0n) {
const signer = signers.find((s) => s.address === addr);
if (signer) {
await (await spokeO.connect(signer).claimSettlement(usdcAddr)).wait();
pending.set(addr, 0n);
}
}
}
await assertInvariants(`c${c} finale`);
const residual = await usdc.balanceOf(spokeOAddr);
let residualPending = 0n;
for (const v of pending.values()) residualPending += v;
expect(residual, `[spoke DRAIN] pool not fully drained c${c}`).to.equal(residualPending);
}
console.log(
` [spoke main] steps=${steps} deposits=${deps} fills=${fills} settles=${settles} ` +
`refunds=${refunds} (expired=${expired}) claims=${claims}`
);
expect(steps).to.be.greaterThan(300);
expect(deps).to.be.greaterThan(40);
expect(fills).to.be.greaterThan(20);
expect(settles).to.be.greaterThan(15);
expect(refunds + claims).to.be.greaterThan(10);
});
// -------------------------------------------------------------------------
// TARGETED GUARDS: deterministic checks on the exact revert paths.
// -------------------------------------------------------------------------
it("targeted guards: wrong caller / wrong sender / expired-fill / settle-vs-refund exclusivity", async function () {
const [_, alice, solver, mallory] = signers;
const stack = await deployStack();
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [alice], [solver]);
const now = await latestTs();
const deadline = now + 3600;
await (await spokeO.connect(alice).deposit(usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, alice.address, deadline)).wait();
const id = Number(await spokeO.depositCounter());
// handle is messenger-gated: a random EOA cannot call it.
await expect(
spokeO.connect(mallory).handle(D_DOMAIN, b32(spokeDAddr), settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), alice.address, solver.address, solver.address)),
"[spoke HANDLE-CALLER] non-messenger accepted"
).to.be.reverted;
// A settlement whose `sender` is NOT the enrolled remote spoke is rejected.
const badBody = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), alice.address, solver.address, solver.address);
const badNonce = msgNonce++;
const badSender = b32(mallory.address);
const badId = ethers.keccak256(
AbiCoder.encode(["uint32", "uint32", "uint32", "bytes32", "bytes32", "bytes"],
[D_DOMAIN, O_DOMAIN, badNonce, badSender, b32(spokeOAddr), badBody])
);
const badSig = await validator.signMessage(ethers.getBytes(badId));
await expect(
stack.messengerO.process(badSig, D_DOMAIN, badSender, spokeOAddr, badBody, badNonce),
"[spoke HANDLE-SENDER] unknown remote spoke accepted"
).to.be.reverted;
// Fill after the deadline reverts (fill window closed).
await time.increase(4000);
await network.provider.send("evm_mine");
const rdLate = relayData({
depositId: id, depositor: alice.address, inputToken: usdcAddr, inputAmount: U(1000),
outputToken: usddAddr, outputAmount: U(999), recipient: alice.address,
fillDeadline: deadline, solverRepaymentAddress: solver.address,
});
await expect(spokeD.connect(solver).fillRelay(rdLate), "[spoke FILL-EXPIRED] late fill accepted").to.be.reverted;
// Refund is gated on fillDeadline + REFUND_BUFFER (the settlement-race fix),
// so advance past the buffer before the depositor can recover an unfilled
// deposit. Now expired + past-buffer + unfilled -> refund works exactly once.
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
await time.increase(REFUND_BUFFER + 10);
await network.provider.send("evm_mine");
await (await spokeO.connect(alice).claimRefund(id)).wait();
await expect(spokeO.connect(alice).claimRefund(id), "[spoke REFUND-ONCE] double refund accepted").to.be.reverted;
// A second, still-live deposit that gets SETTLED can never be refunded.
const now2 = await latestTs();
const dl2 = now2 + 3600;
await (await spokeO.connect(alice).deposit(usdcAddr, U(500), usddAddr, U(499), D_DOMAIN, alice.address, dl2)).wait();
const id2 = Number(await spokeO.depositCounter());
const rd2 = relayData({
depositId: id2, depositor: alice.address, inputToken: usdcAddr, inputAmount: U(500),
outputToken: usddAddr, outputAmount: U(499), recipient: alice.address,
fillDeadline: dl2, solverRepaymentAddress: solver.address,
});
await (await spokeD.connect(solver).fillRelay(rd2)).wait();
await (await relaySettlement("guard-settle", stack, settlementBody(id2, usdcAddr, U(500), usddAddr, U(499), alice.address, solver.address, solver.address))).wait();
await time.increase(4000);
await network.provider.send("evm_mine");
await expect(spokeO.connect(alice).claimRefund(id2), "[spoke NO-REFUND-AFTER-SETTLE] settled deposit refunded").to.be.reverted;
console.log(" [spoke guards] all negative paths reverted as expected");
});
// -------------------------------------------------------------------------
// FIX (HIGH) -- regression proof for the zero-output / mismatched-output theft.
//
// BEFORE THE FIX: settlement authenticated a fill by (depositId, inputToken,
// inputAmount) ONLY. The output leg actually delivered on the destination
// (outputToken / outputAmount / recipient) was never transmitted to nor
// verified by the origin `handle`, and fillRelay did not require
// outputAmount > 0. So any address could read a victim's public deposit,
// fillRelay it with outputAmount = 0 delivering NOTHING, relay the genuine
// validator-signable settlement, mark the deposit `settled`, and drain the
// victim's entire locked principal while permanently blocking their refund.
//
// AFTER THE FIX: the fill commits the delivered output into the settlement
// payload, and the origin releases principal only when that output satisfies
// the stored order (correct token, correct recipient, amount >= requested),
// with outputAmount = 0 rejected outright at fill time. This test proves the
// theft is dead on every variant, that the victim stays refundable, and that
// a GENUINE relayer who actually delivers the requested output is still
// repaid exactly once.
// -------------------------------------------------------------------------
it("FIX (HIGH): no fill can settle without delivering the requested output; victim stays refundable; honest relayer repaid exactly once", async function () {
const [_, victim, attacker, honestRecipient, honestSolver] = signers;
const stack = await deployStack();
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [victim], [attacker, honestSolver]);
// Victim opens an honest order: lock 10,000 USDC, expecting 9,990 USDd out
// to honestRecipient on the destination chain.
const now = await latestTs();
const deadline = now + 24 * 3600;
await (await spokeO.connect(victim).deposit(
usdcAddr, U(10_000), usddAddr, U(9_990), D_DOMAIN, honestRecipient.address, deadline
)).wait();
const id = Number(await spokeO.depositCounter());
expect(await usdc.balanceOf(spokeOAddr), "[spoke FIX-LOCKED] victim principal not locked").to.equal(U(10_000));
// ATTACK 1 -- zero-output fill. Delivers nothing; must be rejected at fill.
const rdZero = relayData({
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
outputToken: usddAddr, outputAmount: 0n, recipient: attacker.address,
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
});
await expect(
spokeD.connect(attacker).fillRelay(rdZero),
"[spoke FIX-ZERO-OUTPUT] zero-output fill was accepted"
).to.be.reverted;
// ATTACK 2 -- deliver a token amount to the WRONG recipient (the attacker),
// then relay the genuine settlement. fillRelay succeeds (a real 1-unit
// transfer) and dispatches a genuine message, but the origin must REJECT the
// settlement because the delivered output does not satisfy the order.
const recipBefore = await usdd.balanceOf(honestRecipient.address);
const rdWrongRecipient = relayData({
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
outputToken: usddAddr, outputAmount: 1n, recipient: attacker.address,
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
});
await (await spokeD.connect(attacker).fillRelay(rdWrongRecipient)).wait();
expect(await usdd.balanceOf(honestRecipient.address), "[spoke FIX-NO-DELIVERY] victim recipient received output")
.to.equal(recipBefore);
const evilBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, 1n, attacker.address, attacker.address, attacker.address);
await expect(
relaySettlement("fix-wrong-recipient", stack, evilBody),
"[spoke FIX-RECIPIENT] mismatched-recipient settlement was accepted"
).to.be.reverted;
// ATTACK 3 -- even delivering the FULL requested amount but to the attacker's
// own address (wrong recipient) must not settle.
const rdFullWrongRecipient = relayData({
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
outputToken: usddAddr, outputAmount: U(9_990), recipient: attacker.address,
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
});
await (await spokeD.connect(attacker).fillRelay(rdFullWrongRecipient)).wait();
const evilBody2 = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), attacker.address, attacker.address, attacker.address);
await expect(
relaySettlement("fix-full-wrong-recipient", stack, evilBody2),
"[spoke FIX-RECIPIENT2] full-amount-wrong-recipient settlement was accepted"
).to.be.reverted;
// ATTACK 4 -- deliver the RIGHT recipient but LESS than requested (short-fill).
const rdShort = relayData({
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
outputToken: usddAddr, outputAmount: U(1), recipient: honestRecipient.address,
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
});
await (await spokeD.connect(attacker).fillRelay(rdShort)).wait();
const shortBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(1), honestRecipient.address, attacker.address, attacker.address);
await expect(
relaySettlement("fix-short-fill", stack, shortBody),
"[spoke FIX-SHORTFILL] under-delivered settlement was accepted"
).to.be.reverted;
// Nothing was credited and the principal is still fully locked.
expect(await spokeO.pendingSettlement(attacker.address, usdcAddr), "[spoke FIX-NO-CREDIT] attacker was credited")
.to.equal(0n);
expect(await usdc.balanceOf(spokeOAddr), "[spoke FIX-STILL-LOCKED] principal released by an invalid fill")
.to.equal(U(10_000));
// GENUINE relayer delivers the requested output to the requested recipient.
// This must settle and repay the honest solver exactly the locked input.
const rBefore = await usdd.balanceOf(honestRecipient.address);
const rdHonest = relayData({
depositId: id, depositor: victim.address, inputToken: usdcAddr, inputAmount: U(10_000),
outputToken: usddAddr, outputAmount: U(9_990), recipient: honestRecipient.address,
fillDeadline: deadline, solverRepaymentAddress: honestSolver.address,
});
await (await spokeD.connect(honestSolver).fillRelay(rdHonest)).wait();
expect((await usdd.balanceOf(honestRecipient.address)) - rBefore, "[spoke FIX-HONEST-DELIVERS] recipient not paid")
.to.equal(U(9_990));
const okBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), honestRecipient.address, honestSolver.address, honestSolver.address);
await (await relaySettlement("fix-honest", stack, okBody)).wait();
expect(await spokeO.pendingSettlement(honestSolver.address, usdcAddr), "[spoke FIX-HONEST-CREDIT] honest solver not credited")
.to.equal(U(10_000));
// Repaid EXACTLY once: replaying the same genuine settlement now reverts.
const okBody2 = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), honestRecipient.address, honestSolver.address, honestSolver.address);
await expect(
relaySettlement("fix-honest-replay", stack, okBody2),
"[spoke FIX-REPAY-ONCE] genuine settlement replayed"
).to.be.reverted;
const hsBefore = await usdc.balanceOf(honestSolver.address);
await (await spokeO.connect(honestSolver).claimSettlement(usdcAddr)).wait();
expect((await usdc.balanceOf(honestSolver.address)) - hsBefore, "[spoke FIX-CLAIM-EXACT] honest solver payout wrong")
.to.equal(U(10_000));
// ---- Refund still works after expiry on a deposit that was only attacked. ----
const now2 = await latestTs();
const dl2 = now2 + 3600;
await (await spokeO.connect(victim).deposit(
usdcAddr, U(5_000), usddAddr, U(4_995), D_DOMAIN, honestRecipient.address, dl2
)).wait();
const id2 = Number(await spokeO.depositCounter());
// Attacker tries the same theft on this deposit and fails at every step.
await expect(
spokeD.connect(attacker).fillRelay(relayData({
depositId: id2, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(5_000),
outputToken: usddAddr, outputAmount: 0n, recipient: attacker.address,
fillDeadline: dl2, solverRepaymentAddress: attacker.address,
})),
"[spoke FIX-ZERO-OUTPUT2] zero-output fill accepted on second deposit"
).to.be.reverted;
// After the deadline AND the refund buffer, with no genuine fill, the victim
// refunds exactly the input (buffer added by the settlement-race fix).
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
await time.increase(3601 + REFUND_BUFFER);
await network.provider.send("evm_mine");
const vBefore = await usdc.balanceOf(victim.address);
await (await spokeO.connect(victim).claimRefund(id2)).wait();
expect((await usdc.balanceOf(victim.address)) - vBefore, "[spoke FIX-REFUND] victim could not recover an unfilled deposit")
.to.equal(U(5_000));
console.log(
" [spoke FIX] zero-output / wrong-recipient / short-fill settlements all reverted; " +
"principal never released without a valid fill; honest solver repaid exactly once; victim refund intact"
);
});
// -------------------------------------------------------------------------
// FIX (MEDIUM) -- theft-of-principal under relay latency (refund-vs-settlement race).
//
// BEFORE THE FIX: claimRefund gated the depositor's refund purely on a local
// timer, `block.timestamp > d.fillDeadline`. A solver may legitimately fill on
// the destination at any t < fillDeadline (fillRelay irreversibly delivers the
// output), while the authenticated settlement that credits the solver is still
// in flight across the messenger (destination finality + validator signing +
// relayer submission = latency L). In the window [fillDeadline, fillDeadline+L]
// the depositor could front-run the in-flight settlement, pull the input back,
// and the later handle() would revert on `refunded` -- the solver who already
// delivered the output is never repaid.
//
// AFTER THE FIX: a refund requires `block.timestamp > fillDeadline +
// REFUND_BUFFER`, where REFUND_BUFFER (3h) generously covers max relay latency,
// so an in-flight settlement for a fill dispatched at/just-before the deadline
// always lands (setting `settled`) before any refund is possible. The three
// tests below encode exactly the brief: (a) fill-just-before-deadline settles
// within the buffer and the depositor cannot refund; (b) a genuinely unfilled
// deposit refunds only after the buffer; (c) a late settlement after a
// legitimate expiry refund cannot double-pay.
// -------------------------------------------------------------------------
it("FIX (MEDIUM a): fill dispatched just before fillDeadline settles within the buffer; depositor cannot front-run the in-flight settlement with a timer refund", async function () {
const [_, depositor, solver, recipient] = signers;
const stack = await deployStack();
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], [solver]);
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
expect(REFUND_BUFFER, "[spoke MED-BUFFER] REFUND_BUFFER must be a positive grace period").to.be.greaterThan(0);
const now = await latestTs();
const deadline = now + 3600;
await (await spokeO.connect(depositor).deposit(
usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, recipient.address, deadline
)).wait();
const id = Number(await spokeO.depositCounter());
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-LOCKED] principal not locked").to.equal(U(1000));
// Solver legitimately fills on the destination JUST BEFORE the deadline.
// This irreversibly delivers the output to the recipient and dispatches the
// authenticated settlement -- which is now "in flight".
await time.increaseTo(deadline - 30);
const rd = relayData({
depositId: id, depositor: depositor.address, inputToken: usdcAddr, inputAmount: U(1000),
outputToken: usddAddr, outputAmount: U(999), recipient: recipient.address,
fillDeadline: deadline, solverRepaymentAddress: solver.address,
});
const rBefore = await usdd.balanceOf(recipient.address);
await (await spokeD.connect(solver).fillRelay(rd)).wait();
expect((await usdd.balanceOf(recipient.address)) - rBefore, "[spoke MED-DELIVERED] solver did not deliver output")
.to.equal(U(999));
// Cross the fillDeadline but stay INSIDE the refund buffer: this is the exact
// relay-latency window the old code let the depositor exploit.
await time.increaseTo(deadline + 60);
await expect(
spokeO.connect(depositor).claimRefund(id),
"[spoke MED-RACE] depositor front-ran the in-flight settlement inside the buffer"
).to.be.reverted;
// The authenticated settlement now lands (still within the buffer). It wins
// the race: the solver is credited exactly the locked input.
const body = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), recipient.address, solver.address, solver.address);
await (await relaySettlement("med-race", stack, body)).wait();
expect(await spokeO.pendingSettlement(solver.address, usdcAddr), "[spoke MED-CREDIT] solver not credited by winning settlement")
.to.equal(U(1000));
// The deposit is now settled: even after the FULL buffer elapses, the
// depositor can never refund (settled/refunded mutual exclusion preserved).
await time.increaseTo(deadline + REFUND_BUFFER + 100);
await expect(
spokeO.connect(depositor).claimRefund(id),
"[spoke MED-NO-REFUND-AFTER-SETTLE] settled deposit refunded after the buffer"
).to.be.reverted;
// Solver withdraws exactly the input; no double-spend of principal occurred.
const sBefore = await usdc.balanceOf(solver.address);
await (await spokeO.connect(solver).claimSettlement(usdcAddr)).wait();
expect((await usdc.balanceOf(solver.address)) - sBefore, "[spoke MED-CLAIM-EXACT] solver payout wrong")
.to.equal(U(1000));
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-DRAIN] pool not drained to zero").to.equal(0n);
console.log(" [spoke MED a] in-flight settlement beat the timer refund; solver repaid; no double-spend");
});
it("FIX (MEDIUM b): a genuinely unfilled deposit is refundable only AFTER fillDeadline + REFUND_BUFFER", async function () {
const [_, depositor, , recipient] = signers;
const stack = await deployStack();
const { spokeO, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], []);
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
const now = await latestTs();
const deadline = now + 3600;
await (await spokeO.connect(depositor).deposit(
usdcAddr, U(2000), usddAddr, U(1990), D_DOMAIN, recipient.address, deadline
)).wait();
const id = Number(await spokeO.depositCounter());
// Before the deadline: refund reverts.
await expect(
spokeO.connect(depositor).claimRefund(id),
"[spoke MED-EARLY] refund allowed before the deadline"
).to.be.reverted;
// Just past the deadline but inside the buffer: still reverts (the race window).
await time.increaseTo(deadline + 10);
await expect(
spokeO.connect(depositor).claimRefund(id),
"[spoke MED-INSIDE-BUFFER] refund allowed inside the buffer"
).to.be.reverted;
// Approaching the buffer boundary from below: still reverts.
await time.increaseTo(deadline + REFUND_BUFFER - 5);
await expect(
spokeO.connect(depositor).claimRefund(id),
"[spoke MED-NEAR-BOUNDARY] refund allowed at/just-before the boundary"
).to.be.reverted;
// Past the full buffer with no fill ever delivered: refund succeeds for
// exactly the input.
await time.increaseTo(deadline + REFUND_BUFFER + 5);
const before = await usdc.balanceOf(depositor.address);
await (await spokeO.connect(depositor).claimRefund(id)).wait();
expect((await usdc.balanceOf(depositor.address)) - before, "[spoke MED-REFUND-EXACT] unfilled refund wrong")
.to.equal(U(2000));
// And only once.
await expect(
spokeO.connect(depositor).claimRefund(id),
"[spoke MED-REFUND-ONCE] double refund accepted"
).to.be.reverted;
console.log(" [spoke MED b] unfilled deposit refundable only after the buffer, exactly once");
});
it("FIX (MEDIUM c): a late settlement arriving after a legitimate expiry refund cannot double-pay", async function () {
const [_, depositor, solver, recipient] = signers;
const stack = await deployStack();
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], [solver]);
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
const now = await latestTs();
const deadline = now + 3600;
await (await spokeO.connect(depositor).deposit(
usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, recipient.address, deadline
)).wait();
const id = Number(await spokeO.depositCounter());
// No fill happens at all. The buffer fully elapses -> depositor legitimately
// refunds the entire input.
await time.increaseTo(deadline + REFUND_BUFFER + 10);
const before = await usdc.balanceOf(depositor.address);
await (await spokeO.connect(depositor).claimRefund(id)).wait();
expect((await usdc.balanceOf(depositor.address)) - before, "[spoke MED-C-REFUND] expiry refund wrong")
.to.equal(U(1000));
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-C-DRAIN] pool should be empty after refund").to.equal(0n);
// A (hypothetical) very-late, otherwise-authentic settlement now arrives.
// handle() MUST reject it on the `refunded` flag: the input is already gone,
// so crediting a solver would double-pay from an underfunded pool.
const body = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), recipient.address, solver.address, solver.address);
await expect(
relaySettlement("med-late-after-refund", stack, body),
"[spoke MED-C-DOUBLE-PAY] settlement credited a solver after the depositor refunded"
).to.be.reverted;
// No credit was recorded and the pool remains empty: no double-spend.
expect(await spokeO.pendingSettlement(solver.address, usdcAddr), "[spoke MED-C-NO-CREDIT] solver credited after refund")
.to.equal(0n);
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-C-STILL-EMPTY] pool balance changed after rejected settlement")
.to.equal(0n);
console.log(" [spoke MED c] late settlement after expiry-refund rejected on `refunded`; no double-pay");
});
});