// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereSpokePool + ERC-7683 cross-chain intent stack.
//
// Source under test (matched to the live addresses in sdk-js/src/addresses.ts):
//   contracts/bridge/AereSpokePool.sol   -> 0xCAB1DBA5f6F06198000C20a974d675f1B3181AbD
//   contracts/bridge/AereERC7683.sol     -> 0x67Fb9830e3a2BC06cEb641cfF3beD87b273ccb29
// Messaging layer: the REAL contracts/bridge/AereMessenger.sol (Hyperlane-style
// ECDSA-multisig). We drive settlement end-to-end through it with a real
// validator signature -- NO messenger mock -- so any finding cannot be blamed
// on a toy relayer.
//
// TOOLCHAIN: hardhat-based seeded 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.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/spokepool-intents-invariant-property.test.js
// No deploy to any live node, no contract-logic change, one new test file only.
//
// INVARIANTS FUZZED (exactly the brief):
//   * NO DOUBLE-SPEND: a deposit/order is fillable at most once; a second fill
//     of the same relay reverts; a settled deposit cannot be settled again.
//   * REPAYMENT == inputAmount (minus the spread the solver already conceded on
//     the output leg); a settlement credits the solver the locked input exactly
//     once, never more.
//   * REFUND only after fillDeadline AND only if still unresolved (not settled,
//     not already refunded).
//   * NO repayment without a valid fill (the security boundary of `handle`).
//   * CONSERVATION: the pool's token balance always equals the sum of still-open
//     deposit inputs plus the sum of unclaimed solver settlements. Funds are
//     never minted.
//
// A real invariant violation is a valuable result: the FINDING test at the
// bottom is a deterministic minimal repro, not papered over.
//
// 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) : 0x5B0CE7;
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 AbiCoder = ethers.AbiCoder.defaultAbiCoder();
const U = (n, d = 6) => BigInt(Math.round(Number(n) * 10 ** 3)) * 10n ** BigInt(d - 3); // USDC-ish 6dp
const b32 = (addr) => ethers.zeroPadValue(addr, 32);
const MAXU = ethers.MaxUint256;

const O_DOMAIN = 2800; // AERE origin
const D_DOMAIN = 8453; // Base-like destination

// Deterministic validator key for the messenger (this account signs inbound
// settlement messages; it never sends a transaction).
const VALIDATOR_PK = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";

const N = {
  CAMPAIGNS: Number(process.env.SP_CAMPAIGNS || 6),
  STEPS: Number(process.env.SP_STEPS || 60),
};

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

// =============================================================================
describe("INVARIANT: AereSpokePool + ERC-7683 cross-chain intents", function () {
  this.timeout(0);

  let signers, messengerF, spokeF, erc20F, settlerF;
  let validator;
  let msgNonce = 0; // monotonically-increasing inbound nonce so the messenger never self-rejects on replay

  before(async function () {
    signers = await ethers.getSigners();
    messengerF = await ethers.getContractFactory("AereMessenger");
    spokeF = await ethers.getContractFactory("contracts/bridge/AereSpokePool.sol:AereSpokePool");
    settlerF = await ethers.getContractFactory("AereERC7683");
    erc20F = await ethers.getContractFactory("MockERC20Lending");
    validator = new ethers.Wallet(VALIDATOR_PK, ethers.provider);
    console.log(
      `      [spoke] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} randomized steps`
    );
  });

  // Two SpokePools facing each other across two domains, each behind a real
  // AereMessenger. spokeO (origin) is the one that holds and pays out locked
  // input. spokeD (destination) is where solvers deliver output + emit the fill.
  async function deployStack() {
    const deployer = signers[0];

    const messengerO = await messengerF.connect(deployer).deploy(O_DOMAIN);
    await messengerO.waitForDeployment();
    const messengerD = await messengerF.connect(deployer).deploy(D_DOMAIN);
    await messengerD.waitForDeployment();

    const spokeO = await spokeF.connect(deployer).deploy(await messengerO.getAddress());
    await spokeO.waitForDeployment();
    const spokeD = await spokeF.connect(deployer).deploy(await messengerD.getAddress());
    await spokeD.waitForDeployment();

    const spokeOAddr = await spokeO.getAddress();
    const spokeDAddr = await spokeD.getAddress();

    // Cross-enroll the two spokes.
    await (await spokeO.enrollRemoteSpokePool(D_DOMAIN, b32(spokeDAddr))).wait();
    await (await spokeD.enrollRemoteSpokePool(O_DOMAIN, b32(spokeOAddr))).wait();

    // The origin messenger accepts settlement messages from our validator.
    await (await messengerO.addValidator(validator.address)).wait();
    // threshold already 1

    // Input token lives on origin; output token lives on destination.
    const usdc = await erc20F.deploy("USD Coin", "USDC", 6);
    await usdc.waitForDeployment();
    const usdd = await erc20F.deploy("Dest USD", "USDd", 6);
    await usdd.waitForDeployment();

    return {
      messengerO, messengerD, spokeO, spokeD, spokeOAddr, spokeDAddr,
      usdc, usdd, usdcAddr: await usdc.getAddress(), usddAddr: await usdd.getAddress(),
    };
  }

  async function fund(usdc, usdd, spokeOAddr, spokeDAddr, users, solvers) {
    for (const u of users) {
      await (await usdc.mint(u.address, U(100_000_000))).wait();
      await (await usdc.connect(u).approve(spokeOAddr, MAXU)).wait();
    }
    for (const s of solvers) {
      await (await usdd.mint(s.address, U(100_000_000))).wait();
      await (await usdd.connect(s).approve(spokeDAddr, MAXU)).wait();
    }
  }

  // Build the RelayData tuple fillRelay expects.
  function relayData(o) {
    return [
      O_DOMAIN, o.depositId, o.depositor, o.inputToken, o.inputAmount,
      o.outputToken, o.outputAmount, o.recipient, o.fillDeadline, o.solverRepaymentAddress,
    ];
  }

  // Faithful cross-chain relay of the settlement message that spokeD.fillRelay
  // dispatched: reconstruct the exact body, get the validator to sign the
  // Hyperlane-style messageId, and process() it on the origin messenger, which
  // invokes spokeO.handle. Returns the tx (so callers can assert reverts).
  async function relaySettlement(ctx, stack, body, opts = {}) {
    const { messengerO, spokeOAddr, spokeDAddr } = stack;
    const nonce = opts.nonce !== undefined ? opts.nonce : msgNonce++;
    const sender = b32(spokeDAddr);
    const recipientB32 = b32(spokeOAddr);
    const messageId = ethers.keccak256(
      AbiCoder.encode(
        ["uint32", "uint32", "uint32", "bytes32", "bytes32", "bytes"],
        [D_DOMAIN, O_DOMAIN, nonce, sender, recipientB32, body]
      )
    );
    const sig = await validator.signMessage(ethers.getBytes(messageId));
    return messengerO.process(sig, D_DOMAIN, sender, spokeOAddr, body, nonce);
  }

  // The corrected settlement payload commits to the OUTPUT the solver actually
  // delivered (outputToken / outputAmount / recipient) alongside the input leg,
  // so the origin can verify the fill satisfied the deposit order before it
  // releases any locked principal. This mirrors AereSpokePool.fillRelay's
  // dispatched body exactly, so relaying it reproduces the real cross-chain path.
  function settlementBody(depositId, inputToken, inputAmount, outputToken, outputAmount, recipient, solver, solverRepayment) {
    return AbiCoder.encode(
      ["uint32", "address", "uint256", "address", "uint256", "address", "address", "address"],
      [depositId, inputToken, inputAmount, outputToken, outputAmount, recipient, solver, solverRepayment]
    );
  }

  // -------------------------------------------------------------------------
  // MAIN FUZZ: honest multi-actor interleaving. Every invariant below must hold
  // after every single step.
  // -------------------------------------------------------------------------
  it("randomized multi-actor: conservation, repayment-exact-once, refund-only-if-unfilled-and-expired", async function () {
    let steps = 0, deps = 0, fills = 0, settles = 0, refunds = 0, claims = 0, expired = 0;

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("main", c);
      const stack = await deployStack();
      const { spokeO, spokeD, spokeOAddr, usdc, usdcAddr, usddAddr } = stack;
      const users = [signers[1], signers[2], signers[3], signers[4]];
      const solvers = [signers[5], signers[6], signers[7]];
      await fund(usdc, stack.usdd, spokeOAddr, stack.spokeDAddr, users, solvers);

      // Oracle: mirror of every deposit's lifecycle + the JS-side settlement ledger.
      const dq = new Map(); // depositId -> record
      const pending = new Map(); // payoutAddr -> unclaimed bigint (input token)
      const addPending = (a, v) => pending.set(a, (pending.get(a) || 0n) + v);

      // Master invariant battery, run after every step.
      async function assertInvariants(ctx) {
        // (1) CONSERVATION: pool balance == open deposit inputs + unclaimed settlements.
        let openSum = 0n;
        for (const d of dq.values()) if (d.status === "OPEN") openSum += d.inputAmount;
        let pendSum = 0n;
        for (const [addr, v] of pending) {
          pendSum += v;
          // cross-check the JS ledger against the on-chain accumulator.
          const onchain = await spokeO.pendingSettlement(addr, usdcAddr);
          expect(onchain, `[spoke PENDING-XCHK] ${ctx} addr=${addr.slice(0, 8)}`).to.equal(v);
        }
        const bal = await usdc.balanceOf(spokeOAddr);
        expect(bal, `[spoke CONSERVE] balance != openDeposits(${openSum}) + pending(${pendSum}) ${ctx}`)
          .to.equal(openSum + pendSum);

        // (2) never minted: pool cannot hold more than the input that ever entered.
        expect(bal, `[spoke NO-MINT] ${ctx}`).to.be.lte(openSum + pendSum);

        // (3) per-deposit repayment-exact-once: a SETTLED deposit credited its
        //     solver exactly inputAmount, and could only reach SETTLED via a fill.
        for (const d of dq.values()) {
          if (d.status === "SETTLED") {
            expect(d.credited, `[spoke REPAY-EXACT] id=${d.id} credited ${d.credited} != input ${d.inputAmount} ${ctx}`)
              .to.equal(d.inputAmount);
            expect(d.genuinelyFilled, `[spoke NO-FREE-REPAY] settled id=${d.id} was never filled ${ctx}`).to.equal(true);
          }
        }
      }

      await assertInvariants(`c${c} init`);

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

        try {
          if (action === "deposit") {
            const user = pick(rng, users);
            const inAmt = U(ri(rng, 1, 50_000)) + BigInt(ri(rng, 0, 999999));
            const spread = U(ri(rng, 0, 200)); // solver's fee = input - output value
            const outAmt = inAmt > spread ? inAmt - spread : 1n;
            const now = await latestTs();
            const life = ri(rng, 120, 5 * 24 * 3600);
            const deadline = now + life;
            const recipient = pick(rng, users).address;
            await (await spokeO.connect(user).deposit(
              usdcAddr, inAmt, usddAddr, outAmt, D_DOMAIN, recipient, deadline
            )).wait();
            const id = Number(await spokeO.depositCounter());
            dq.set(id, {
              id, depositor: user, inputAmount: inAmt, outputAmount: outAmt,
              recipient, deadline, status: "OPEN", genuinelyFilled: false, credited: 0n,
            });
            deps++;
          } else if (action === "fillAndSettle") {
            // pick an OPEN deposit still inside its fill window.
            const now = await latestTs();
            const cands = [...dq.values()].filter((d) => d.status === "OPEN" && d.deadline > now + 2);
            if (!cands.length) { steps++; continue; }
            const d = pick(rng, cands);
            const solver = pick(rng, solvers);
            const repayTo = chance(rng, 0.3) ? pick(rng, solvers).address : solver.address;
            // HONEST fill: deliver the exact outputAmount to the exact recipient.
            const rd = relayData({
              depositId: d.id, depositor: d.depositor.address, inputToken: usdcAddr,
              inputAmount: d.inputAmount, outputToken: usddAddr, outputAmount: d.outputAmount,
              recipient: d.recipient, fillDeadline: d.deadline, solverRepaymentAddress: repayTo,
            });
            const recvBefore = await stack.usdd.balanceOf(d.recipient);
            await (await spokeD.connect(solver).fillRelay(rd)).wait();
            const recvAfter = await stack.usdd.balanceOf(d.recipient);
            expect(recvAfter - recvBefore, `[spoke FILL-DELIVERS] id=${d.id} ${ctx}`).to.equal(d.outputAmount);
            d.genuinelyFilled = true;
            fills++;

            // double-fill of the identical relay must revert.
            await expect(spokeD.connect(solver).fillRelay(rd), `[spoke NO-DOUBLE-FILL] id=${d.id} ${ctx}`)
              .to.be.reverted;

            // relay the settlement to origin (unless we randomly defer it).
            if (chance(rng, 0.85)) {
              // The relayed body commits to the output the solver actually
              // delivered above (the honest outputToken / outputAmount / recipient).
              const body = settlementBody(d.id, usdcAddr, d.inputAmount, usddAddr, d.outputAmount, d.recipient, solver.address, repayTo);
              await (await relaySettlement(ctx, stack, body)).wait();
              d.status = "SETTLED";
              d.credited = d.inputAmount;
              addPending(repayTo, d.inputAmount);
              settles++;

              // replay the SAME settlement (fresh messenger nonce so the messenger
              // itself does not reject it): spokeO.handle must reject on `settled`.
              const body2 = settlementBody(d.id, usdcAddr, d.inputAmount, usddAddr, d.outputAmount, d.recipient, solver.address, repayTo);
              await expect(relaySettlement(ctx, stack, body2), `[spoke NO-DOUBLE-SETTLE] id=${d.id} ${ctx}`)
                .to.be.reverted;
            }
          } else if (action === "refund") {
            const now = await latestTs();
            const open = [...dq.values()].filter((d) => d.status === "OPEN");
            if (!open.length) { steps++; continue; }
            const d = pick(rng, open);
            if (d.deadline >= now) {
              // refund before the deadline must revert (invariant: refund only after expiry).
              await expect(spokeO.connect(d.depositor).claimRefund(d.id), `[spoke NO-EARLY-REFUND] id=${d.id} ${ctx}`)
                .to.be.reverted;
              steps++;
              await assertInvariants(ctx);
              continue;
            }
            const before = await usdc.balanceOf(d.depositor.address);
            await (await spokeO.connect(d.depositor).claimRefund(d.id)).wait();
            const got = (await usdc.balanceOf(d.depositor.address)) - before;
            expect(got, `[spoke REFUND-EXACT] id=${d.id} got ${got} != input ${d.inputAmount} ${ctx}`)
              .to.equal(d.inputAmount);
            d.status = "REFUNDED";
            refunds++;
            expired++;
          } else if (action === "claim") {
            // a solver withdraws its accumulated repayment.
            const holders = [...pending.entries()].filter(([, v]) => v > 0n);
            if (!holders.length) { steps++; continue; }
            const [addr, amt] = pick(rng, holders);
            const signer = signers.find((s) => s.address === addr);
            if (!signer) { steps++; continue; }
            const before = await usdc.balanceOf(addr);
            await (await spokeO.connect(signer).claimSettlement(usdcAddr)).wait();
            const got = (await usdc.balanceOf(addr)) - before;
            expect(got, `[spoke CLAIM-EXACT] addr=${addr.slice(0, 8)} got ${got} != pending ${amt} ${ctx}`)
              .to.equal(amt);
            pending.set(addr, 0n);
            claims++;
          } else if (action === "advance") {
            await time.increase(ri(rng, 60, 3 * 24 * 3600));
            await network.provider.send("evm_mine");
          }
        } catch (e) {
          if (/spoke [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break
          // otherwise a legitimate revert on an edge action -> state unchanged, invariants still checked.
        }

        await assertInvariants(ctx);
        steps++;
      }

      // Finale: expire everything, refund every still-open deposit, claim every
      // pending settlement, and prove the pool drains to exactly zero.
      await time.increase(10 * 24 * 3600);
      await network.provider.send("evm_mine");
      for (const d of dq.values()) {
        if (d.status === "OPEN") {
          await (await spokeO.connect(d.depositor).claimRefund(d.id)).wait();
          d.status = "REFUNDED";
        }
      }
      for (const [addr, v] of [...pending.entries()]) {
        if (v > 0n) {
          const signer = signers.find((s) => s.address === addr);
          if (signer) {
            await (await spokeO.connect(signer).claimSettlement(usdcAddr)).wait();
            pending.set(addr, 0n);
          }
        }
      }
      await assertInvariants(`c${c} finale`);
      const residual = await usdc.balanceOf(spokeOAddr);
      let residualPending = 0n;
      for (const v of pending.values()) residualPending += v;
      expect(residual, `[spoke DRAIN] pool not fully drained c${c}`).to.equal(residualPending);
    }

    console.log(
      `      [spoke main] steps=${steps} deposits=${deps} fills=${fills} settles=${settles} ` +
        `refunds=${refunds} (expired=${expired}) claims=${claims}`
    );
    expect(steps).to.be.greaterThan(300);
    expect(deps).to.be.greaterThan(40);
    expect(fills).to.be.greaterThan(20);
    expect(settles).to.be.greaterThan(15);
    expect(refunds + claims).to.be.greaterThan(10);
  });

  // -------------------------------------------------------------------------
  // TARGETED GUARDS: deterministic checks on the exact revert paths.
  // -------------------------------------------------------------------------
  it("targeted guards: wrong caller / wrong sender / expired-fill / settle-vs-refund exclusivity", async function () {
    const [_, alice, solver, mallory] = signers;
    const stack = await deployStack();
    const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
    await fund(usdc, usdd, spokeOAddr, spokeDAddr, [alice], [solver]);

    const now = await latestTs();
    const deadline = now + 3600;
    await (await spokeO.connect(alice).deposit(usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, alice.address, deadline)).wait();
    const id = Number(await spokeO.depositCounter());

    // handle is messenger-gated: a random EOA cannot call it.
    await expect(
      spokeO.connect(mallory).handle(D_DOMAIN, b32(spokeDAddr), settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), alice.address, solver.address, solver.address)),
      "[spoke HANDLE-CALLER] non-messenger accepted"
    ).to.be.reverted;

    // A settlement whose `sender` is NOT the enrolled remote spoke is rejected.
    const badBody = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), alice.address, solver.address, solver.address);
    const badNonce = msgNonce++;
    const badSender = b32(mallory.address);
    const badId = ethers.keccak256(
      AbiCoder.encode(["uint32", "uint32", "uint32", "bytes32", "bytes32", "bytes"],
        [D_DOMAIN, O_DOMAIN, badNonce, badSender, b32(spokeOAddr), badBody])
    );
    const badSig = await validator.signMessage(ethers.getBytes(badId));
    await expect(
      stack.messengerO.process(badSig, D_DOMAIN, badSender, spokeOAddr, badBody, badNonce),
      "[spoke HANDLE-SENDER] unknown remote spoke accepted"
    ).to.be.reverted;

    // Fill after the deadline reverts (fill window closed).
    await time.increase(4000);
    await network.provider.send("evm_mine");
    const rdLate = relayData({
      depositId: id, depositor: alice.address, inputToken: usdcAddr, inputAmount: U(1000),
      outputToken: usddAddr, outputAmount: U(999), recipient: alice.address,
      fillDeadline: deadline, solverRepaymentAddress: solver.address,
    });
    await expect(spokeD.connect(solver).fillRelay(rdLate), "[spoke FILL-EXPIRED] late fill accepted").to.be.reverted;

    // Refund is gated on fillDeadline + REFUND_BUFFER (the settlement-race fix),
    // so advance past the buffer before the depositor can recover an unfilled
    // deposit. Now expired + past-buffer + unfilled -> refund works exactly once.
    const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
    await time.increase(REFUND_BUFFER + 10);
    await network.provider.send("evm_mine");
    await (await spokeO.connect(alice).claimRefund(id)).wait();
    await expect(spokeO.connect(alice).claimRefund(id), "[spoke REFUND-ONCE] double refund accepted").to.be.reverted;

    // A second, still-live deposit that gets SETTLED can never be refunded.
    const now2 = await latestTs();
    const dl2 = now2 + 3600;
    await (await spokeO.connect(alice).deposit(usdcAddr, U(500), usddAddr, U(499), D_DOMAIN, alice.address, dl2)).wait();
    const id2 = Number(await spokeO.depositCounter());
    const rd2 = relayData({
      depositId: id2, depositor: alice.address, inputToken: usdcAddr, inputAmount: U(500),
      outputToken: usddAddr, outputAmount: U(499), recipient: alice.address,
      fillDeadline: dl2, solverRepaymentAddress: solver.address,
    });
    await (await spokeD.connect(solver).fillRelay(rd2)).wait();
    await (await relaySettlement("guard-settle", stack, settlementBody(id2, usdcAddr, U(500), usddAddr, U(499), alice.address, solver.address, solver.address))).wait();
    await time.increase(4000);
    await network.provider.send("evm_mine");
    await expect(spokeO.connect(alice).claimRefund(id2), "[spoke NO-REFUND-AFTER-SETTLE] settled deposit refunded").to.be.reverted;

    console.log("      [spoke guards] all negative paths reverted as expected");
  });

  // -------------------------------------------------------------------------
  // FIX (HIGH) -- regression proof for the zero-output / mismatched-output theft.
  //
  // BEFORE THE FIX: settlement authenticated a fill by (depositId, inputToken,
  // inputAmount) ONLY. The output leg actually delivered on the destination
  // (outputToken / outputAmount / recipient) was never transmitted to nor
  // verified by the origin `handle`, and fillRelay did not require
  // outputAmount > 0. So any address could read a victim's public deposit,
  // fillRelay it with outputAmount = 0 delivering NOTHING, relay the genuine
  // validator-signable settlement, mark the deposit `settled`, and drain the
  // victim's entire locked principal while permanently blocking their refund.
  //
  // AFTER THE FIX: the fill commits the delivered output into the settlement
  // payload, and the origin releases principal only when that output satisfies
  // the stored order (correct token, correct recipient, amount >= requested),
  // with outputAmount = 0 rejected outright at fill time. This test proves the
  // theft is dead on every variant, that the victim stays refundable, and that
  // a GENUINE relayer who actually delivers the requested output is still
  // repaid exactly once.
  // -------------------------------------------------------------------------
  it("FIX (HIGH): no fill can settle without delivering the requested output; victim stays refundable; honest relayer repaid exactly once", async function () {
    const [_, victim, attacker, honestRecipient, honestSolver] = signers;
    const stack = await deployStack();
    const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
    await fund(usdc, usdd, spokeOAddr, spokeDAddr, [victim], [attacker, honestSolver]);

    // Victim opens an honest order: lock 10,000 USDC, expecting 9,990 USDd out
    // to honestRecipient on the destination chain.
    const now = await latestTs();
    const deadline = now + 24 * 3600;
    await (await spokeO.connect(victim).deposit(
      usdcAddr, U(10_000), usddAddr, U(9_990), D_DOMAIN, honestRecipient.address, deadline
    )).wait();
    const id = Number(await spokeO.depositCounter());
    expect(await usdc.balanceOf(spokeOAddr), "[spoke FIX-LOCKED] victim principal not locked").to.equal(U(10_000));

    // ATTACK 1 -- zero-output fill. Delivers nothing; must be rejected at fill.
    const rdZero = relayData({
      depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
      outputToken: usddAddr, outputAmount: 0n, recipient: attacker.address,
      fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
    });
    await expect(
      spokeD.connect(attacker).fillRelay(rdZero),
      "[spoke FIX-ZERO-OUTPUT] zero-output fill was accepted"
    ).to.be.reverted;

    // ATTACK 2 -- deliver a token amount to the WRONG recipient (the attacker),
    // then relay the genuine settlement. fillRelay succeeds (a real 1-unit
    // transfer) and dispatches a genuine message, but the origin must REJECT the
    // settlement because the delivered output does not satisfy the order.
    const recipBefore = await usdd.balanceOf(honestRecipient.address);
    const rdWrongRecipient = relayData({
      depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
      outputToken: usddAddr, outputAmount: 1n, recipient: attacker.address,
      fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
    });
    await (await spokeD.connect(attacker).fillRelay(rdWrongRecipient)).wait();
    expect(await usdd.balanceOf(honestRecipient.address), "[spoke FIX-NO-DELIVERY] victim recipient received output")
      .to.equal(recipBefore);
    const evilBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, 1n, attacker.address, attacker.address, attacker.address);
    await expect(
      relaySettlement("fix-wrong-recipient", stack, evilBody),
      "[spoke FIX-RECIPIENT] mismatched-recipient settlement was accepted"
    ).to.be.reverted;

    // ATTACK 3 -- even delivering the FULL requested amount but to the attacker's
    // own address (wrong recipient) must not settle.
    const rdFullWrongRecipient = relayData({
      depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
      outputToken: usddAddr, outputAmount: U(9_990), recipient: attacker.address,
      fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
    });
    await (await spokeD.connect(attacker).fillRelay(rdFullWrongRecipient)).wait();
    const evilBody2 = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), attacker.address, attacker.address, attacker.address);
    await expect(
      relaySettlement("fix-full-wrong-recipient", stack, evilBody2),
      "[spoke FIX-RECIPIENT2] full-amount-wrong-recipient settlement was accepted"
    ).to.be.reverted;

    // ATTACK 4 -- deliver the RIGHT recipient but LESS than requested (short-fill).
    const rdShort = relayData({
      depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
      outputToken: usddAddr, outputAmount: U(1), recipient: honestRecipient.address,
      fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
    });
    await (await spokeD.connect(attacker).fillRelay(rdShort)).wait();
    const shortBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(1), honestRecipient.address, attacker.address, attacker.address);
    await expect(
      relaySettlement("fix-short-fill", stack, shortBody),
      "[spoke FIX-SHORTFILL] under-delivered settlement was accepted"
    ).to.be.reverted;

    // Nothing was credited and the principal is still fully locked.
    expect(await spokeO.pendingSettlement(attacker.address, usdcAddr), "[spoke FIX-NO-CREDIT] attacker was credited")
      .to.equal(0n);
    expect(await usdc.balanceOf(spokeOAddr), "[spoke FIX-STILL-LOCKED] principal released by an invalid fill")
      .to.equal(U(10_000));

    // GENUINE relayer delivers the requested output to the requested recipient.
    // This must settle and repay the honest solver exactly the locked input.
    const rBefore = await usdd.balanceOf(honestRecipient.address);
    const rdHonest = relayData({
      depositId: id, depositor: victim.address, inputToken: usdcAddr, inputAmount: U(10_000),
      outputToken: usddAddr, outputAmount: U(9_990), recipient: honestRecipient.address,
      fillDeadline: deadline, solverRepaymentAddress: honestSolver.address,
    });
    await (await spokeD.connect(honestSolver).fillRelay(rdHonest)).wait();
    expect((await usdd.balanceOf(honestRecipient.address)) - rBefore, "[spoke FIX-HONEST-DELIVERS] recipient not paid")
      .to.equal(U(9_990));
    const okBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), honestRecipient.address, honestSolver.address, honestSolver.address);
    await (await relaySettlement("fix-honest", stack, okBody)).wait();
    expect(await spokeO.pendingSettlement(honestSolver.address, usdcAddr), "[spoke FIX-HONEST-CREDIT] honest solver not credited")
      .to.equal(U(10_000));

    // Repaid EXACTLY once: replaying the same genuine settlement now reverts.
    const okBody2 = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), honestRecipient.address, honestSolver.address, honestSolver.address);
    await expect(
      relaySettlement("fix-honest-replay", stack, okBody2),
      "[spoke FIX-REPAY-ONCE] genuine settlement replayed"
    ).to.be.reverted;

    const hsBefore = await usdc.balanceOf(honestSolver.address);
    await (await spokeO.connect(honestSolver).claimSettlement(usdcAddr)).wait();
    expect((await usdc.balanceOf(honestSolver.address)) - hsBefore, "[spoke FIX-CLAIM-EXACT] honest solver payout wrong")
      .to.equal(U(10_000));

    // ---- Refund still works after expiry on a deposit that was only attacked. ----
    const now2 = await latestTs();
    const dl2 = now2 + 3600;
    await (await spokeO.connect(victim).deposit(
      usdcAddr, U(5_000), usddAddr, U(4_995), D_DOMAIN, honestRecipient.address, dl2
    )).wait();
    const id2 = Number(await spokeO.depositCounter());

    // Attacker tries the same theft on this deposit and fails at every step.
    await expect(
      spokeD.connect(attacker).fillRelay(relayData({
        depositId: id2, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(5_000),
        outputToken: usddAddr, outputAmount: 0n, recipient: attacker.address,
        fillDeadline: dl2, solverRepaymentAddress: attacker.address,
      })),
      "[spoke FIX-ZERO-OUTPUT2] zero-output fill accepted on second deposit"
    ).to.be.reverted;

    // After the deadline AND the refund buffer, with no genuine fill, the victim
    // refunds exactly the input (buffer added by the settlement-race fix).
    const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
    await time.increase(3601 + REFUND_BUFFER);
    await network.provider.send("evm_mine");
    const vBefore = await usdc.balanceOf(victim.address);
    await (await spokeO.connect(victim).claimRefund(id2)).wait();
    expect((await usdc.balanceOf(victim.address)) - vBefore, "[spoke FIX-REFUND] victim could not recover an unfilled deposit")
      .to.equal(U(5_000));

    console.log(
      "      [spoke FIX] zero-output / wrong-recipient / short-fill settlements all reverted; " +
        "principal never released without a valid fill; honest solver repaid exactly once; victim refund intact"
    );
  });

  // -------------------------------------------------------------------------
  // FIX (MEDIUM) -- theft-of-principal under relay latency (refund-vs-settlement race).
  //
  // BEFORE THE FIX: claimRefund gated the depositor's refund purely on a local
  // timer, `block.timestamp > d.fillDeadline`. A solver may legitimately fill on
  // the destination at any t < fillDeadline (fillRelay irreversibly delivers the
  // output), while the authenticated settlement that credits the solver is still
  // in flight across the messenger (destination finality + validator signing +
  // relayer submission = latency L). In the window [fillDeadline, fillDeadline+L]
  // the depositor could front-run the in-flight settlement, pull the input back,
  // and the later handle() would revert on `refunded` -- the solver who already
  // delivered the output is never repaid.
  //
  // AFTER THE FIX: a refund requires `block.timestamp > fillDeadline +
  // REFUND_BUFFER`, where REFUND_BUFFER (3h) generously covers max relay latency,
  // so an in-flight settlement for a fill dispatched at/just-before the deadline
  // always lands (setting `settled`) before any refund is possible. The three
  // tests below encode exactly the brief: (a) fill-just-before-deadline settles
  // within the buffer and the depositor cannot refund; (b) a genuinely unfilled
  // deposit refunds only after the buffer; (c) a late settlement after a
  // legitimate expiry refund cannot double-pay.
  // -------------------------------------------------------------------------

  it("FIX (MEDIUM a): fill dispatched just before fillDeadline settles within the buffer; depositor cannot front-run the in-flight settlement with a timer refund", async function () {
    const [_, depositor, solver, recipient] = signers;
    const stack = await deployStack();
    const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
    await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], [solver]);

    const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
    expect(REFUND_BUFFER, "[spoke MED-BUFFER] REFUND_BUFFER must be a positive grace period").to.be.greaterThan(0);

    const now = await latestTs();
    const deadline = now + 3600;
    await (await spokeO.connect(depositor).deposit(
      usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, recipient.address, deadline
    )).wait();
    const id = Number(await spokeO.depositCounter());
    expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-LOCKED] principal not locked").to.equal(U(1000));

    // Solver legitimately fills on the destination JUST BEFORE the deadline.
    // This irreversibly delivers the output to the recipient and dispatches the
    // authenticated settlement -- which is now "in flight".
    await time.increaseTo(deadline - 30);
    const rd = relayData({
      depositId: id, depositor: depositor.address, inputToken: usdcAddr, inputAmount: U(1000),
      outputToken: usddAddr, outputAmount: U(999), recipient: recipient.address,
      fillDeadline: deadline, solverRepaymentAddress: solver.address,
    });
    const rBefore = await usdd.balanceOf(recipient.address);
    await (await spokeD.connect(solver).fillRelay(rd)).wait();
    expect((await usdd.balanceOf(recipient.address)) - rBefore, "[spoke MED-DELIVERED] solver did not deliver output")
      .to.equal(U(999));

    // Cross the fillDeadline but stay INSIDE the refund buffer: this is the exact
    // relay-latency window the old code let the depositor exploit.
    await time.increaseTo(deadline + 60);
    await expect(
      spokeO.connect(depositor).claimRefund(id),
      "[spoke MED-RACE] depositor front-ran the in-flight settlement inside the buffer"
    ).to.be.reverted;

    // The authenticated settlement now lands (still within the buffer). It wins
    // the race: the solver is credited exactly the locked input.
    const body = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), recipient.address, solver.address, solver.address);
    await (await relaySettlement("med-race", stack, body)).wait();
    expect(await spokeO.pendingSettlement(solver.address, usdcAddr), "[spoke MED-CREDIT] solver not credited by winning settlement")
      .to.equal(U(1000));

    // The deposit is now settled: even after the FULL buffer elapses, the
    // depositor can never refund (settled/refunded mutual exclusion preserved).
    await time.increaseTo(deadline + REFUND_BUFFER + 100);
    await expect(
      spokeO.connect(depositor).claimRefund(id),
      "[spoke MED-NO-REFUND-AFTER-SETTLE] settled deposit refunded after the buffer"
    ).to.be.reverted;

    // Solver withdraws exactly the input; no double-spend of principal occurred.
    const sBefore = await usdc.balanceOf(solver.address);
    await (await spokeO.connect(solver).claimSettlement(usdcAddr)).wait();
    expect((await usdc.balanceOf(solver.address)) - sBefore, "[spoke MED-CLAIM-EXACT] solver payout wrong")
      .to.equal(U(1000));
    expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-DRAIN] pool not drained to zero").to.equal(0n);

    console.log("      [spoke MED a] in-flight settlement beat the timer refund; solver repaid; no double-spend");
  });

  it("FIX (MEDIUM b): a genuinely unfilled deposit is refundable only AFTER fillDeadline + REFUND_BUFFER", async function () {
    const [_, depositor, , recipient] = signers;
    const stack = await deployStack();
    const { spokeO, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
    await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], []);

    const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
    const now = await latestTs();
    const deadline = now + 3600;
    await (await spokeO.connect(depositor).deposit(
      usdcAddr, U(2000), usddAddr, U(1990), D_DOMAIN, recipient.address, deadline
    )).wait();
    const id = Number(await spokeO.depositCounter());

    // Before the deadline: refund reverts.
    await expect(
      spokeO.connect(depositor).claimRefund(id),
      "[spoke MED-EARLY] refund allowed before the deadline"
    ).to.be.reverted;

    // Just past the deadline but inside the buffer: still reverts (the race window).
    await time.increaseTo(deadline + 10);
    await expect(
      spokeO.connect(depositor).claimRefund(id),
      "[spoke MED-INSIDE-BUFFER] refund allowed inside the buffer"
    ).to.be.reverted;

    // Approaching the buffer boundary from below: still reverts.
    await time.increaseTo(deadline + REFUND_BUFFER - 5);
    await expect(
      spokeO.connect(depositor).claimRefund(id),
      "[spoke MED-NEAR-BOUNDARY] refund allowed at/just-before the boundary"
    ).to.be.reverted;

    // Past the full buffer with no fill ever delivered: refund succeeds for
    // exactly the input.
    await time.increaseTo(deadline + REFUND_BUFFER + 5);
    const before = await usdc.balanceOf(depositor.address);
    await (await spokeO.connect(depositor).claimRefund(id)).wait();
    expect((await usdc.balanceOf(depositor.address)) - before, "[spoke MED-REFUND-EXACT] unfilled refund wrong")
      .to.equal(U(2000));

    // And only once.
    await expect(
      spokeO.connect(depositor).claimRefund(id),
      "[spoke MED-REFUND-ONCE] double refund accepted"
    ).to.be.reverted;

    console.log("      [spoke MED b] unfilled deposit refundable only after the buffer, exactly once");
  });

  it("FIX (MEDIUM c): a late settlement arriving after a legitimate expiry refund cannot double-pay", async function () {
    const [_, depositor, solver, recipient] = signers;
    const stack = await deployStack();
    const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
    await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], [solver]);

    const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
    const now = await latestTs();
    const deadline = now + 3600;
    await (await spokeO.connect(depositor).deposit(
      usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, recipient.address, deadline
    )).wait();
    const id = Number(await spokeO.depositCounter());

    // No fill happens at all. The buffer fully elapses -> depositor legitimately
    // refunds the entire input.
    await time.increaseTo(deadline + REFUND_BUFFER + 10);
    const before = await usdc.balanceOf(depositor.address);
    await (await spokeO.connect(depositor).claimRefund(id)).wait();
    expect((await usdc.balanceOf(depositor.address)) - before, "[spoke MED-C-REFUND] expiry refund wrong")
      .to.equal(U(1000));
    expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-C-DRAIN] pool should be empty after refund").to.equal(0n);

    // A (hypothetical) very-late, otherwise-authentic settlement now arrives.
    // handle() MUST reject it on the `refunded` flag: the input is already gone,
    // so crediting a solver would double-pay from an underfunded pool.
    const body = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), recipient.address, solver.address, solver.address);
    await expect(
      relaySettlement("med-late-after-refund", stack, body),
      "[spoke MED-C-DOUBLE-PAY] settlement credited a solver after the depositor refunded"
    ).to.be.reverted;

    // No credit was recorded and the pool remains empty: no double-spend.
    expect(await spokeO.pendingSettlement(solver.address, usdcAddr), "[spoke MED-C-NO-CREDIT] solver credited after refund")
      .to.equal(0n);
    expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-C-STILL-EMPTY] pool balance changed after rejected settlement")
      .to.equal(0n);

    console.log("      [spoke MED c] late settlement after expiry-refund rejected on `refunded`; no double-pay");
  });
});
