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