// ============================================================================= // TDD proof for AereSettlementHubV2 - the CORRECTED fork of the live // AereSettlementHub (canonical 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02). // // Proves BOTH audited defects are closed AND the legit lifecycle is intact: // // F-SWEEP (HIGH griefing) - CLOSED. A permissionless sweepResidual() can only // take the GENUINE residual (balance minus committed = parked + bonds). It // can NEVER push a live intent's parked funds or a posted solver bond into // the sink, so it can never brick settle(). We reproduce the exact V1 attack // (griefer sweeps the full hub balance) and show it now reverts, then that // settle() still pays the solver exactly. // // F-LOCK (liveness) - CLOSED. A never-claimed deposit is refundable to the // depositor via cancelIntent() after REFUND_TIMEOUT. // // CONSERVATION - hubBalance == committedLiabilities (parked + bonds) + genuine // residual, at every step of an interleaved flow. // // LEGIT FLOW - deposit / claim / settle / slash all behave exactly as V1. // // GENUINE RESIDUAL - a fee (or slashed bond) that could not reach the sink IS // sweepable; and only up to the residual bound. // // Run in ISOLATION (full suite OOMs on unrelated PQC KATs): // npx hardhat test test/settlementhub-v2-fix.test.js // // No em-dashes anywhere in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); const MAXU = ethers.MaxUint256; const FEE_BPS = 50n; const BPS = 10_000n; const CHALLENGE_WINDOW = 6 * 60 * 60; const REFUND_TIMEOUT = 7 * 24 * 60 * 60; function feeOf(amount) { const fee = (amount * FEE_BPS) / BPS; return { fee, parked: amount - fee }; } describe("AereSettlementHubV2 - F-SWEEP + F-LOCK fixes", function () { this.timeout(0); let signers, hubF, erc20F, toggleSinkF; before(async function () { signers = await ethers.getSigners(); hubF = await ethers.getContractFactory("AereSettlementHubV2"); erc20F = await ethers.getContractFactory("MockERC20Lending"); // MockSinkSimple always accepts; good enough for the drain/legit tests. toggleSinkF = await ethers.getContractFactory("MockSinkSimple"); }); async function baseStack() { const owner = signers[0]; const sink = await toggleSinkF.deploy(); await sink.waitForDeployment(); const sinkAddr = await sink.getAddress(); const hub = await hubF.connect(owner).deploy(sinkAddr); await hub.waitForDeployment(); const hubAddr = await hub.getAddress(); const T = await erc20F.deploy("Tok", "TK", 18); await T.waitForDeployment(); const tokAddr = await T.getAddress(); return { owner, sink, sinkAddr, hub, hubAddr, T, tokAddr }; } // --------------------------------------------------------------------------- it("F-SWEEP CLOSED: griefer sweep of the full hub balance reverts; settle still pays the solver", async function () { const [owner, depositor, solver, griefer, recip] = signers; const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait(); for (const s of [depositor, solver, griefer]) { await (await T.mint(s.address, 10n ** 24n)).wait(); await (await T.connect(s).approve(hubAddr, MAXU)).wait(); } const amount = 1000n * 10n ** 18n; const { parked } = feeOf(amount); const id = ethers.keccak256(ethers.toUtf8Bytes("sweep-victim")); await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait(); await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, 10n ** 18n)).wait(); await (await hub.connect(solver).claim(id, "0xabcd")).wait(); const heldBefore = await T.balanceOf(hubAddr); expect(heldBefore, "hub holds parked + bond").to.equal(parked + 10n ** 18n); // Committed == parked + bond; residual == 0. expect(await hub.committedLiabilities(tokAddr)).to.equal(parked + 10n ** 18n); expect(await hub.residualOf(tokAddr), "no genuine residual exists").to.equal(0n); // ATTACK: griefer tries to sweep the full hub balance. In V1 this drained // the parked funds + bond into the sink. In V2 it must revert ExceedsResidual. await expect( hub.connect(griefer).sweepResidual(tokAddr, heldBefore) ).to.be.revertedWithCustomError(hub, "ExceedsResidual"); // Even sweeping 1 wei must revert: residual is exactly 0. await expect( hub.connect(griefer).sweepResidual(tokAddr, 1n) ).to.be.revertedWithCustomError(hub, "ExceedsResidual"); // Funds untouched. expect(await T.balanceOf(hubAddr)).to.equal(heldBefore); // Now settle after the window: solver is paid EXACTLY parked (not bricked). await time.increase(CHALLENGE_WINDOW + 60); await network.provider.send("evm_mine"); const solverBefore = await T.balanceOf(solver.address); await (await hub.settle(id)).wait(); expect((await T.balanceOf(solver.address)) - solverBefore, "solver paid exactly parked").to.equal(parked); const on = await hub.intents(id); expect(on.settled).to.equal(true); // After settle, only the bond remains committed; residual still 0. expect(await hub.committedLiabilities(tokAddr)).to.equal(10n ** 18n); expect(await hub.residualOf(tokAddr)).to.equal(0n); // Sanity: sink never received the parked money (only nothing here since sink accepts fees; fee=5e18 went in at deposit). expect(await T.balanceOf(sinkAddr)).to.equal(feeOf(amount).fee); }); // --------------------------------------------------------------------------- it("F-LOCK CLOSED: an unclaimed deposit is refundable to the depositor after REFUND_TIMEOUT", async function () { const [owner, depositor, , , recip] = signers; const { hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); await (await T.mint(depositor.address, 10n ** 24n)).wait(); await (await T.connect(depositor).approve(hubAddr, MAXU)).wait(); const amount = 500n * 10n ** 18n; const { parked } = feeOf(amount); const id = ethers.keccak256(ethers.toUtf8Bytes("lock-victim")); await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait(); expect(await T.balanceOf(hubAddr)).to.equal(parked); // Too early: refund reverts. await expect( hub.connect(depositor).cancelIntent(id) ).to.be.revertedWithCustomError(hub, "RefundTimeoutNotReached"); // Non-depositor cannot refund even after timeout. await time.increase(REFUND_TIMEOUT + 60); await network.provider.send("evm_mine"); await expect( hub.connect(owner).cancelIntent(id) ).to.be.revertedWithCustomError(hub, "NotDepositor"); // Depositor refund succeeds and returns EXACTLY parked. const before = await T.balanceOf(depositor.address); await (await hub.connect(depositor).cancelIntent(id)).wait(); expect((await T.balanceOf(depositor.address)) - before, "depositor refunded parked").to.equal(parked); // Intent is closed; committed drops to 0; hub emptied. expect(await T.balanceOf(hubAddr)).to.equal(0n); expect(await hub.committedLiabilities(tokAddr)).to.equal(0n); const on = await hub.intents(id); expect(on.open).to.equal(false); // Double refund reverts; settle on a closed intent reverts. await expect(hub.connect(depositor).cancelIntent(id)).to.be.revertedWithCustomError(hub, "IntentUnknown"); await expect(hub.settle(id)).to.be.revertedWithCustomError(hub, "IntentUnknown"); }); // --------------------------------------------------------------------------- it("F-LOCK: a CLAIMED intent cannot be cancelled (must settle to the solver)", async function () { const [owner, depositor, solver, , recip] = signers; const { hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait(); for (const s of [depositor, solver]) { await (await T.mint(s.address, 10n ** 24n)).wait(); await (await T.connect(s).approve(hubAddr, MAXU)).wait(); } const amount = 100n * 10n ** 18n; const id = ethers.keccak256(ethers.toUtf8Bytes("claimed-no-cancel")); await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait(); await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, 10n ** 18n)).wait(); await (await hub.connect(solver).claim(id, "0x")).wait(); await time.increase(REFUND_TIMEOUT + 60); await network.provider.send("evm_mine"); await expect( hub.connect(depositor).cancelIntent(id) ).to.be.revertedWithCustomError(hub, "IntentAlreadyClaimed"); }); // --------------------------------------------------------------------------- it("GENUINE RESIDUAL: surplus tokens above committed are sweepable, bounded by residualOf", async function () { // Residual = balance above committed liabilities (e.g. a fee or slashed bond // that could not reach the sink, or a stray transfer). Here we model it as a // stray transfer into the hub: balance > committed => residual sweepable. const [owner, depositor, solver, anyone, recip] = signers; const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait(); for (const s of [depositor, solver, anyone]) { await (await T.mint(s.address, 10n ** 24n)).wait(); await (await T.connect(s).approve(hubAddr, MAXU)).wait(); } // A live intent parks funds (committed). const amount = 200n * 10n ** 18n; const { parked } = feeOf(amount); const id = ethers.keccak256(ethers.toUtf8Bytes("residual-live")); await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait(); // Someone accidentally sends surplus tokens to the hub => genuine residual. const surplus = 7n * 10n ** 18n; await (await T.connect(anyone).transfer(hubAddr, surplus)).wait(); expect(await hub.committedLiabilities(tokAddr)).to.equal(parked); expect(await hub.residualOf(tokAddr), "residual equals the surplus").to.equal(surplus); // Cannot sweep more than residual. await expect( hub.connect(anyone).sweepResidual(tokAddr, surplus + 1n) ).to.be.revertedWithCustomError(hub, "ExceedsResidual"); // Sweeping exactly the residual works and moves it to the sink; parked funds untouched. const sinkBefore = await T.balanceOf(sinkAddr); const hubBefore = await T.balanceOf(hubAddr); await (await hub.connect(anyone).sweepResidual(tokAddr, surplus)).wait(); expect((await T.balanceOf(sinkAddr)) - sinkBefore, "surplus moved to sink").to.equal(surplus); expect(hubBefore - (await T.balanceOf(hubAddr))).to.equal(surplus); // Residual now 0; hub still holds exactly the committed parked funds. expect(await hub.residualOf(tokAddr)).to.equal(0n); expect(await T.balanceOf(hubAddr)).to.equal(parked); expect(await hub.committedLiabilities(tokAddr)).to.equal(parked); }); // --------------------------------------------------------------------------- it("LEGIT FLOW intact: deposit -> bond -> claim -> settle -> slash, with conservation at each step", async function () { const [owner, depositor, solver, , recip] = signers; const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 5n * 10n ** 17n, 0, false)).wait(); await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait(); for (const s of [depositor, solver]) { await (await T.mint(s.address, 10n ** 24n)).wait(); await (await T.connect(s).approve(hubAddr, MAXU)).wait(); } const conserve = async (label) => { const bal = await T.balanceOf(hubAddr); const committed = await hub.committedLiabilities(tokAddr); const residual = await hub.residualOf(tokAddr); expect(bal, `[conserve ${label}] balance == committed + residual`).to.equal(committed + residual); expect(residual, `[conserve ${label}] no unexpected residual`).to.equal(0n); }; await conserve("init"); const amount = 300n * 10n ** 18n; const { fee, parked } = feeOf(amount); const id = ethers.keccak256(ethers.toUtf8Bytes("legit")); await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait(); expect(await T.balanceOf(sinkAddr), "fee routed to sink").to.equal(fee); await conserve("after-deposit"); const bond = 2n * 10n ** 18n; await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait(); await conserve("after-bond"); await (await hub.connect(solver).claim(id, "0xdead")).wait(); await conserve("after-claim"); // early settle reverts. await expect(hub.settle(id)).to.be.revertedWithCustomError(hub, "WithinChallengeWindow"); // challenge in-window emits. await expect(hub.challenge(id, "why")).to.emit(hub, "Challenged"); await time.increase(CHALLENGE_WINDOW + 1); await network.provider.send("evm_mine"); const solverBefore = await T.balanceOf(solver.address); await (await hub.settle(id)).wait(); expect((await T.balanceOf(solver.address)) - solverBefore, "solver paid parked").to.equal(parked); await conserve("after-settle"); // double settle reverts. await expect(hub.settle(id)).to.be.revertedWithCustomError(hub, "IntentAlreadySettled"); // slash the remaining bond -> to sink; committed drops; conservation holds. const sinkBeforeSlash = await T.balanceOf(sinkAddr); await (await hub.connect(owner).slashSolverBond(tokAddr, solver.address, bond)).wait(); expect((await T.balanceOf(sinkAddr)) - sinkBeforeSlash, "slashed bond to sink").to.equal(bond); expect(await hub.committedLiabilities(tokAddr)).to.equal(0n); await conserve("after-slash"); expect(await T.balanceOf(hubAddr), "hub emptied").to.equal(0n); }); // =========================================================================== // F-BOND (HIGH liveness) - CLOSED. V2 previously had NO path returning an // honest solver bond: depositSolverBond() pulled tokens and credited the bond, // but the only function reducing solverBond was slashSolverBond() (owner-only, // routes to the sink, never back to the solver), so a solver's bond was locked // forever. withdrawSolverBond() lets a solver reclaim its FREE bond (the part // not reserved to back an outstanding claimed-but-unsettled intent), in lockstep // with committedLiabilities so the sweep-reserve invariant still holds. // =========================================================================== // --------------------------------------------------------------------------- it("F-BOND CLOSED: a solver withdraws its free bond and receives exactly the tokens; committed decremented (pre-fix impossible)", async function () { const [owner, , solver] = signers; const { hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); await (await T.mint(solver.address, 10n ** 24n)).wait(); await (await T.connect(solver).approve(hubAddr, MAXU)).wait(); const bond = 5n * 10n ** 18n; await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait(); expect(await hub.solverBond(tokAddr, solver.address)).to.equal(bond); expect(await hub.committedLiabilities(tokAddr), "bond is a committed liability").to.equal(bond); expect(await T.balanceOf(hubAddr)).to.equal(bond); // Withdraw the full free bond (no claimed intent reserves any of it). const before = await T.balanceOf(solver.address); await expect(hub.connect(solver).withdrawSolverBond(tokAddr, bond)) .to.emit(hub, "SolverBondWithdrawn").withArgs(tokAddr, solver.address, bond); expect((await T.balanceOf(solver.address)) - before, "solver received exactly the bond back").to.equal(bond); // solverBond and committed both drop to 0 in lockstep; hub emptied. expect(await hub.solverBond(tokAddr, solver.address)).to.equal(0n); expect(await hub.committedLiabilities(tokAddr), "committed decremented in lockstep").to.equal(0n); expect(await hub.residualOf(tokAddr)).to.equal(0n); expect(await T.balanceOf(hubAddr)).to.equal(0n); }); // --------------------------------------------------------------------------- it("F-BOND: cannot withdraw more than free bond, and cannot withdraw another solver's bond", async function () { const [owner, , solverA, solverB] = signers; const { hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); for (const s of [solverA, solverB]) { await (await T.mint(s.address, 10n ** 24n)).wait(); await (await T.connect(s).approve(hubAddr, MAXU)).wait(); } const bondB = 3n * 10n ** 18n; await (await hub.connect(solverB).depositSolverBond(tokAddr, solverB.address, bondB)).wait(); // Over-withdraw of own bond reverts. await expect(hub.connect(solverB).withdrawSolverBond(tokAddr, bondB + 1n)) .to.be.revertedWithCustomError(hub, "InsufficientFreeBond"); // solverA holds zero bond and can never siphon solverB's bond: withdraw only // ever touches msg.sender's own (asset, solver) balance, which is 0 here. await expect(hub.connect(solverA).withdrawSolverBond(tokAddr, bondB)) .to.be.revertedWithCustomError(hub, "InsufficientFreeBond"); await expect(hub.connect(solverA).withdrawSolverBond(tokAddr, 1n)) .to.be.revertedWithCustomError(hub, "InsufficientFreeBond"); // Zero amount reverts. await expect(hub.connect(solverB).withdrawSolverBond(tokAddr, 0n)) .to.be.revertedWithCustomError(hub, "ZeroAmount"); // solverB's bond intact; solverA never received anything. expect(await hub.solverBond(tokAddr, solverB.address)).to.equal(bondB); expect(await hub.solverBond(tokAddr, solverA.address)).to.equal(0n); // A bond posted FOR solverB but funded by a third party is still only // withdrawable by solverB (it is credited to solverB's balance). const extra = 2n * 10n ** 18n; await (await hub.connect(solverA).depositSolverBond(tokAddr, solverB.address, extra)).wait(); await expect(hub.connect(solverA).withdrawSolverBond(tokAddr, extra)) .to.be.revertedWithCustomError(hub, "InsufficientFreeBond"); const beforeB = await T.balanceOf(solverB.address); await (await hub.connect(solverB).withdrawSolverBond(tokAddr, bondB + extra)).wait(); expect((await T.balanceOf(solverB.address)) - beforeB, "solverB reclaims its full credited bond").to.equal(bondB + extra); expect(await hub.committedLiabilities(tokAddr)).to.equal(0n); }); // --------------------------------------------------------------------------- it("F-BOND: a solver cannot withdraw bond backing an unresolved claimed intent (no slash-dodge); the locked portion stays slashable", async function () { const [owner, depositor, solver, , recip] = signers; const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack(); const minBond = 10n ** 18n; await (await hub.connect(owner).listAsset(tokAddr, 18, minBond, 0, false)).wait(); await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait(); for (const s of [depositor, solver]) { await (await T.mint(s.address, 10n ** 24n)).wait(); await (await T.connect(s).approve(hubAddr, MAXU)).wait(); } const amount = 100n * 10n ** 18n; const { parked } = feeOf(amount); const id = ethers.keccak256(ethers.toUtf8Bytes("bond-lock-guard")); await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait(); // Post 2x minBond, then claim -> exactly minBond becomes RESERVED (locked), // the other minBond stays free. const bond = 2n * minBond; await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait(); await (await hub.connect(solver).claim(id, "0xabcd")).wait(); expect(await hub.lockedBond(tokAddr, solver.address), "minBond reserved at claim").to.equal(minBond); // Cannot pull the whole bond: that would strip the claimed intent's backing // and let the solver dodge a slash. Only the FREE minBond is withdrawable. await expect(hub.connect(solver).withdrawSolverBond(tokAddr, bond)) .to.be.revertedWithCustomError(hub, "InsufficientFreeBond"); const before = await T.balanceOf(solver.address); await (await hub.connect(solver).withdrawSolverBond(tokAddr, minBond)).wait(); expect((await T.balanceOf(solver.address)) - before, "only free minBond withdrawable").to.equal(minBond); // Nothing free now; even 1 wei reverts. The reserved minBond stays put. await expect(hub.connect(solver).withdrawSolverBond(tokAddr, 1n)) .to.be.revertedWithCustomError(hub, "InsufficientFreeBond"); expect(await hub.solverBond(tokAddr, solver.address)).to.equal(minBond); expect(await hub.lockedBond(tokAddr, solver.address)).to.equal(minBond); // The reserved bond is still fully slashable by the owner (dodge prevented). const sinkBefore = await T.balanceOf(sinkAddr); await (await hub.connect(owner).slashSolverBond(tokAddr, solver.address, minBond)).wait(); expect((await T.balanceOf(sinkAddr)) - sinkBefore, "reserved bond slashed to sink").to.equal(minBond); expect(await hub.solverBond(tokAddr, solver.address)).to.equal(0n); // Settling later still pays the depositor's parked funds to the solver and // releases the (now-empty) reservation without underflow. await time.increase(CHALLENGE_WINDOW + 60); await network.provider.send("evm_mine"); const sBefore = await T.balanceOf(solver.address); await (await hub.settle(id)).wait(); expect((await T.balanceOf(solver.address)) - sBefore, "solver still paid parked at settle").to.equal(parked); expect(await hub.lockedBond(tokAddr, solver.address), "reservation released at settle").to.equal(0n); expect(await hub.committedLiabilities(tokAddr)).to.equal(0n); }); // --------------------------------------------------------------------------- it("F-BOND: sweepResidual still cannot touch a posted bond, including the remainder after a partial withdraw", async function () { const [owner, , solver, anyone] = signers; const { hub, hubAddr, T, tokAddr } = await baseStack(); await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait(); await (await T.mint(solver.address, 10n ** 24n)).wait(); await (await T.connect(solver).approve(hubAddr, MAXU)).wait(); const bond = 5n * 10n ** 18n; await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait(); // A posted bond is committed => zero residual => sweep of any size reverts. expect(await hub.residualOf(tokAddr)).to.equal(0n); await expect(hub.connect(anyone).sweepResidual(tokAddr, bond)) .to.be.revertedWithCustomError(hub, "ExceedsResidual"); // Partial withdraw; the remainder is STILL fully reserved (residual stays 0). await (await hub.connect(solver).withdrawSolverBond(tokAddr, 2n * 10n ** 18n)).wait(); const remaining = bond - 2n * 10n ** 18n; expect(await hub.solverBond(tokAddr, solver.address)).to.equal(remaining); expect(await hub.committedLiabilities(tokAddr)).to.equal(remaining); expect(await hub.residualOf(tokAddr), "remaining bond is still reserved").to.equal(0n); await expect(hub.connect(anyone).sweepResidual(tokAddr, remaining)) .to.be.revertedWithCustomError(hub, "ExceedsResidual"); await expect(hub.connect(anyone).sweepResidual(tokAddr, 1n)) .to.be.revertedWithCustomError(hub, "ExceedsResidual"); // Hub holds exactly the remaining reserved bond, untouched by the sweep. expect(await T.balanceOf(hubAddr)).to.equal(remaining); }); });