aere-contracts/test/settlementhub-v2-fix.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

476 lines
24 KiB
JavaScript

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