// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for AereStateChannels
// (live 0x64488eda27fA5b55A277ac8D386E169DC78cd7b6, 24h challenge window).
//
// Bidirectional ERC-20 payment channels + HTLC. This drives long random
// multi-actor, multi-channel sequences (open / cooperativeClose / startChallenge
// / overrideChallenge / settleHtlc / finalize / withdraw / time jumps) against a
// SHARED contract holding several channels of one token, asserting the core
// invariants after EVERY step:
//
//   I1 GLOBAL SOLVENCY  contract token balance == sum of every channel's owed
//                        funds (open/challenged -> whole deposit is locked;
//                        finalised -> unwithdrawn allocation). A cross-channel
//                        drain or any mint breaks this exact equality.
//   I2 NO MINT          per channel, finalBalanceA + finalBalanceB <= depositA +
//                        depositB after any close (never creates value).
//   I3 WITHDRAW CAP     a party withdraws EXACTLY its last-signed allocation,
//                        once; a re-withdraw pays 0; non-party reverts.
//   I4 STALE<-NEWER     during the challenge window a stale (<= latest nonce)
//                        signed state can NOT override a strictly-newer one.
//   I5 HTLC EXCLUSIVITY  locked funds are claimable only with the correct
//                        preimage before timeout, and refundable only after
//                        (finalize), never both.
//   I6 REENTRANCY/CEI   withdraw zeroes the allocation before transferring and
//                        is nonReentrant; the exact global-solvency equality
//                        across interleaved withdrawals leaves no drain path.
//
// TOOLCHAIN: hardhat-based seeded fuzzing (Foundry/forge not installed). Every
// random choice comes from mulberry32(seed); seed printed at start + pinnable via
// FUZZ_SEED. Each assertion embeds campaign/step/actor/values for a minimal repro.
//
// SCOPE: run in ISOLATION (the full suite OOM/segfaults on the ML-DSA KAT tests):
//     npx hardhat test test/invariant-statechannels-property.test.js
// One new test file. No contract-logic change, no deploy, no live node. Reuses
// the existing MockERC20Lending mock (standard OZ ERC20, bool-returning transfers).
// =============================================================================

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) : 0x5C2800AE;
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;

/* ----------------------------- misc helpers ------------------------------- */
const E = (n) => ethers.parseEther(String(n));
const rand32 = (rng) =>
  "0x" + Array.from({ length: 32 }, () => ri(rng, 0, 255).toString(16).padStart(2, "0")).join("");
async function now() {
  return BigInt(await time.latest());
}

const N = {
  CAMPAIGNS: Number(process.env.SC_CAMPAIGNS || 16),
  STEPS: Number(process.env.SC_STEPS || 55),
};

const Status = { None: 0, Open: 1, Challenged: 2, Finalised: 3 };

describe("INVARIANT: AereStateChannels bidirectional channels + HTLC", function () {
  this.timeout(0);

  let signers, tokenF, scF, chainId;

  before(async function () {
    signers = await ethers.getSigners();
    tokenF = await ethers.getContractFactory("MockERC20Lending");
    scF = await ethers.getContractFactory("AereStateChannels");
    chainId = Number((await ethers.provider.getNetwork()).chainId);
    console.log(
      `      [sc] base seed 0x${BASE_SEED.toString(16)}, ${N.CAMPAIGNS} campaigns x ${N.STEPS} steps, chainId ${chainId}`
    );
  });

  // EIP-712 types mirroring STATE_TYPEHASH exactly.
  const TYPES = {
    ChannelState: [
      { name: "channelId", type: "bytes32" },
      { name: "nonce", type: "uint256" },
      { name: "balanceA", type: "uint256" },
      { name: "balanceB", type: "uint256" },
      { name: "htlcLockedFromA", type: "uint256" },
      { name: "htlcLockedFromB", type: "uint256" },
      { name: "lockHash", type: "bytes32" },
      { name: "lockTimeout", type: "uint256" },
    ],
  };
  function domainFor(addr) {
    return { name: "AereStateChannels", version: "1", chainId, verifyingContract: addr };
  }
  function structFor(st) {
    return {
      channelId: st.channelId,
      nonce: st.nonce,
      balanceA: st.balanceA,
      balanceB: st.balanceB,
      htlcLockedFromA: st.htlcA,
      htlcLockedFromB: st.htlcB,
      lockHash: st.lockHash,
      lockTimeout: st.lockTimeout,
    };
  }
  async function signState(domain, partyA, partyB, st) {
    const v = structFor(st);
    const sigA = await partyA.signTypedData(domain, TYPES, v);
    const sigB = await partyB.signTypedData(domain, TYPES, v);
    return { struct: v, sigA, sigB };
  }

  // Generate a balances-conserving signed state at nonce `nonce`. Optionally carve
  // an HTLC out of A's and/or B's shares. lockTimeout is baked as an absolute unix
  // ts (genTime + offset) so both "settleable" and "already-expired" paths arise.
  async function makeSignedState(rng, ch, domain, nonce) {
    const total = ch.depA + ch.depB;
    let a = BigInt(ri(rng, 0, Number(total)));
    let b = total - a;
    let htlcA = 0n,
      htlcB = 0n,
      lockHash = ethers.ZeroHash,
      lockTimeout = 0n,
      preimage = null;
    if (chance(rng, 0.45)) {
      const ha = a > 0n ? BigInt(ri(rng, 0, Number(a))) : 0n;
      const hb = b > 0n ? BigInt(ri(rng, 0, Number(b))) : 0n;
      if (ha + hb > 0n) {
        a -= ha;
        b -= hb;
        htlcA = ha;
        htlcB = hb;
        preimage = rand32(rng);
        lockHash = ethers.keccak256(preimage);
        const off = ri(rng, -30, 4000); // may be already-past to force expiry
        lockTimeout = (await now()) + BigInt(off);
        if (lockTimeout < 0n) lockTimeout = 0n;
      }
    }
    const st = { channelId: ch.id, nonce: BigInt(nonce), balanceA: a, balanceB: b, htlcA, htlcB, lockHash, lockTimeout, preimage };
    const signed = await signState(domain, ch.pA, ch.pB, st);
    st.sigA = signed.sigA;
    st.sigB = signed.sigB;
    st.struct = signed.struct;
    return st;
  }

  // Owed funds still held by the contract for this channel.
  function owed(ch) {
    if (ch.status === Status.Open || ch.status === Status.Challenged) return ch.depA + ch.depB;
    if (ch.status === Status.Finalised) return ch.remA + ch.remB;
    return 0n;
  }

  it("global solvency, no-mint, withdraw-cap, stale-override & HTLC exclusivity hold over random sequences", async function () {
    let steps = 0;
    const tally = {
      opens: 0,
      coopClose: 0,
      startChal: 0,
      override: 0,
      overrideStaleBlocked: 0,
      overrideExpiredBlocked: 0,
      settleOk: 0,
      settleWrongPre: 0,
      settleExpired: 0,
      settleAlready: 0,
      finalizeSettled: 0,
      finalizeRefund: 0,
      finalizeNoHtlc: 0,
      finalizeWindowOpen: 0,
      withdraws: 0,
      reWithdrawZero: 0,
      nonPartyBlocked: 0,
      solvencyChecks: 0,
    };

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

      const token = await tokenF.deploy("Mock", "MCK", 18);
      await token.waitForDeployment();
      const tokenAddr = await token.getAddress();

      const window = BigInt(ri(rng, 60, 3 * 24 * 3600)); // 1min .. 3 days
      const sc = await scF.deploy(window);
      await sc.waitForDeployment();
      const scAddr = await sc.getAddress();
      const domain = domainFor(scAddr);

      // Actor pool: 6 funded signers, all approve the channel contract.
      const actors = signers.slice(1, 7);
      for (const a of actors) {
        await (await token.mint(a.address, E("1000000000"))).wait();
        await (await token.connect(a).approve(scAddr, ethers.MaxUint256)).wait();
      }
      const outsider = signers[8]; // never a channel party

      // Open a handful of channels between distinct actor pairs.
      const channels = [];
      const nCh = ri(rng, 2, 4);
      for (let k = 0; k < nCh; k++) {
        const i = ri(rng, 0, actors.length - 1);
        let j = ri(rng, 0, actors.length - 1);
        if (j === i) j = (j + 1) % actors.length;
        const pA = actors[i],
          pB = actors[j];
        const depA = E(ri(rng, 0, 500));
        let depB = E(ri(rng, 0, 500));
        if (depA + depB === 0n) depB = E(ri(rng, 1, 500)); // open() rejects zero total
        const rc = await (await sc.connect(pA).open(pA.address, pB.address, tokenAddr, depA, depB)).wait();
        // Recover channelId from the Opened event.
        let id = null;
        for (const log of rc.logs) {
          try {
            const parsed = sc.interface.parseLog(log);
            if (parsed && parsed.name === "Opened") id = parsed.args.channelId;
          } catch (_) {}
        }
        expect(id, `[sc OPEN] no Opened event (c${c} k${k})`).to.not.equal(null);
        const ch = {
          id,
          pA,
          pB,
          depA,
          depB,
          status: Status.Open,
          latestNonce: 0n,
          nextNonce: 1,
          signed: [], // pre-signed states (nonce >= 1)
          challenged: null, // the state used to challenge (model copy)
          htlcResolved: false, // on-chain htlcResolved persists once set (mirrors contract)
          resolvedHash: null, // the lockHash that settleHtlc recorded
          finalA: null,
          finalB: null,
          remA: 0n,
          remB: 0n,
        };
        // Pre-sign a chain of states.
        const m = ri(rng, 1, 5);
        for (let s = 1; s <= m; s++) ch.signed.push(await makeSignedState(rng, ch, domain, s));
        ch.nextNonce = m + 1;
        channels.push(ch);
        tally.opens++;
      }

      async function assertSolvency(tag) {
        const bal = await token.balanceOf(scAddr);
        let sum = 0n;
        for (const ch of channels) sum += owed(ch);
        expect(bal, `[sc SOLVENCY] token balance != sum owed after ${tag} (c${c} s${steps}) bal=${bal} owed=${sum}`).to.equal(sum);
        tally.solvencyChecks++;
      }
      await assertSolvency("open-all");

      for (let s = 0; s < N.STEPS; s++) {
        const ch = pick(rng, channels);
        const ctx = `c${c} s${s} ch=${ch.id.slice(0, 10)} status=${ch.status}`;

        if (ch.status === Status.Open) {
          // Either cooperative-close or start a challenge with some signed state.
          if (chance(rng, 0.4)) {
            // cooperativeClose with the latest signed state (both sigs).
            const st = ch.signed[ch.signed.length - 1];
            await (await sc.connect(ch.pA).cooperativeClose(st.struct, st.sigA, st.sigB)).wait();
            const [fa, fb] = coopFinal(st);
            ch.status = Status.Finalised;
            ch.finalA = fa;
            ch.finalB = fb;
            ch.remA = fa;
            ch.remB = fb;
            ch.latestNonce = st.nonce;
            expect(fa + fb, `[sc NO-MINT coop] over deposits — ${ctx}`).to.be.lte(ch.depA + ch.depB);
            tally.coopClose++;
          } else {
            // startChallenge with a possibly-stale state (random nonce index).
            const st = pick(rng, ch.signed);
            await (await sc.connect(ch.pA).startChallenge(st.struct, st.sigA, st.sigB)).wait();
            ch.status = Status.Challenged;
            ch.challenged = st;
            ch.latestNonce = st.nonce;
            // Fresh challenge episode: on-chain the channel was never resolved.
            ch.htlcResolved = false;
            ch.resolvedHash = null;
            ch.challengeStart = await now();
            tally.startChal++;
          }
        } else if (ch.status === Status.Challenged) {
          const t = await now();
          const deadline = ch.challengeStart + window;
          const windowOpen = t < deadline;
          const sub = ri(rng, 0, 4);

          if (sub === 0 && windowOpen) {
            // Override attempt with a random signed state (may be stale or newer).
            const st = pick(rng, ch.signed);
            if (st.nonce > ch.latestNonce) {
              await (await sc.connect(ch.pB).overrideChallenge(st.struct, st.sigA, st.sigB)).wait();
              ch.challenged = st;
              ch.latestNonce = st.nonce;
              // On-chain htlcResolved / settledHtlcLockHash are NOT touched by an
              // override, so the model must not reset them either. finalize later
              // reconciles: settled iff resolvedHash == new challenged lockHash.
              tally.override++;
            } else {
              // I4: stale (<= latest) must be rejected.
              await expect(
                sc.connect(ch.pB).overrideChallenge(st.struct, st.sigA, st.sigB),
                `[sc I4 STALE] stale override not rejected — ${ctx} n=${st.nonce} latest=${ch.latestNonce}`
              ).to.be.revertedWithCustomError(sc, "NonceNotHigher");
              tally.overrideStaleBlocked++;
            }
          } else if (sub === 0 && !windowOpen) {
            // Window closed: any override reverts WindowExpired (checked before nonce).
            const st = pick(rng, ch.signed);
            await expect(
              sc.connect(ch.pB).overrideChallenge(st.struct, st.sigA, st.sigB),
              `[sc OVERRIDE-EXPIRED] — ${ctx}`
            ).to.be.revertedWithCustomError(sc, "WindowExpired");
            tally.overrideExpiredBlocked++;
          } else if (sub === 1) {
            // HTLC settle probe on the challenged state. Contract check order:
            //   status -> htlcResolved -> lockTimeout -> preimage.
            const cs = ch.challenged;
            if (ch.htlcResolved) {
              // Global resolve flag already set (this episode) -> always blocks.
              await expect(
                sc.settleHtlc(ch.id, cs.lockHash === ethers.ZeroHash ? "0x1234" : cs.preimage),
                `[sc SETTLE-ALREADY] — ${ctx}`
              ).to.be.revertedWithCustomError(sc, "HtlcAlreadyResolved");
              tally.settleAlready++;
            } else if (cs.lockHash === ethers.ZeroHash) {
              // No active HTLC: latestLockTimeout==0 < now -> LockExpired.
              await expect(
                sc.settleHtlc(ch.id, "0x1234"),
                `[sc SETTLE-NOHTLC] — ${ctx}`
              ).to.be.revertedWithCustomError(sc, "LockExpired");
            } else if (t > cs.lockTimeout) {
              // I5: past timeout -> not claimable (refund-only from here).
              await expect(
                sc.settleHtlc(ch.id, cs.preimage),
                `[sc I5 EXPIRED] settle after timeout succeeded — ${ctx} t=${t} to=${cs.lockTimeout}`
              ).to.be.revertedWithCustomError(sc, "LockExpired");
              tally.settleExpired++;
            } else if (chance(rng, 0.5)) {
              // Wrong preimage, in time -> mismatch.
              let wrong = rand32(rng);
              while (ethers.keccak256(wrong) === cs.lockHash) wrong = rand32(rng);
              await expect(
                sc.settleHtlc(ch.id, wrong),
                `[sc SETTLE-WRONGPRE] — ${ctx}`
              ).to.be.revertedWithCustomError(sc, "PreimageMismatch");
              tally.settleWrongPre++;
            } else {
              // Correct preimage, in time -> settles (once).
              await (await sc.settleHtlc(ch.id, cs.preimage)).wait();
              ch.htlcResolved = true;
              ch.resolvedHash = cs.lockHash;
              tally.settleOk++;
            }
          } else if (sub === 2) {
            // Advance time (sometimes past the window / lock).
            const jump = chance(rng, 0.5) ? Number(window) + ri(rng, 1, 50) : ri(rng, 1, Number(window));
            await time.increase(jump);
          } else if (sub === 3 || sub === 4) {
            // finalize (expect revert if window still open).
            const t2 = await now();
            if (t2 < ch.challengeStart + window) {
              await expect(sc.finalize(ch.id), `[sc FINALIZE-EARLY] — ${ctx}`).to.be.revertedWithCustomError(sc, "WindowOpen");
              tally.finalizeWindowOpen++;
            } else {
              await (await sc.finalize(ch.id)).wait();
              const [fa, fb, kind] = finalizeFinal(ch);
              ch.status = Status.Finalised;
              ch.finalA = fa;
              ch.finalB = fb;
              ch.remA = fa;
              ch.remB = fb;
              expect(fa + fb, `[sc NO-MINT finalize] over deposits — ${ctx}`).to.be.lte(ch.depA + ch.depB);
              // conservation is exact for finalize (no stuck-fund path).
              expect(fa + fb, `[sc CONSERVE finalize] != deposits — ${ctx}`).to.equal(ch.depA + ch.depB);
              if (kind === "settled") tally.finalizeSettled++;
              else if (kind === "refund") tally.finalizeRefund++;
              else tally.finalizeNoHtlc++;
            }
          }
        } else if (ch.status === Status.Finalised) {
          // Withdraw / re-withdraw / non-party probe. I3 + I6.
          const who = ri(rng, 0, 2);
          if (who === 2) {
            await expect(sc.connect(outsider).withdraw(ch.id), `[sc NONPARTY] — ${ctx}`).to.be.revertedWithCustomError(sc, "NotParticipant");
            tally.nonPartyBlocked++;
          } else {
            const party = who === 0 ? ch.pA : ch.pB;
            const rem = who === 0 ? ch.remA : ch.remB;
            const before = await token.balanceOf(party.address);
            await (await sc.connect(party).withdraw(ch.id)).wait();
            const after = await token.balanceOf(party.address);
            expect(after - before, `[sc I3 WITHDRAW] payout != allocation — ${ctx} rem=${rem}`).to.equal(rem);
            if (rem > 0n) tally.withdraws++;
            else tally.reWithdrawZero++;
            if (who === 0) ch.remA = 0n;
            else ch.remB = 0n;
          }
        }

        await assertSolvency(`step${s}`);
        steps++;
      }

      // Drain everything at campaign end so a lingering over/under-credit surfaces
      // in the final exact-solvency check.
      for (const ch of channels) {
        if (ch.status === Status.Challenged) {
          const t = await now();
          const need = ch.challengeStart + window;
          if (t < need) await time.increase(Number(need - t) + 1);
          await (await sc.finalize(ch.id)).wait();
          const [fa, fb] = finalizeFinal(ch);
          ch.status = Status.Finalised;
          ch.remA = fa;
          ch.remB = fb;
        }
        if (ch.status === Status.Finalised) {
          if (ch.remA > 0n) {
            const b0 = await token.balanceOf(ch.pA.address);
            await (await sc.connect(ch.pA).withdraw(ch.id)).wait();
            expect((await token.balanceOf(ch.pA.address)) - b0, `[sc FINALE A] c${c}`).to.equal(ch.remA);
            ch.remA = 0n;
          }
          if (ch.remB > 0n) {
            const b0 = await token.balanceOf(ch.pB.address);
            await (await sc.connect(ch.pB).withdraw(ch.id)).wait();
            expect((await token.balanceOf(ch.pB.address)) - b0, `[sc FINALE B] c${c}`).to.equal(ch.remB);
            ch.remB = 0n;
          }
        }
        await assertSolvency("finale-drain");
      }
      // Every fully-drained + still-open channel accounted: contract holds exactly
      // the deposits of channels still Open (never challenged/closed this run).
      let stillLocked = 0n;
      for (const ch of channels) if (ch.status === Status.Open) stillLocked += ch.depA + ch.depB;
      expect(await token.balanceOf(scAddr), `[sc END] residual != still-open deposits (c${c})`).to.equal(stillLocked);
    }

    console.log(`      [sc] steps=${steps} solvencyChecks=${tally.solvencyChecks}`);
    console.log(`      [sc] opens=${tally.opens} coopClose=${tally.coopClose} startChal=${tally.startChal} override=${tally.override}`);
    console.log(
      `      [sc] I4 staleBlocked=${tally.overrideStaleBlocked} expiredOverrideBlocked=${tally.overrideExpiredBlocked}`
    );
    console.log(
      `      [sc] settleOk=${tally.settleOk} wrongPre=${tally.settleWrongPre} expiredSettle=${tally.settleExpired} already=${tally.settleAlready}`
    );
    console.log(
      `      [sc] finalize settled=${tally.finalizeSettled} refund=${tally.finalizeRefund} noHtlc=${tally.finalizeNoHtlc} windowOpen=${tally.finalizeWindowOpen}`
    );
    console.log(`      [sc] withdraws=${tally.withdraws} reWithdraw0=${tally.reWithdrawZero} nonPartyBlocked=${tally.nonPartyBlocked}`);

    // Coverage floors: prove the sequences actually exercised the hard paths.
    expect(steps).to.be.greaterThan(700);
    expect(tally.solvencyChecks).to.be.greaterThan(700);
    expect(tally.startChal).to.be.greaterThan(20);
    expect(tally.overrideStaleBlocked, "stale-override (I4) never exercised").to.be.greaterThan(0);
    expect(tally.settleOk, "HTLC settle-ok never exercised").to.be.greaterThan(0);
    expect(tally.settleExpired + tally.settleWrongPre, "HTLC negative paths never exercised").to.be.greaterThan(0);
    expect(tally.finalizeSettled + tally.finalizeRefund, "HTLC finalize resolution never exercised").to.be.greaterThan(0);
    expect(tally.withdraws, "no successful withdrawals").to.be.greaterThan(10);
  });

  // Cooperative-close final allocation (model mirror of the contract).
  function coopFinal(st) {
    if (st.lockHash !== ethers.ZeroHash) return [st.balanceA + st.htlcA, st.balanceB + st.htlcB];
    return [st.balanceA, st.balanceB];
  }
  // finalize final allocation (model mirror), returns [a, b, kind].
  function finalizeFinal(ch) {
    const st = ch.challenged;
    if (st.lockHash === ethers.ZeroHash) return [st.balanceA, st.balanceB, "nohtlc"];
    // Mirror the contract: settled iff a recorded resolve hash matches the CURRENT
    // challenged lockHash (an override to a different hash falls back to refund).
    const settled = ch.htlcResolved && ch.resolvedHash === st.lockHash;
    if (settled) return [st.balanceA + st.htlcB, st.balanceB + st.htlcA, "settled"];
    return [st.balanceA + st.htlcA, st.balanceB + st.htlcB, "refund"];
  }

  // ------------------------------------------------------------------------
  // DIRECTED: HTLC claim-XOR-refund exclusivity, both directions, both outcomes.
  // ------------------------------------------------------------------------
  it("HTLC: preimage-before-timeout claims to receiver; timeout refunds funder; never both", async function () {
    const token = await tokenF.deploy("Mock", "MCK", 18);
    await token.waitForDeployment();
    const tokenAddr = await token.getAddress();
    const window = 3600n;

    const [_, A, B] = signers;
    for (const a of [A, B]) {
      await (await token.mint(a.address, E("1000000"))).wait();
    }

    async function scenario(settleInTime) {
      const sc = await scF.deploy(window);
      await sc.waitForDeployment();
      const scAddr = await sc.getAddress();
      await (await token.connect(A).approve(scAddr, ethers.MaxUint256)).wait();
      await (await token.connect(B).approve(scAddr, ethers.MaxUint256)).wait();
      const domain = domainFor(scAddr);

      const depA = E(100),
        depB = E(60);
      const rc = await (await sc.connect(A).open(A.address, B.address, tokenAddr, depA, depB)).wait();
      let id = null;
      for (const log of rc.logs) {
        try {
          const p = sc.interface.parseLog(log);
          if (p && p.name === "Opened") id = p.args.channelId;
        } catch (_) {}
      }

      // State nonce 1: A pays B 10 via HTLC (htlcLockedFromA = 10), rest split.
      const preimage = ethers.keccak256("0x1337beef");
      const lockHash = ethers.keccak256(preimage);
      const lockTimeout = (await now()) + 1800n; // inside the window
      const st = {
        channelId: id,
        nonce: 1n,
        balanceA: E(90),
        balanceB: E(60),
        htlcA: E(10),
        htlcB: 0n,
        lockHash,
        lockTimeout,
        preimage,
      };
      const signed = await signState(domain, A, B, st);
      st.struct = signed.struct;
      st.sigA = signed.sigA;
      st.sigB = signed.sigB;

      await (await sc.connect(A).startChallenge(st.struct, st.sigA, st.sigB)).wait();

      if (settleInTime) {
        await (await sc.connect(B).settleHtlc(id, preimage)).wait();
        // A second settle is rejected.
        await expect(sc.settleHtlc(id, preimage)).to.be.revertedWithCustomError(sc, "HtlcAlreadyResolved");
      } else {
        // Jump past lockTimeout; settle now reverts (refund-only from here).
        await time.increaseTo(Number(lockTimeout) + 1);
        await expect(sc.settleHtlc(id, preimage)).to.be.revertedWithCustomError(sc, "LockExpired");
      }

      // Finalize after the challenge window.
      await time.increase(Number(window) + 5);
      await (await sc.finalize(id)).wait();

      const b0A = await token.balanceOf(A.address);
      const b0B = await token.balanceOf(B.address);
      await (await sc.connect(A).withdraw(id)).wait();
      await (await sc.connect(B).withdraw(id)).wait();
      const gotA = (await token.balanceOf(A.address)) - b0A;
      const gotB = (await token.balanceOf(B.address)) - b0B;

      // Conservation: exactly the deposits are paid out, no more.
      expect(gotA + gotB, `[HTLC CONSERVE settle=${settleInTime}]`).to.equal(depA + depB);
      // Contract fully drained.
      expect(await token.balanceOf(scAddr), `[HTLC DRAIN settle=${settleInTime}]`).to.equal(0n);
      if (settleInTime) {
        // Receiver B got the 10 HTLC on top of its 60.
        expect(gotB, "[HTLC settled -> receiver]").to.equal(E(70));
        expect(gotA, "[HTLC settled -> funder loses lock]").to.equal(E(90));
      } else {
        // Refund: funder A keeps its 10 back.
        expect(gotA, "[HTLC expired -> funder refunded]").to.equal(E(100));
        expect(gotB, "[HTLC expired -> receiver gets nothing extra]").to.equal(E(60));
      }
    }

    await scenario(true);
    await scenario(false);
  });

  // ------------------------------------------------------------------------
  // DIRECTED: a strictly-newer signed state ALWAYS beats a stale one submitted
  // first, and a stale override can never roll a channel back (I4).
  // ------------------------------------------------------------------------
  it("stale signed state cannot override a strictly-newer one during the challenge window", async function () {
    const token = await tokenF.deploy("Mock", "MCK", 18);
    await token.waitForDeployment();
    const tokenAddr = await token.getAddress();
    const window = 7200n;
    const [_, A, B] = signers;
    await (await token.mint(A.address, E("1000000"))).wait();
    await (await token.mint(B.address, E("1000000"))).wait();

    const sc = await scF.deploy(window);
    await sc.waitForDeployment();
    const scAddr = await sc.getAddress();
    await (await token.connect(A).approve(scAddr, ethers.MaxUint256)).wait();
    await (await token.connect(B).approve(scAddr, ethers.MaxUint256)).wait();
    const domain = domainFor(scAddr);

    const depA = E(100),
      depB = E(100);
    const rc = await (await sc.connect(A).open(A.address, B.address, tokenAddr, depA, depB)).wait();
    let id = null;
    for (const log of rc.logs) {
      try {
        const p = sc.interface.parseLog(log);
        if (p && p.name === "Opened") id = p.args.channelId;
      } catch (_) {}
    }

    async function mk(nonce, a, b) {
      const st = { channelId: id, nonce: BigInt(nonce), balanceA: E(a), balanceB: E(b), htlcA: 0n, htlcB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
      const s = await signState(domain, A, B, st);
      st.struct = s.struct;
      st.sigA = s.sigA;
      st.sigB = s.sigB;
      return st;
    }
    const stStale = await mk(1, 150, 50); // A-favouring stale
    const stFresh = await mk(5, 20, 180); // B-favouring newer
    const stMid = await mk(3, 90, 110);

    // B starts the challenge with the FRESH (nonce 5) state.
    await (await sc.connect(B).startChallenge(stFresh.struct, stFresh.sigA, stFresh.sigB)).wait();
    // A tries to roll back to the stale nonce-1 state -> rejected.
    await expect(sc.connect(A).overrideChallenge(stStale.struct, stStale.sigA, stStale.sigB)).to.be.revertedWithCustomError(sc, "NonceNotHigher");
    // Even an equal or in-between older nonce is rejected.
    await expect(sc.connect(A).overrideChallenge(stMid.struct, stMid.sigA, stMid.sigB)).to.be.revertedWithCustomError(sc, "NonceNotHigher");

    await time.increase(Number(window) + 1);
    await (await sc.finalize(id)).wait();
    const b0A = await token.balanceOf(A.address);
    const b0B = await token.balanceOf(B.address);
    await (await sc.connect(A).withdraw(id)).wait();
    await (await sc.connect(B).withdraw(id)).wait();
    // The NEWER (nonce 5) allocation wins: A=20, B=180.
    expect((await token.balanceOf(A.address)) - b0A).to.equal(E(20));
    expect((await token.balanceOf(B.address)) - b0B).to.equal(E(180));
    expect(await token.balanceOf(scAddr)).to.equal(0n);
  });
});
