// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereSettlementHub (canonical mainnet 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02
// per sdk-js/src/addresses.ts).
//
// Source under test: contracts/settlement/AereSettlementHub.sol — an
// institutional cross-asset settlement venue: counterparties deposit()
// tokenised collateral under an intentId (a 50bps immutable protocol fee is
// routed to AereSink, the remainder parked), an allowlisted+bonded solver
// claim()s, and after a 6h challenge window anyone settle()s to release the
// parked remainder to the solver. Solvers post per-(asset,solver) bonds; the
// owner may slashSolverBond(); anyone may sweepResidual().
//
// TOOLCHAIN: hardhat-based 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
// for a minimal repro.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/settlementhub-netting-invariant-property.test.js
// No deploy to any live node, no contract-logic change, one new test file only.
//
// INVARIANTS FUZZED (exactly the AereSettlementHub brief):
//   * NETTING CONSERVATION: every deposit splits amount = fee + parkedRemainder;
//     the fee leaves to the sink and the remainder is parked under the intent.
//     Per asset, at every step:
//        hubBalance == sum(parked amount of every unsettled intent)
//                    + sum(solverBond over all solvers)
//     i.e. the hub holds exactly the sum of what it owes solvers plus posted
//     bonds — no value is minted and nothing extra can be extracted.
//   * DOUBLE-SETTLE: a settled intent can never be settled again (reverts
//     IntentAlreadySettled); the solver is paid its parked amount exactly once.
//   * GHOST / UNCLAIMED SETTLE: a non-existent intentId, an unclaimed intent, or
//     an intent still inside its challenge window can never settle.
//   * PAYOUT EXACTNESS: settle() transfers EXACTLY intent.amount to the recorded
//     solver — never more (no over-payment), and the depositor never gets a
//     refund path (there is none), so no party extracts more than owed.
//   * FEE ACCOUNTING: cumulative fees observed == sink balance == sum over
//     intents of (grossDeposited - parkedAmount).
//   * SUPPLY RECONCILIATION: total tokens ever minted to actors are conserved:
//     depositorBalances + hubBalance + sinkBalance + solverBalances == mintedTotal
//     for each asset, across interleaved deposit/claim/settle/bond/slash.
//
// Plus two deterministic FINDINGS at the bottom (minimal repros), NOT papered
// over: (F-SWEEP) permissionless unbounded sweepResidual() drains parked
// settlement funds + bonds to the sink, and (F-LOCK) an unclaimed deposit has
// no refund path and is locked permanently.
//
// 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) : 0x5E77_1E00;
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;

/* ------------------------------- constants -------------------------------- */
const FEE_BPS = 50n;
const BPS = 10_000n;
const CHALLENGE_WINDOW = 6 * 60 * 60; // 6 hours in seconds
const MAXU = ethers.MaxUint256;

async function latestTs() {
  return Number((await ethers.provider.getBlock("latest")).timestamp);
}

// Iteration counts (env-overridable so a quick smoke can shrink them).
const N = {
  CAMPAIGNS: Number(process.env.HUB_CAMPAIGNS || 6),
  STEPS: Number(process.env.HUB_STEPS || 90),
};

function feeOf(amount) {
  const f = (amount * FEE_BPS) / BPS; // floor
  return { fee: f, parked: amount - f };
}

// =============================================================================
describe("INVARIANT: AereSettlementHub netting conservation + settle lifecycle", function () {
  this.timeout(0);

  let signers, hubF, sinkF, erc20F;

  before(async function () {
    signers = await ethers.getSigners();
    hubF = await ethers.getContractFactory("AereSettlementHub");
    sinkF = await ethers.getContractFactory("MockSinkSimple");
    erc20F = await ethers.getContractFactory("MockERC20Lending");
    console.log(
      `      [hub] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} interleaved multi-actor`
    );
  });

  // Fresh sink + hub + a small basket of assets with varied decimals / caps /
  // restriction. Owner = signers[0].
  async function deployStack(rng) {
    const owner = signers[0];
    const sink = await sinkF.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();

    // Three assets: A = 18dec unrestricted no-cap high-bond,
    //               B = 6dec  unrestricted capped low-bond,
    //               C = 8dec  RESTRICTED-recipient no-cap mid-bond.
    const A = await erc20F.deploy("AssetA", "AA", 18);
    const B = await erc20F.deploy("AssetB", "BB", 6);
    const C = await erc20F.deploy("AssetC", "CC", 8);
    for (const t of [A, B, C]) await t.waitForDeployment();

    const assets = [
      { tok: A, addr: await A.getAddress(), dec: 18n, cap: 0n, minBond: 10n ** 21n, restricted: false },
      { tok: B, addr: await B.getAddress(), dec: 6n, cap: 5n * 10n ** 12n, minBond: 10n ** 6n, restricted: false },
      { tok: C, addr: await C.getAddress(), dec: 8n, cap: 0n, minBond: 5n * 10n ** 8n, restricted: true },
    ];

    for (const a of assets) {
      await (await hub.connect(owner).listAsset(a.addr, Number(a.dec), a.minBond, a.cap, a.restricted)).wait();
    }
    return { sink, sinkAddr, hub, hubAddr, assets, A, B, C };
  }

  // Depositors, solvers, recipients (allowlisted for restricted asset C).
  function rolesFrom(signers) {
    return {
      depositors: [signers[1], signers[2], signers[3], signers[4]],
      solvers: [signers[5], signers[6], signers[7]],
      recipients: [signers[10], signers[11]], // allowlisted for restricted asset
      unrestrictedRecipients: [signers[12], signers[13], signers[14]],
    };
  }

  async function fund(hub, hubAddr, assets, roles) {
    const owner = signers[0];
    const everyone = [...roles.depositors, ...roles.solvers];
    for (const a of assets) {
      // mint a big float to every actor and approve the hub for deposits + bonds.
      for (const s of everyone) {
        await (await a.tok.mint(s.address, a.dec >= 18n ? 10n ** 30n : 10n ** 24n)).wait();
        await (await a.tok.connect(s).approve(hubAddr, MAXU)).wait();
      }
      // allowlist solvers for this asset.
      for (const sv of roles.solvers) {
        await (await hub.connect(owner).setSolverAllowed(a.addr, sv.address, true)).wait();
      }
      // for the restricted asset, allow the restricted-recipient set only.
      if (a.restricted) {
        for (const r of roles.recipients) {
          await (await hub.connect(owner).setRecipientAllowed(a.addr, r.address, true)).wait();
        }
      }
    }
  }

  // The core invariant battery over the whole basket. `book` is our JS mirror:
  //   book.intents: id -> {assetAddr, gross, parked, settled, solver, claimedAt}
  //   book.ids: [id...]
  //   book.mint[assetAddr]: cumulative minted to actors (for supply reconcile)
  async function assertInvariants(ctx, hub, hubAddr, sinkAddr, assets, roles, book) {
    for (const a of assets) {
      const bal = await a.tok.balanceOf(hubAddr);

      // parked = sum of unsettled intent amounts on this asset (read from CHAIN).
      let parked = 0n;
      let feeSum = 0n;
      for (const id of book.ids) {
        const rec = book.intents[id];
        if (rec.assetAddr !== a.addr) continue;
        feeSum += rec.gross - rec.parked;
        const on = await hub.intents(id); // [depositor,asset,amount,recipient,open,settled,solver,claimedAt]
        // cross-check chain vs mirror.
        expect(on.settled, `[hub MIRROR-settled] ${ctx} id=${id}`).to.equal(rec.settled);
        expect(on.amount, `[hub MIRROR-amount] ${ctx} id=${id}`).to.equal(rec.parked);
        if (!on.settled) parked += on.amount;
      }

      // bonds = sum of solverBond over all solvers on this asset (read from CHAIN).
      let bonds = 0n;
      for (const sv of roles.solvers) bonds += await hub.solverBond(a.addr, sv.address);

      // ---- NETTING CONSERVATION: hub holds exactly parked + bonds. ----
      expect(bal, `[hub CONSERVE] balance != parked(${parked}) + bonds(${bonds}) ${ctx} asset=${a.addr}`)
        .to.equal(parked + bonds);

      // ---- FEE ACCOUNTING: sink balance for this asset == cumulative fees. ----
      const sinkBal = await a.tok.balanceOf(sinkAddr);
      expect(sinkBal, `[hub FEE-ACCT] sink balance != cumulative fees ${ctx} asset=${a.addr}`)
        .to.equal(feeSum + book.slashed[a.addr]);

      // ---- SUPPLY RECONCILIATION: nothing minted, nothing burned. ----
      let held = bal + sinkBal;
      for (const s of [...roles.depositors, ...roles.solvers]) held += await a.tok.balanceOf(s.address);
      expect(held, `[hub SUPPLY] token supply not conserved ${ctx} asset=${a.addr}`).to.equal(book.mint[a.addr]);
    }
  }

  // -------------------------------------------------------------------------
  it("interleaved deposit/claim/settle/bond/slash: conservation, exact payout, no double-settle", async function () {
    let steps = 0, deposits = 0, bonds = 0, claims = 0, settles = 0, slashes = 0,
        dblGuard = 0, ghostGuard = 0, earlyGuard = 0, unclaimedGuard = 0;

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("hub", c);
      const { sink, sinkAddr, hub, hubAddr, assets } = await deployStack(rng);
      const roles = rolesFrom(signers);
      await fund(hub, hubAddr, assets, roles);

      // JS mirror / bookkeeping.
      const book = {
        ids: [],
        intents: {},
        mint: {},     // assetAddr -> total minted to actors
        slashed: {},  // assetAddr -> cumulative slashed-to-sink (adds to sink balance)
      };
      for (const a of assets) {
        book.mint[a.addr] = (a.dec >= 18n ? 10n ** 30n : 10n ** 24n) * BigInt(roles.depositors.length + roles.solvers.length);
        book.slashed[a.addr] = 0n;
      }

      let idNonce = 0;
      const freshId = () =>
        ethers.keccak256(ethers.toUtf8Bytes(`c${c}:${idNonce++}:${BASE_SEED}`));

      await assertInvariants(`c${c} init`, hub, hubAddr, sinkAddr, assets, roles, book);

      for (let s = 0; s < N.STEPS; s++) {
        const action = pick(rng, [
          "deposit", "deposit", "deposit", "bond", "bond",
          "claim", "claim", "settle", "settle", "slash", "advance", "challenge",
        ]);
        const ctx = `c${c} s${s} action=${action}`;

        try {
          if (action === "deposit") {
            const a = pick(rng, assets);
            const depositor = pick(rng, roles.depositors);
            // amount: keep under cap where present; vary scale by decimals.
            let amount;
            const unit = 10n ** a.dec;
            if (chance(rng, 0.2)) amount = BigInt(ri(rng, 1, 500)); // tiny (stress fee=0 floor)
            else amount = BigInt(ri(rng, 1, 100000)) * unit / BigInt(ri(rng, 1, 97));
            if (amount === 0n) amount = 1n;
            if (a.cap !== 0n && amount > a.cap) amount = a.cap;

            const recipient = a.restricted
              ? pick(rng, roles.recipients).address
              : pick(rng, roles.unrestrictedRecipients).address;

            const id = freshId();
            const { fee, parked } = feeOf(amount);
            const balBefore = await a.tok.balanceOf(hubAddr);
            await (await hub.connect(depositor).deposit(a.addr, amount, id, recipient)).wait();
            const balAfter = await a.tok.balanceOf(hubAddr);

            // hub balance grew by EXACTLY the parked remainder (fee left to sink).
            expect(balAfter - balBefore, `[hub DEP-DELTA] parked mismatch ${ctx} amount=${amount}`).to.equal(parked);
            const on = await hub.intents(id);
            expect(on.amount, `[hub DEP-AMOUNT] stored != parked ${ctx}`).to.equal(parked);
            expect(on.depositor, `[hub DEP-WHO] ${ctx}`).to.equal(depositor.address);

            book.ids.push(id);
            book.intents[id] = { assetAddr: a.addr, gross: amount, parked, settled: false, solver: null, claimedAt: 0 };
            deposits++;
          } else if (action === "bond") {
            const a = pick(rng, assets);
            const sv = pick(rng, roles.solvers);
            const amount = BigInt(ri(rng, 1, 100)) * (10n ** a.dec);
            await (await hub.connect(sv).depositSolverBond(a.addr, sv.address, amount)).wait();
            bonds++;
          } else if (action === "claim") {
            // pick an unsettled, unclaimed intent; use an allowlisted solver with enough bond.
            const open = book.ids.filter((id) => {
              const r = book.intents[id];
              return !r.settled && r.claimedAt === 0;
            });
            if (open.length === 0) { steps++; continue; }
            const id = pick(rng, open);
            const rec = book.intents[id];
            const a = assets.find((x) => x.addr === rec.assetAddr);
            // find a solver whose bond >= minBond; if none, top one up first.
            let chosen = null;
            for (const sv of roles.solvers) {
              const b = await hub.solverBond(a.addr, sv.address);
              if (b >= a.minBond) { chosen = sv; break; }
            }
            if (!chosen) {
              chosen = pick(rng, roles.solvers);
              const need = a.minBond;
              await (await hub.connect(chosen).depositSolverBond(a.addr, chosen.address, need)).wait();
            }
            await (await hub.connect(chosen).claim(id, "0x1234")).wait();
            const on = await hub.intents(id);
            rec.claimedAt = Number(on.claimedAt);
            rec.solver = chosen.address;
            claims++;

            // GUARD: claiming an already-claimed intent must revert.
            if (chance(rng, 0.5)) {
              let reverted = false;
              try { await hub.connect(chosen).claim(id, "0x"); } catch { reverted = true; }
              expect(reverted, `[hub GUARD dbl-claim did not revert] ${ctx} id=${id}`).to.equal(true);
            }
          } else if (action === "settle") {
            const now = await latestTs();
            const ready = book.ids.filter((id) => {
              const r = book.intents[id];
              return !r.settled && r.claimedAt !== 0 && now >= r.claimedAt + CHALLENGE_WINDOW;
            });
            if (ready.length === 0) {
              // Opportunistically prove that a still-in-window claimed intent CANNOT settle.
              const early = book.ids.filter((id) => {
                const r = book.intents[id];
                return !r.settled && r.claimedAt !== 0 && now < r.claimedAt + CHALLENGE_WINDOW;
              });
              if (early.length > 0) {
                const id = pick(rng, early);
                let reverted = false;
                try { await hub.settle(id); } catch { reverted = true; }
                expect(reverted, `[hub GUARD early-settle did not revert] ${ctx} id=${id}`).to.equal(true);
                earlyGuard++;
              }
              steps++; continue;
            }
            const id = pick(rng, ready);
            const rec = book.intents[id];
            const a = assets.find((x) => x.addr === rec.assetAddr);
            const solverBalBefore = await a.tok.balanceOf(rec.solver);
            const hubBalBefore = await a.tok.balanceOf(hubAddr);
            await (await hub.connect(pick(rng, roles.depositors)).settle(id)).wait();
            const solverBalAfter = await a.tok.balanceOf(rec.solver);
            const hubBalAfter = await a.tok.balanceOf(hubAddr);

            // PAYOUT EXACTNESS: solver received EXACTLY parked; hub dropped EXACTLY parked.
            expect(solverBalAfter - solverBalBefore, `[hub PAYOUT] solver got != parked ${ctx} id=${id}`).to.equal(rec.parked);
            expect(hubBalBefore - hubBalAfter, `[hub PAYOUT-HUB] hub dropped != parked ${ctx} id=${id}`).to.equal(rec.parked);

            rec.settled = true;
            settles++;

            // GUARD: settling the same intent again must revert (no double payout).
            let reverted = false;
            try { await hub.settle(id); } catch { reverted = true; }
            expect(reverted, `[hub GUARD double-settle did not revert] ${ctx} id=${id}`).to.equal(true);
            dblGuard++;
          } else if (action === "slash") {
            const a = pick(rng, assets);
            const sv = pick(rng, roles.solvers);
            const cur = await hub.solverBond(a.addr, sv.address);
            if (cur === 0n) { steps++; continue; }
            const amt = cur === 1n ? 1n : BigInt(ri(rng, 1, Number(cur > 1_000_000n ? 1_000_000n : cur))) * cur / (cur > 1_000_000n ? 1_000_000n : cur);
            const slashAmt = amt === 0n ? 1n : (amt > cur ? cur : amt);
            await (await hub.connect(signers[0]).slashSolverBond(a.addr, sv.address, slashAmt)).wait();
            book.slashed[a.addr] += slashAmt; // slashed funds land in the sink
            slashes++;
          } else if (action === "advance") {
            await time.increase(ri(rng, 60, CHALLENGE_WINDOW + 2 * 3600)); // sometimes past the window
            await network.provider.send("evm_mine");
          } else if (action === "challenge") {
            // challenge only emits an event on a claimed, in-window intent. Exercise it.
            const now = await latestTs();
            const inWin = book.ids.filter((id) => {
              const r = book.intents[id];
              return !r.settled && r.claimedAt !== 0 && now < r.claimedAt + CHALLENGE_WINDOW;
            });
            if (inWin.length > 0) {
              await (await hub.challenge(pick(rng, inWin), "fuzz-reason")).wait();
            }
          }
        } catch (e) {
          if (/hub [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break
          // Legal reverts on edge inputs (rounding to fee=0, cap clamps, etc.).
        }

        // Occasional GHOST-settle guard: a random never-created id must not settle.
        if (chance(rng, 0.15)) {
          const ghost = ethers.keccak256(ethers.toUtf8Bytes(`ghost:${c}:${s}:${idNonce}`));
          let reverted = false;
          try { await hub.settle(ghost); } catch { reverted = true; }
          expect(reverted, `[hub GUARD ghost-settle did not revert] ${ctx}`).to.equal(true);
          ghostGuard++;
        }

        // Occasional UNCLAIMED-settle guard: a deposited-but-never-claimed intent must not settle.
        if (chance(rng, 0.15)) {
          const unclaimed = book.ids.filter((id) => {
            const r = book.intents[id];
            return !r.settled && r.claimedAt === 0;
          });
          if (unclaimed.length > 0) {
            let reverted = false;
            try { await hub.settle(pick(rng, unclaimed)); } catch { reverted = true; }
            expect(reverted, `[hub GUARD unclaimed-settle did not revert] ${ctx}`).to.equal(true);
            unclaimedGuard++;
          }
        }

        await assertInvariants(ctx, hub, hubAddr, sinkAddr, assets, roles, book);
        steps++;
      }

      // ---- finale: advance well past every window and settle everything claimed. ----
      await time.increase(CHALLENGE_WINDOW + 8 * 3600);
      await network.provider.send("evm_mine");
      for (const id of book.ids) {
        const rec = book.intents[id];
        if (!rec.settled && rec.claimedAt !== 0) {
          const a = assets.find((x) => x.addr === rec.assetAddr);
          const before = await a.tok.balanceOf(rec.solver);
          await (await hub.settle(id)).wait();
          expect((await a.tok.balanceOf(rec.solver)) - before, `[hub FINALE-PAYOUT] c${c} id=${id}`).to.equal(rec.parked);
          rec.settled = true;
        }
      }
      await assertInvariants(`c${c} finale`, hub, hubAddr, sinkAddr, assets, roles, book);
    }

    console.log(
      `      [hub] steps=${steps} dep=${deposits} bond=${bonds} claim=${claims} settle=${settles} ` +
        `slash=${slashes} | guards: dbl=${dblGuard} ghost=${ghostGuard} early=${earlyGuard} unclaimed=${unclaimedGuard}`
    );
    expect(steps).to.be.greaterThan(400);
    expect(deposits).to.be.greaterThan(60);
    expect(settles).to.be.greaterThan(10);
    expect(dblGuard + ghostGuard).to.be.greaterThan(0);
  });

  // -------------------------------------------------------------------------
  // FINDING (F-SWEEP): sweepResidual() is permissionless AND unbounded. Its
  // NatSpec intent is to re-flush ONLY residual fees that could not reach the
  // sink on their first attempt. But it accepts an ARBITRARY `amount` from ANY
  // caller and simply approves+flushes it to the sink. There is no accounting of
  // what is actually "residual" versus committed. When the sink accepts (the
  // normal case), a griefer can sweep the parked settlement funds (money owed to
  // a solver for a real, claimed intent) and/or posted solver bonds straight into
  // the sink. The hub becomes insolvent: the honest settle() then reverts
  // TransferFailed and the depositor's funds are gone (burned into the sink),
  // never delivered. Reachable by any EOA on mainnet.
  it("FINDING (F-SWEEP): permissionless unbounded sweepResidual drains parked funds + bonds, bricks settle", async function () {
    const [owner, depositor, solver, griefer, recip] = signers;
    const { sink, sinkAddr, hub, hubAddr } = await deployStack();

    // one unrestricted 18-dec asset, no cap, minBond = 1e18.
    const T = await erc20F.deploy("Tok", "TK", 18);
    await T.waitForDeployment();
    const tokAddr = await T.getAddress();
    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();
    }

    // A real settlement in flight: depositor deposits 1000e18, solver bonds + claims.
    const amount = 1000n * 10n ** 18n;
    const { fee, 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();

    // Hub now holds parked (995e18) + bond (1e18). The fee (5e18) already left to the sink.
    const heldBefore = await T.balanceOf(hubAddr);
    expect(heldBefore, "[F-SWEEP precheck] hub holdings").to.equal(parked + 10n ** 18n);
    const sinkBefore = await T.balanceOf(sinkAddr);

    // GRIEF: anyone calls sweepResidual for the FULL hub balance. Nothing was
    // actually residual (the sink already took the fee), yet it flushes the
    // solver's parked settlement money + the bond into the sink.
    await (await hub.connect(griefer).sweepResidual(tokAddr, heldBefore)).wait();

    const heldAfter = await T.balanceOf(hubAddr);
    const sinkAfter = await T.balanceOf(sinkAddr);
    expect(heldAfter, "[F-SWEEP] hub not drained").to.equal(0n);
    expect(sinkAfter - sinkBefore, "[F-SWEEP] funds did not move to sink").to.equal(heldBefore);

    // The intent is still live and claimed; advance past the window and settle.
    // It now reverts because the hub has no tokens left: the depositor's money is
    // gone (burned to the sink) and the solver is never paid.
    await time.increase(CHALLENGE_WINDOW + 60);
    await network.provider.send("evm_mine");
    let settleReverted = false;
    try { await hub.settle(id); } catch { settleReverted = true; }
    expect(settleReverted, "[F-SWEEP] settle unexpectedly succeeded after drain").to.equal(true);

    // The intent is permanently unsettleable (funds neither with solver nor refundable).
    const on = await hub.intents(id);
    expect(on.settled, "[F-SWEEP] intent should remain unsettled/stuck").to.equal(false);

    console.log(
      `      [hub F-SWEEP] drained=${ethers.formatEther(heldBefore)} tokens to sink; ` +
        `settle now reverts -> depositor funds burned, solver unpaid, intent bricked.`
    );
  });

  // -------------------------------------------------------------------------
  // FINDING (F-LOCK): there is NO refund / cancel / timeout path for a deposited
  // intent that no solver ever claims. deposit() pulls the funds and parks them;
  // the ONLY exit is settle(), which requires a prior claim() by an allowlisted
  // bonded solver. If no solver ever claims (asset paused, solver set removed,
  // or simply nobody bothers), the depositor's parked funds are locked forever.
  // Not a value mint, but a permanent-lock / liveness defect worth recording.
  it("FINDING (F-LOCK): an unclaimed deposit has no refund path and is locked permanently", async function () {
    const [owner, depositor, , , recip] = signers;
    const { hub, hubAddr } = await deployStack();

    const T = await erc20F.deploy("Tok", "TK", 18);
    await T.waitForDeployment();
    const tokAddr = await T.getAddress();
    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"));
    const balBefore = await T.balanceOf(depositor.address);
    await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();

    // Depositor is out `amount`; hub holds `parked`. No solver claims (ever).
    expect(balBefore - (await T.balanceOf(depositor.address)), "[F-LOCK] depositor debit").to.equal(amount);

    // Time passes arbitrarily. There is no cancel()/refund()/withdraw() to call.
    await time.increase(365 * 24 * 3600);
    await network.provider.send("evm_mine");

    // settle() cannot run (never claimed): the funds are stuck in the hub.
    let reverted = false;
    try { await hub.settle(id); } catch { reverted = true; }
    expect(reverted, "[F-LOCK] settle should revert on an unclaimed intent").to.equal(true);
    expect(await T.balanceOf(hubAddr), "[F-LOCK] parked funds still trapped in hub").to.equal(parked);

    // Confirm the contract truly exposes no depositor-side exit (surface check).
    const fns = hub.interface.fragments.filter((f) => f.type === "function").map((f) => f.name);
    for (const forbidden of ["cancel", "refund", "reclaim", "withdrawIntent"]) {
      expect(fns.includes(forbidden), `[F-LOCK] unexpected refund fn ${forbidden}`).to.equal(false);
    }

    console.log(
      `      [hub F-LOCK] deposited ${ethers.formatEther(amount)}, ${ethers.formatEther(parked)} parked; ` +
        `no claim ever -> no refund path exists, funds locked permanently.`
    );
  });
});
