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