// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT test for AereLendingMarket
// (isolated single-pair money market, immutable LTV) + its oracle adapter.
//
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry — `forge`
// is not installed here and the repo builds through hardhat with OZ 4.9.6 +
// viaIR + a cancun hardfork override). Per the task we drive long random
// sequences of multi-actor actions with varied amounts / timing / prices via a
// SEEDED mulberry32 PRNG (reproducible) and assert the market invariants AFTER
// EVERY STEP. Every random choice comes from mulberry32(seed); the base seed is
// printed at suite start and can be pinned with FUZZ_SEED. On any invariant
// break the failing action (campaign, step, actor, values, prices) is embedded
// in the assertion message, giving a minimal repro.
//
// INVARIANTS UNDER TEST (the point of the exercise):
//   [SOLV-BORROW]  a successful borrow always leaves the position healthy at the
//                  immutable LTV (colVal*LTV/1e4 >= debtVal); an oversized borrow
//                  reverts (never borrow value beyond collateral capacity).
//   [SOLV-WITHDRAW] a successful collateral withdraw with open debt leaves the
//                  position healthy at LTV; withdrawing all collateral under debt
//                  reverts.
//   [LIQ-GATE]     liquidation succeeds ONLY when genuinely under the liquidation
//                  threshold; a clearly-healthy position reverts PositionHealthy.
//   [LIQ-BOUND]    liquidation never seizes more collateral than the close /
//                  bonus permits (value seized <= value repaid * (1+bonus)); never
//                  seizes more than the borrower holds; liquidator pays <= request
//                  and <= real debt.
//   [LIQ-PAUSE]    liquidation stays available while borrow+deposit are paused.
//   [INDEX-MONO]   borrowIndex and supplyIndex are monotonic non-decreasing.
//   [ACCT-SCALED]  sum(debtOf)==totalBorrowsScaled and
//                  sum(supplierScaled)==totalSuppliedScaled at all times.
//   [ACCT-COL]     sum(collateralOf)==COLLATERAL.balanceOf(market) (collateral
//                  conservation) at all times.
//   [ACCT-DEBT]    every debt-token op moves EXACTLY the expected amount in/out of
//                  the market (transfer consistency; no mint/burn drift).
//   [REPAY-FLOOR]  repay never drives debt negative and never over-charges past
//                  the real debt (clamp), even on gross overpay.
//
// SCOPE: run this file in ISOLATION (the full hardhat suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/invariant-lending-market-property.test.js
// TEST-ONLY: one new file, NO contract logic changed, NOTHING deployed to any
// live node.
// =============================================================================

const { expect } = require("chai");
const { ethers, network } = require("hardhat");

/* ----------------------------- 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) : 0x1E4D2800;
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;

/* ----------------------------- fixed-point USD math ----------------------- */
// Prices are 18-decimal USD-per-whole-token, mirroring AereLendingMarket._price.
const TEN = (n) => 10n ** BigInt(n);
function scaleUp(amount, dec) {
  // dec is always <= 18 in these campaigns; mirrors _scaleUp.
  return dec === 18 ? amount : amount * TEN(18 - dec);
}
function scaleDown(amount18, dec) {
  return dec === 18 ? amount18 : amount18 / TEN(18 - dec);
}
// USD value (18-dec) of `amount` token units, replicating the contract exactly:
//   _scaleUp(amount, dec) * price / 1e18
function usdVal(amount, dec, price) {
  return (scaleUp(amount, dec) * price) / TEN(18);
}

/* ----------------------------- config knobs ------------------------------- */
const N = {
  CAMPAIGNS: Number(process.env.LEND_CAMPAIGNS || 10),
  STEPS: Number(process.env.LEND_STEPS || 70),
};
const DECIMAL_CONFIGS = [
  { col: 18, debt: 6 },
  { col: 8, debt: 6 },
  { col: 18, debt: 18 },
  { col: 6, debt: 18 },
  { col: 8, debt: 8 },
];

async function advance(seconds) {
  await network.provider.send("evm_increaseTime", ["0x" + seconds.toString(16)]);
  await network.provider.send("evm_mine", []);
}

describe("INVARIANT: AereLendingMarket solvency / liquidation / accounting", function () {
  this.timeout(0);

  let signers, ERC20F, OracleF, MarketF;

  before(async function () {
    signers = await ethers.getSigners();
    ERC20F = await ethers.getContractFactory("MockERC20Lending");
    OracleF = await ethers.getContractFactory("MockPriceOracle"); // getPrice(address)->uint256, setRevert
    MarketF = await ethers.getContractFactory("AereLendingMarket");
    console.log(`      [lend] base seed 0x${BASE_SEED.toString(16)}, ${N.CAMPAIGNS} campaigns x ${N.STEPS} steps`);
  });

  it("drives multi-actor supply/borrow/repay/withdraw/liquidate across price moves; invariants hold every step", async function () {
    // Global tallies (asserted at the end to prove the campaigns really exercised each path).
    let steps = 0;
    let okBorrows = 0, okWithdraws = 0, okSupplies = 0, okRedeems = 0, okRepays = 0, okDeposits = 0;
    let liqSuccess = 0, liqHealthyRevert = 0, liqBorderline = 0;
    let oversizedBorrowBlocked = 0, oversizedWithdrawBlocked = 0;
    let overpayRepays = 0, pauseLiqProbes = 0;
    let invariantChecks = 0;

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("lend", c);

      // ---- random-but-valid market config for this campaign ----
      const dc = pick(rng, DECIMAL_CONFIGS);
      const colDec = dc.col, debtDec = dc.debt;
      const ltvBps = ri(rng, 3000, 8500);
      const liqThresholdBps = ri(rng, ltvBps, Math.min(9500, ltvBps + 1200)); // >= ltv, < 10000
      const liqBonusBps = ri(rng, 100, 1500);
      const borrowFeeBps = ri(rng, 0, 3000);

      const collateral = await ERC20F.deploy("COL", "COL", colDec);
      await collateral.waitForDeployment();
      const debt = await ERC20F.deploy("DEBT", "DEBT", debtDec);
      await debt.waitForDeployment();
      const oracle = await OracleF.deploy();
      await oracle.waitForDeployment();
      const colAddr = await collateral.getAddress();
      const debtAddr = await debt.getAddress();

      // Prices in 18-dec USD. Start both at $1.00.
      let colPrice = TEN(18);   // $1
      let debtPrice = TEN(18);  // $1
      await (await oracle.setPrice(colAddr, colPrice)).wait();
      await (await oracle.setPrice(debtAddr, debtPrice)).wait();

      const marketCap = 100_000_000n * TEN(debtDec); // effectively non-binding
      const market = await MarketF.deploy({
        collateral: colAddr,
        debtToken: debtAddr,
        sink: signers[9].address, // never flushed in this test; just needs to be nonzero
        oracle: await oracle.getAddress(),
        ltvBps, liqThresholdBps, liqBonusBps, borrowFeeBps,
        marketDebtCap: marketCap,
        collateralDecimals: colDec,
        debtDecimals: debtDec,
      });
      await market.waitForDeployment();
      const mAddr = await market.getAddress();
      const owner = signers[0];

      // Actors: users borrow/supply; a dedicated liquidator only liquidates.
      const users = [signers[1], signers[2], signers[3], signers[4], signers[5]];
      const liquidator = signers[6];
      const allCollateralHolders = [...users, liquidator]; // for the collateral-sum invariant

      // Fund + approve generously (fresh tokens per campaign, so balances don't leak state).
      const bigCol = 10_000_000n * TEN(colDec);
      const bigDebt = 10_000_000n * TEN(debtDec);
      for (const u of [...users, liquidator]) {
        await (await collateral.mint(u.address, bigCol)).wait();
        await (await debt.mint(u.address, bigDebt)).wait();
        await (await collateral.connect(u).approve(mAddr, ethers.MaxUint256)).wait();
        await (await debt.connect(u).approve(mAddr, ethers.MaxUint256)).wait();
      }

      // Seed deep debt-token liquidity so borrows are not liquidity-starved.
      await (await market.connect(users[0]).supply(2_000_000n * TEN(debtDec))).wait();
      await (await market.connect(users[1]).supply(1_000_000n * TEN(debtDec))).wait();

      // Give each borrower some starting collateral so early borrows can happen.
      for (const u of users) {
        await (await market.connect(u).depositCollateral(BigInt(ri(rng, 500, 5000)) * TEN(colDec))).wait();
      }

      let prevBorrowIndex = await market.borrowIndex();
      let prevSupplyIndex = await market.supplyIndex();

      // ---- the core invariant checker, run after EVERY step ----
      async function assertInvariants(ctx) {
        // [INDEX-MONO]
        const bi = await market.borrowIndex();
        const si = await market.supplyIndex();
        expect(bi, `[INDEX-MONO] borrowIndex decreased | ${ctx}`).to.be.gte(prevBorrowIndex);
        expect(si, `[INDEX-MONO] supplyIndex decreased | ${ctx}`).to.be.gte(prevSupplyIndex);
        prevBorrowIndex = bi;
        prevSupplyIndex = si;

        // [ACCT-SCALED] sum(debtOf)==totalBorrowsScaled, sum(supplierScaled)==totalSuppliedScaled
        let sumDebt = 0n, sumSup = 0n, sumCol = 0n;
        for (const u of allCollateralHolders) {
          sumDebt += await market.debtOf(u.address);
          sumSup += await market.supplierScaled(u.address);
          sumCol += await market.collateralOf(u.address);
        }
        expect(sumDebt, `[ACCT-SCALED] sum(debtOf)!=totalBorrowsScaled | ${ctx}`).to.equal(await market.totalBorrowsScaled());
        expect(sumSup, `[ACCT-SCALED] sum(supplierScaled)!=totalSuppliedScaled | ${ctx}`).to.equal(await market.totalSuppliedScaled());

        // [ACCT-COL] collateral conservation
        expect(sumCol, `[ACCT-COL] sum(collateralOf)!=market collateral balance | ${ctx}`).to.equal(await collateral.balanceOf(mAddr));

        invariantChecks++;
      }

      await assertInvariants(`c${c} seed setup`);

      // Compute a user's remaining borrow capacity (in debt-token units) from on-chain state.
      async function remainingBorrowTokens(u) {
        const col = await market.collateralOf(u.address);
        const debtReal = await market.debtBalance(u.address);
        const colValue = usdVal(col, colDec, colPrice);
        const debtValue = usdVal(debtReal, debtDec, debtPrice);
        const maxDebtValue = (colValue * BigInt(ltvBps)) / 10_000n;
        if (maxDebtValue <= debtValue) return 0n;
        const remainingValue = maxDebtValue - debtValue;
        // value -> debt tokens: value18 / price -> 18dec token, then scaleDown to debtDec
        const tokens18 = (remainingValue * TEN(18)) / debtPrice;
        return scaleDown(tokens18, debtDec);
      }

      for (let s = 0; s < N.STEPS; s++) {
        const action = pick(rng, [
          "borrow", "borrow", "repay", "supply", "redeem", "deposit", "deposit",
          "withdraw", "price", "price", "accrue", "liquidate", "liquidate",
          "oversizedBorrow", "oversizedWithdraw", "setRate",
        ]);
        const u = pick(rng, users);
        const ctx = () => `c${c} s${s} act=${action} actor=${users.indexOf(u) + 1} colDec=${colDec} debtDec=${debtDec} ltv=${ltvBps} liqTh=${liqThresholdBps} bonus=${liqBonusBps} fee=${borrowFeeBps} colPx=${colPrice} debtPx=${debtPrice}`;

        try {
          if (action === "accrue") {
            await advance(ri(rng, 1, 90 * 24 * 3600)); // up to ~90 days
            await (await market.accrueInterest()).wait();

          } else if (action === "price") {
            // Move collateral price (cents in [15c, 320c]) and debt price ([90c,110c]).
            colPrice = BigInt(ri(rng, 15, 320)) * TEN(16);
            debtPrice = BigInt(ri(rng, 90, 110)) * TEN(16);
            await (await oracle.setPrice(colAddr, colPrice)).wait();
            await (await oracle.setPrice(debtAddr, debtPrice)).wait();

          } else if (action === "setRate") {
            // Owner bumps the borrow rate (bounded by the contract's own cap).
            const cap = 317_097_919_837_645_865_000n;
            const rate = BigInt(ri(rng, 1, 100)) * (cap / 100n);
            await (await market.connect(owner).setBorrowRate(rate)).wait();

          } else if (action === "deposit") {
            const amt = BigInt(ri(rng, 1, 5000)) * TEN(colDec);
            await (await market.connect(u).depositCollateral(amt)).wait();
            okDeposits++;

          } else if (action === "supply") {
            const amt = BigInt(ri(rng, 1, 100000)) * TEN(debtDec);
            const before = await debt.balanceOf(mAddr);
            await (await market.connect(u).supply(amt)).wait();
            expect((await debt.balanceOf(mAddr)) - before, `[ACCT-DEBT] supply delta | ${ctx()}`).to.equal(amt);
            okSupplies++;

          } else if (action === "redeem") {
            // Best-effort: bounded by user's real balance and available liquidity.
            const bal = await market.supplierBalance(u.address);
            if (bal > 0n) {
              const amt = (bal * BigInt(ri(rng, 1, 100))) / 100n;
              if (amt > 0n) {
                const before = await debt.balanceOf(mAddr);
                await (await market.connect(u).redeem(amt)).wait();
                expect(before - (await debt.balanceOf(mAddr)), `[ACCT-DEBT] redeem delta | ${ctx()}`).to.equal(amt);
                okRedeems++;
              }
            }

          } else if (action === "borrow") {
            const remain = await remainingBorrowTokens(u);
            if (remain > 0n) {
              const liq = (await debt.balanceOf(mAddr)) - (await market.pendingSinkFee());
              let amt = (remain * BigInt(ri(rng, 30, 92))) / 100n;
              if (amt > liq - 1n) amt = liq > 1n ? liq - 1n : 0n;
              if (amt > 0n) {
                const before = await debt.balanceOf(mAddr);
                await (await market.connect(u).borrow(amt)).wait();
                expect(before - (await debt.balanceOf(mAddr)), `[ACCT-DEBT] borrow delta | ${ctx()}`).to.equal(amt);
                okBorrows++;
                // [SOLV-BORROW]: position must be healthy at LTV right after a successful borrow.
                const col = await market.collateralOf(u.address);
                const dReal = await market.debtBalance(u.address);
                const cv = usdVal(col, colDec, colPrice);
                const dv = usdVal(dReal, debtDec, debtPrice);
                expect((cv * BigInt(ltvBps)) / 10_000n, `[SOLV-BORROW] borrowed into insolvency | ${ctx()} col=${col} debt=${dReal}`).to.be.gte(dv);
              }
            }

          } else if (action === "oversizedBorrow") {
            // Deliberately request far beyond capacity: MUST revert (never over-borrow).
            const remain = await remainingBorrowTokens(u);
            const base = remain > 0n ? remain : 1n * TEN(debtDec);
            const amt = base * 5n + 1000n * TEN(debtDec);
            await expect(market.connect(u).borrow(amt), `[SOLV-BORROW] oversized borrow did NOT revert | ${ctx()}`).to.be.reverted;
            oversizedBorrowBlocked++;

          } else if (action === "repay") {
            const dReal = await market.debtBalance(u.address);
            if (dReal > 0n) {
              const overpay = chance(rng, 0.35);
              const amt = overpay ? dReal + BigInt(ri(rng, 1, 5000)) * TEN(debtDec) : (dReal * BigInt(ri(rng, 1, 100))) / 100n;
              if (amt > 0n) {
                const payerBefore = await debt.balanceOf(u.address);
                const mBefore = await debt.balanceOf(mAddr);
                await (await market.connect(u).repay(amt)).wait();
                const paid = payerBefore - (await debt.balanceOf(u.address));
                // [ACCT-DEBT] market received exactly `paid`.
                expect((await debt.balanceOf(mAddr)) - mBefore, `[ACCT-DEBT] repay delta | ${ctx()}`).to.equal(paid);
                // [REPAY-FLOOR] never pull more than the payer offered.
                expect(paid, `[REPAY-FLOOR] charged more than offered | ${ctx()} amt=${amt} paid=${paid}`).to.be.lte(amt);
                // debtReal only grows between the view-read and the tx (accrual runs inside repay),
                // so a small slack covers up to one block of interest.
                const accrualSlack = dReal / 1_000_000n + 2n * TEN(debtDec);
                if (overpay) {
                  // [REPAY-FLOOR] a gross overpay clamps to the real debt (never over-charges) and zeroes it.
                  expect(await market.debtBalance(u.address), `[REPAY-FLOOR] overpay did not zero debt | ${ctx()}`).to.equal(0n);
                  expect(paid, `[REPAY-FLOOR] overpay took more than real debt | ${ctx()} dReal=${dReal} paid=${paid}`).to.be.lte(dReal + accrualSlack);
                  overpayRepays++;
                } else {
                  // amt <= dReal(view) <= dReal(tx), so the clamp does not bind: exactly `amt` is applied.
                  expect(paid, `[REPAY-FLOOR] partial repay applied != offered | ${ctx()} amt=${amt} paid=${paid}`).to.equal(amt);
                }
                okRepays++;
              }
            }

          } else if (action === "withdraw") {
            const col = await market.collateralOf(u.address);
            if (col > 0n) {
              const amt = (col * BigInt(ri(rng, 1, 100))) / 100n;
              if (amt > 0n) {
                const hadDebt = (await market.debtBalance(u.address)) > 0n;
                const before = await collateral.balanceOf(mAddr);
                try {
                  await (await market.connect(u).withdrawCollateral(amt)).wait();
                  expect(before - (await collateral.balanceOf(mAddr)), `[ACCT-COL] withdraw delta | ${ctx()}`).to.equal(amt);
                  okWithdraws++;
                  if (hadDebt) {
                    // [SOLV-WITHDRAW]: still healthy at LTV after pulling collateral out.
                    const nc = await market.collateralOf(u.address);
                    const dReal = await market.debtBalance(u.address);
                    const cv = usdVal(nc, colDec, colPrice);
                    const dv = usdVal(dReal, debtDec, debtPrice);
                    expect((cv * BigInt(ltvBps)) / 10_000n, `[SOLV-WITHDRAW] withdrew into insolvency | ${ctx()} nc=${nc} debt=${dReal}`).to.be.gte(dv);
                  }
                } catch (e) {
                  // A revert is only acceptable if the position had debt (health guard) or dust rounding.
                  // If it had NO debt, withdraw of an owned amount must succeed.
                  if (!hadDebt) throw e;
                }
              }
            }

          } else if (action === "oversizedWithdraw") {
            // Withdraw ALL collateral while holding debt MUST revert (health guard).
            const col = await market.collateralOf(u.address);
            const dReal = await market.debtBalance(u.address);
            if (col > 0n && dReal > 0n) {
              await expect(market.connect(u).withdrawCollateral(col), `[SOLV-WITHDRAW] full withdraw under debt did NOT revert | ${ctx()}`).to.be.reverted;
              oversizedWithdrawBlocked++;
            }

          } else if (action === "liquidate") {
            // Sync interest, then read the health factor at the liquidation threshold.
            await (await market.accrueInterest()).wait();
            const target = pick(rng, users);
            const dReal = await market.debtBalance(target.address);
            const colBefore = await market.collateralOf(target.address);
            if (dReal === 0n) {
              // [LIQ-GATE] no debt => not liquidatable.
              await expect(market.connect(liquidator).liquidate(target.address, 1n * TEN(debtDec)),
                `[LIQ-GATE] liquidated a zero-debt position | ${ctx()}`).to.be.revertedWithCustomError(market, "PositionHealthy");
              liqHealthyRevert++;
            } else {
              const hf = await market.healthFactor(target.address); // >=10000 healthy@threshold, <10000 liquidatable
              const repayReq = (dReal * BigInt(ri(rng, 10, 100))) / 100n + 1n;
              if (hf >= 10100n) {
                // [LIQ-GATE] clearly healthy => must revert.
                await expect(market.connect(liquidator).liquidate(target.address, repayReq),
                  `[LIQ-GATE] liquidated a healthy position hf=${hf} | ${ctx()}`).to.be.revertedWithCustomError(market, "PositionHealthy");
                liqHealthyRevert++;
              } else if (hf <= 9900n) {
                // [LIQ-GATE] clearly under threshold => must succeed and respect the seize bound.
                const kDebtBefore = await debt.balanceOf(liquidator.address);
                const kColBefore = await collateral.balanceOf(liquidator.address);
                const mColBefore = await collateral.balanceOf(mAddr);
                const mDebtBefore = await debt.balanceOf(mAddr);
                await (await market.connect(liquidator).liquidate(target.address, repayReq)).wait();
                const paid = kDebtBefore - (await debt.balanceOf(liquidator.address));
                const seized = (await collateral.balanceOf(liquidator.address)) - kColBefore;

                // [ACCT-DEBT]/[ACCT-COL] transfer consistency for the liquidation legs.
                expect((await debt.balanceOf(mAddr)) - mDebtBefore, `[ACCT-DEBT] liq pay-in delta | ${ctx()}`).to.equal(paid);
                expect(mColBefore - (await collateral.balanceOf(mAddr)), `[ACCT-COL] liq seize delta | ${ctx()}`).to.equal(seized);

                // [LIQ-BOUND] never seize more than the borrower held; borrower col reduced by exactly seized.
                expect(seized, `[LIQ-BOUND] seized more than borrower held | ${ctx()} seized=${seized} colBefore=${colBefore}`).to.be.lte(colBefore);
                expect(await market.collateralOf(target.address), `[LIQ-BOUND] borrower collateral bookkeeping | ${ctx()}`).to.equal(colBefore - seized);

                // [LIQ-BOUND] liquidator paid no more than requested and no more than the real debt (+1s accrual slack).
                const debtSlack = dReal / 1_000_000n + 2n * TEN(debtDec);
                expect(paid, `[LIQ-BOUND] paid more than requested | ${ctx()} paid=${paid} req=${repayReq}`).to.be.lte(repayReq);
                expect(paid, `[LIQ-BOUND] paid more than real debt | ${ctx()} paid=${paid} dReal=${dReal}`).to.be.lte(dReal + debtSlack);

                // [LIQ-BOUND] value seized <= value repaid * (1 + bonus) (+ rounding tolerance of a couple token-units).
                const valPaid = usdVal(paid, debtDec, debtPrice);
                const valSeized = usdVal(seized, colDec, colPrice);
                const allowed = (valPaid * BigInt(10_000 + liqBonusBps)) / 10_000n;
                const tol = usdVal(2n, colDec, colPrice) + usdVal(2n, debtDec, debtPrice) + 1n;
                expect(valSeized, `[LIQ-BOUND] seized value exceeds bonus cap | ${ctx()} valSeized=${valSeized} allowed=${allowed} paid=${paid} seized=${seized}`).to.be.lte(allowed + tol);
                liqSuccess++;
              } else {
                // Borderline band: exercise it but don't strictly gate (rounding across the boundary).
                try {
                  const kColBefore = await collateral.balanceOf(liquidator.address);
                  await (await market.connect(liquidator).liquidate(target.address, repayReq)).wait();
                  const seized = (await collateral.balanceOf(liquidator.address)) - kColBefore;
                  expect(seized, `[LIQ-BOUND] borderline seized more than held | ${ctx()}`).to.be.lte(colBefore);
                } catch (_) { /* PositionHealthy at the exact boundary is acceptable */ }
                liqBorderline++;
              }
            }
          }
        } catch (e) {
          // Best-effort actions (borrow/redeem/withdraw/repay under shifting prices+interest) may
          // legitimately revert; the directed probes above use expect(...).to.be.reverted and are
          // NOT swallowed here because they don't throw on the expected revert. Re-throw anything
          // that is clearly one of OUR invariant assertions.
          if (e && e.message && /\[(SOLV|LIQ|ACCT|INDEX|REPAY)-/.test(e.message)) throw e;
          // otherwise: an ordinary contract revert on a best-effort action — ignore and continue.
        }

        await assertInvariants(ctx());
        steps++;
      }

      // ---- Directed finale per campaign: [LIQ-PAUSE] liquidation survives a full pause. ----
      // Force a borrower deeply underwater via a collateral-price crash, pause borrow+deposit,
      // and prove the liquidation path is still open (and borrow/deposit are genuinely blocked).
      {
        const victim = users[2];
        // Ensure the victim actually has debt; if not, set up a fresh position.
        await (await market.accrueInterest()).wait();
        let vDebt = await market.debtBalance(victim.address);
        if (vDebt === 0n) {
          colPrice = TEN(18); debtPrice = TEN(18);
          await (await oracle.setPrice(colAddr, colPrice)).wait();
          await (await oracle.setPrice(debtAddr, debtPrice)).wait();
          await (await market.connect(victim).depositCollateral(1000n * TEN(colDec))).wait();
          const cap = await remainingBorrowTokens(victim);
          if (cap > 0n) {
            const liq = (await debt.balanceOf(mAddr)) - (await market.pendingSinkFee());
            let amt = (cap * 80n) / 100n;
            if (amt > liq - 1n) amt = liq > 1n ? liq - 1n : 0n;
            if (amt > 0n) await (await market.connect(victim).borrow(amt)).wait();
          }
          vDebt = await market.debtBalance(victim.address);
        }
        const vCol = await market.collateralOf(victim.address);
        if (vDebt > 0n && vCol > 0n) {
          // Crash collateral to ~40% of the exact liquidation-threshold price for THIS position,
          // so it is guaranteed well under threshold regardless of prior collateralisation.
          debtPrice = TEN(18);
          const thresholdColPrice = (scaleUp(vDebt, debtDec) * debtPrice * 10_000n) / (scaleUp(vCol, colDec) * BigInt(liqThresholdBps));
          colPrice = thresholdColPrice * 4n / 10n;
          if (colPrice < 1n) colPrice = 1n;
          await (await oracle.setPrice(colAddr, colPrice)).wait();
          await (await oracle.setPrice(debtAddr, debtPrice)).wait();
          await (await market.connect(owner).setPaused(true, true)).wait(); // borrow + deposit paused
          // Borrow + deposit are blocked while paused...
          await expect(market.connect(users[3]).depositCollateral(1n * TEN(colDec)),
            `[LIQ-PAUSE] deposit not blocked while paused | c${c}`).to.be.revertedWithCustomError(market, "DepositPausedErr");
          await expect(market.connect(users[3]).borrow(1n * TEN(debtDec)),
            `[LIQ-PAUSE] borrow not blocked while paused | c${c}`).to.be.revertedWithCustomError(market, "BorrowPausedErr");
          // ...but liquidation is STILL available.
          const hf = await market.healthFactor(victim.address);
          expect(hf, `[LIQ-PAUSE] victim not underwater after crash hf=${hf} | c${c}`).to.be.lt(10000n);
          const colBefore = await market.collateralOf(victim.address);
          const kColBefore = await collateral.balanceOf(liquidator.address);
          await (await market.connect(liquidator).liquidate(victim.address, vDebt)).wait();
          const seized = (await collateral.balanceOf(liquidator.address)) - kColBefore;
          expect(seized, `[LIQ-PAUSE] liquidation seized nothing while paused | c${c}`).to.be.gt(0n);
          expect(seized, `[LIQ-PAUSE] seized more than held while paused | c${c}`).to.be.lte(colBefore);
          await (await market.connect(owner).setPaused(false, false)).wait();
          pauseLiqProbes++;
        }
        await assertInvariants(`c${c} pause-finale`);
      }
    }

    console.log(`      [lend] steps=${steps} invariantChecks=${invariantChecks}`);
    console.log(`      [lend] borrows=${okBorrows} deposits=${okDeposits} supplies=${okSupplies} redeems=${okRedeems} repays=${okRepays}(overpay=${overpayRepays}) withdraws=${okWithdraws}`);
    console.log(`      [lend] liq: success=${liqSuccess} healthyReverts=${liqHealthyRevert} borderline=${liqBorderline} pauseProbes=${pauseLiqProbes}`);
    console.log(`      [lend] directed: oversizedBorrowBlocked=${oversizedBorrowBlocked} oversizedWithdrawBlocked=${oversizedWithdrawBlocked}`);

    // Coverage floors: prove the campaigns actually exercised the interesting paths.
    expect(invariantChecks, "expected hundreds of full invariant sweeps").to.be.greaterThan(400);
    expect(okBorrows, "expected many successful borrows").to.be.greaterThan(30);
    expect(okRepays, "expected repays").to.be.greaterThan(15);
    expect(liqSuccess, "expected genuine liquidations").to.be.greaterThan(5);
    expect(liqHealthyRevert, "expected healthy-position liquidation reverts").to.be.greaterThan(5);
    expect(oversizedBorrowBlocked, "expected oversized borrows to be blocked").to.be.greaterThan(5);
    expect(pauseLiqProbes, "expected the pause-liquidation probe to run").to.be.greaterThan(0);
  });
});
