// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
// AereBugBountyVault (canonical mainnet 0x253fDCb248649396CBDaD320F81869A570d69cD3,
// 5% max single payout per sdk-js/src/addresses.ts).
//
// Source under test: contracts/security/AereBugBountyVault.sol. Bounty token is
// a standard ERC-20 (WAERE on mainnet); here a MockERC20Lending stands in, plus
// a MockReentrantBountyToken for the token-callback re-entry probe.
//
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry, `forge` is
// not installed; the repo builds through hardhat with OZ 4.9.6 + viaIR). Every
// random choice comes from mulberry32(seed); the base seed is printed at suite
// start and pinnable via FUZZ_SEED. On any break the failing
// (campaign, step, actor, action, values) is embedded in the assertion message
// for a minimal repro.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
//     npx hardhat test test/bugbountyvault-custody-invariant-property.test.js
// No deploy to any live node, no target-contract-logic change, one new test file
// plus one minimal re-entry mock.
//
// INVARIANTS FUZZED (exactly the AereBugBountyVault custody brief):
//   * CUSTODY: pay() is the ONLY value-out path and is gated by onlyTriager. No
//     non-triager address can move funds (pay / proposeTriager / cancel / accept
//     all revert for non-triagers). There is no arbitrary-recipient unprivileged
//     withdraw.
//   * BALANCE CONSERVATION: token.balanceOf(vault) == cumulative inflow (fund +
//     direct donations) minus cumulative legitimate payouts, EXACTLY, after every
//     step.
//   * CAP: every successful payout amount <= MAX_PAYOUT_BPS/10000 of the balance
//     at pay time, and therefore <= balance (no over-draw, no underflow). Any
//     amount above the cap reverts and moves nothing.
//   * NO OVER-DRAW: cumulative payouts never exceed cumulative inflow (balance
//     stays >= 0), and payoutNonce == number of successful pays.
//   * ROTATION: triager rotation honours the 14-day timelock (acceptTriager
//     reverts before earliest); only the current triager can propose/accept/
//     cancel; after acceptance custody transfers cleanly.
//   * NO RE-ENTRANCY DRAIN: a malicious bounty token that re-enters pay()/fund()
//     from within transfer()/transferFrom() cannot cause a second payout; the
//     ReentrancyGuard reverts the nested call.
//
// 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) : 0xB0117A11;
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 MAXU = ethers.MaxUint256;
const TIMELOCK = 14 * 24 * 60 * 60; // TRIAGER_TIMELOCK = 14 days

// Live vault uses 5% (500 bps). Fuzz across several cap regimes incl. extremes.
const BPS_SET = [1, 500, 2000, 5000, 9999, 10000];

// Random token amount: dust, small, and large so the integer cap math is stressed.
function rndAmount(rng) {
  const k = rng();
  if (k < 0.2) return BigInt(ri(rng, 1, 1_000_000)); // 1 wei .. 1e6 wei
  if (k < 0.6) return E(ri(rng, 1, 500));
  const num = E(ri(rng, 1, 100_000));
  return num / BigInt(ri(rng, 1, 997));
}

const N = {
  CAMPAIGNS: Number(process.env.BBV_CAMPAIGNS || 8),
  STEPS: Number(process.env.BBV_STEPS || 70),
};

// =============================================================================
describe("INVARIANT: AereBugBountyVault custody / cap / conservation", function () {
  this.timeout(0);

  let signers, tokenF, vaultF, reentF;

  before(async function () {
    signers = await ethers.getSigners();
    tokenF = await ethers.getContractFactory("MockERC20Lending");
    vaultF = await ethers.getContractFactory("AereBugBountyVault");
    reentF = await ethers.getContractFactory("MockReentrantBountyToken");
    console.log(
      `      [bbv] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} campaigns`
    );
  });

  async function deployStack(bps, triager) {
    const token = await tokenF.deploy("Wrapped AERE", "WAERE", 18);
    await token.waitForDeployment();
    const vault = await vaultF.deploy(await token.getAddress(), triager.address, bps);
    await vault.waitForDeployment();
    return { token, vault, vaultAddr: await vault.getAddress(), tokenAddr: await token.getAddress() };
  }

  // Full invariant battery. `state` carries cumulative accounting.
  async function assertInvariants(ctx, token, vault, vaultAddr, funders, state) {
    // ---- BALANCE CONSERVATION: real ERC-20 balance == inflow - outflow ----
    const bal = await token.balanceOf(vaultAddr);
    const expBal = state.inflow - state.outflow;
    expect(bal, `[bbv CONSERVE] balance ${bal} != inflow ${state.inflow} - outflow ${state.outflow} ${ctx}`)
      .to.equal(expBal);
    expect(await vault.balance(), `[bbv BALANCE-VIEW] balance() disagrees with token ${ctx}`).to.equal(bal);

    // ---- NO OVER-DRAW: never paid out more than ever came in (balance >= 0) ----
    expect(state.outflow, `[bbv OVERDRAW] outflow ${state.outflow} > inflow ${state.inflow} ${ctx}`)
      .to.be.lte(state.inflow);
    expect(bal, `[bbv NONNEG] negative-looking balance ${ctx}`).to.be.gte(0n);

    // ---- payoutNonce == number of successful pays ----
    expect(await vault.payoutNonce(), `[bbv NONCE] payoutNonce != successful-pay count ${ctx}`)
      .to.equal(BigInt(state.payCount));

    // ---- CAP VIEW: singlePayoutCap() == floor(bal * bps / 10000) <= bal ----
    const cap = await vault.singlePayoutCap();
    const expCap = (bal * BigInt(state.bps)) / 10_000n;
    expect(cap, `[bbv CAP-VIEW] singlePayoutCap != floor(bal*bps/1e4) ${ctx}`).to.equal(expCap);
    expect(cap, `[bbv CAP-LE-BAL] cap ${cap} > balance ${bal} ${ctx}`).to.be.lte(bal);

    // ---- CUSTODY: triager is exactly the tracked owner-of-custody ----
    expect(await vault.triager(), `[bbv CUSTODY-TRIAGER] triager drifted ${ctx}`).to.equal(state.triager.address);
  }

  // Non-triager can NEVER move funds or touch rotation. Checked frequently.
  async function assertNoUnprivilegedAccess(ctx, vault, token, vaultAddr, outsider, state) {
    const bal = await token.balanceOf(vaultAddr);
    // pay: arbitrary-recipient drain attempt by a non-triager.
    await expect(
      vault.connect(outsider).pay(ethers.ZeroHash, outsider.address, bal > 0n ? bal : 1n),
      `[bbv CUSTODY-PAY] non-triager pay did not revert ${ctx}`
    ).to.be.revertedWithCustomError(vault, "NotTriager");
    // rotation surface must also be triager-gated.
    await expect(
      vault.connect(outsider).proposeTriager(outsider.address),
      `[bbv CUSTODY-PROPOSE] non-triager proposeTriager did not revert ${ctx}`
    ).to.be.revertedWithCustomError(vault, "NotTriager");
    await expect(
      vault.connect(outsider).acceptTriager(),
      `[bbv CUSTODY-ACCEPT] non-triager acceptTriager did not revert ${ctx}`
    ).to.be.revertedWithCustomError(vault, "NotTriager");
    await expect(
      vault.connect(outsider).cancelTriagerProposal(),
      `[bbv CUSTODY-CANCEL] non-triager cancelTriagerProposal did not revert ${ctx}`
    ).to.be.revertedWithCustomError(vault, "NotTriager");
    // Nothing moved.
    expect(await token.balanceOf(vaultAddr), `[bbv CUSTODY-NOMOVE] outsider changed balance ${ctx}`).to.equal(bal);
  }

  // -------------------------------------------------------------------------
  it("general interleaving: custody, conservation, cap, over-draw, rotation", async function () {
    let steps = 0, funds = 0, donations = 0, paysOk = 0, paysCapReverted = 0,
      proposals = 0, accepts = 0, cancels = 0, custodyChecks = 0;

    for (let c = 0; c < N.CAMPAIGNS; c++) {
      const rng = rngFor("gen", c);
      const bps = pick(rng, BPS_SET);

      // Rotating cast: signer[1] is the initial triager; a pool of funders and
      // outsiders; a set of candidate triagers for rotation.
      let triager = signers[1];
      const funders = [signers[2], signers[3], signers[4], signers[5]];
      const outsiders = [signers[6], signers[7], signers[8]];
      const candidates = [signers[10], signers[11], signers[12]];

      const { token, vault, vaultAddr } = await deployStack(bps, triager);

      const state = { inflow: 0n, outflow: 0n, payCount: 0, bps, triager };

      // Fund every funder generously and pre-approve the vault.
      for (const f of funders) {
        await (await token.mint(f.address, E("100000000000"))).wait();
        await (await token.connect(f).approve(vaultAddr, MAXU)).wait();
      }
      // Donor for direct (non-fund) transfers, exercising accounting robustness.
      const donor = signers[9];
      await (await token.mint(donor.address, E("100000000000"))).wait();

      await assertInvariants(`c${c} init bps=${bps}`, token, vault, vaultAddr, funders, state);
      await assertNoUnprivilegedAccess(`c${c} init`, vault, token, vaultAddr, pick(rng, outsiders), state);
      custodyChecks++;

      for (let s = 0; s < N.STEPS; s++) {
        const action = pick(rng, [
          "fund", "fund", "fund", "donate",
          "payOk", "payOk", "payOverCap", "payZero", "payToZero",
          "proposeTriager", "acceptTriager", "cancelProposal",
          "advance", "custodyProbe",
        ]);
        const ctx = `c${c} s${s} bps=${bps} action=${action}`;

        try {
          if (action === "fund") {
            const f = pick(rng, funders);
            const amt = rndAmount(rng);
            const before = await token.balanceOf(vaultAddr);
            await (await vault.connect(f).fund(amt)).wait();
            const delta = (await token.balanceOf(vaultAddr)) - before;
            expect(delta, `[bbv FUND-DELTA] fund moved != amount ${ctx}`).to.equal(amt);
            state.inflow += delta;
            funds++;
          } else if (action === "donate") {
            // Raw ERC-20 transfer straight to the vault (bypasses fund()). The
            // conservation invariant must still hold: it is tracked as inflow.
            const amt = rndAmount(rng);
            const before = await token.balanceOf(vaultAddr);
            await (await token.connect(donor).transfer(vaultAddr, amt)).wait();
            state.inflow += (await token.balanceOf(vaultAddr)) - before;
            donations++;
          } else if (action === "payOk") {
            // A legitimate payout AT or below the cap by the current triager.
            const cap = await vault.singlePayoutCap();
            if (cap === 0n) { steps++; continue; }
            const amt = chance(rng, 0.4) ? cap : (BigInt(ri(rng, 1, 1_000_000)) * cap) / 1_000_000n;
            const pay = amt === 0n ? 1n : amt;
            if (pay > cap) { steps++; continue; }
            const reporter = pick(rng, outsiders); // arbitrary reporter chosen by triager (allowed)
            const balBefore = await token.balanceOf(vaultAddr);
            const rptBefore = await token.balanceOf(reporter.address);
            const nonceBefore = await vault.payoutNonce();
            await (await vault.connect(state.triager).pay(
              ethers.id(`finding-${c}-${s}`), reporter.address, pay
            )).wait();
            const moved = balBefore - (await token.balanceOf(vaultAddr));
            // CAP holds for the executed payout, and exactly `pay` left the vault to the reporter.
            expect(moved, `[bbv PAY-EXACT] moved ${moved} != requested ${pay} ${ctx}`).to.equal(pay);
            expect(moved, `[bbv PAY-CAP] payout ${moved} > cap ${cap} ${ctx}`).to.be.lte(cap);
            expect((await token.balanceOf(reporter.address)) - rptBefore,
              `[bbv PAY-RECIPIENT] reporter did not receive exactly the payout ${ctx}`).to.equal(pay);
            expect(await vault.payoutNonce(), `[bbv PAY-NONCE-INC] nonce did not ++ ${ctx}`)
              .to.equal(nonceBefore + 1n);
            state.outflow += moved;
            state.payCount++;
            paysOk++;
          } else if (action === "payOverCap") {
            // Anything strictly above the cap must revert and move nothing.
            const cap = await vault.singlePayoutCap();
            const amt = cap + BigInt(ri(rng, 1, 1_000_000));
            const reporter = pick(rng, outsiders);
            const balBefore = await token.balanceOf(vaultAddr);
            await expect(
              vault.connect(state.triager).pay(ethers.id(`over-${c}-${s}`), reporter.address, amt),
              `[bbv PAY-OVERCAP] over-cap pay did not revert ${ctx} amt=${amt} cap=${cap}`
            ).to.be.revertedWithCustomError(vault, "AmountExceedsCap");
            expect(await token.balanceOf(vaultAddr), `[bbv PAY-OVERCAP-NOMOVE] ${ctx}`).to.equal(balBefore);
            paysCapReverted++;
          } else if (action === "payZero") {
            await expect(
              vault.connect(state.triager).pay(ethers.ZeroHash, pick(rng, outsiders).address, 0),
              `[bbv PAY-ZERO] zero-amount pay did not revert ${ctx}`
            ).to.be.revertedWithCustomError(vault, "ZeroAmount");
          } else if (action === "payToZero") {
            const cap = await vault.singlePayoutCap();
            const amt = cap > 0n ? cap : 1n;
            await expect(
              vault.connect(state.triager).pay(ethers.ZeroHash, ethers.ZeroAddress, amt),
              `[bbv PAY-ZEROADDR] payout to zero address did not revert ${ctx}`
            ).to.be.revertedWithCustomError(vault, "ZeroAddress");
          } else if (action === "proposeTriager") {
            const cand = pick(rng, candidates);
            await (await vault.connect(state.triager).proposeTriager(cand.address)).wait();
            state.pending = cand;
            state.earliest = BigInt((await ethers.provider.getBlock("latest")).timestamp) + BigInt(TIMELOCK);
            proposals++;
          } else if (action === "acceptTriager") {
            if (!state.pending) {
              await expect(
                vault.connect(state.triager).acceptTriager(),
                `[bbv ACCEPT-NOPENDING] accept with no proposal did not revert ${ctx}`
              ).to.be.revertedWithCustomError(vault, "NoPendingProposal");
              steps++; continue;
            }
            const now = BigInt((await ethers.provider.getBlock("latest")).timestamp);
            if (now < state.earliest) {
              // TIMELOCK: acceptance before the 14-day window must revert.
              await expect(
                vault.connect(state.triager).acceptTriager(),
                `[bbv TIMELOCK] early accept did not revert ${ctx} now=${now} earliest=${state.earliest}`
              ).to.be.revertedWithCustomError(vault, "TimelockNotElapsed");
            } else {
              await (await vault.connect(state.triager).acceptTriager()).wait();
              state.triager = state.pending;
              state.pending = null;
              state.earliest = 0n;
              accepts++;
            }
          } else if (action === "cancelProposal") {
            if (!state.pending) {
              await expect(
                vault.connect(state.triager).cancelTriagerProposal(),
                `[bbv CANCEL-NOPENDING] cancel with no proposal did not revert ${ctx}`
              ).to.be.revertedWithCustomError(vault, "NoPendingProposal");
            } else {
              await (await vault.connect(state.triager).cancelTriagerProposal()).wait();
              state.pending = null;
              state.earliest = 0n;
              cancels++;
            }
          } else if (action === "advance") {
            // Sometimes long enough to cross the 14-day timelock.
            const secs = chance(rng, 0.4) ? ri(rng, TIMELOCK, TIMELOCK + 3 * 24 * 60 * 60) : ri(rng, 1, 5 * 24 * 60 * 60);
            await time.increase(secs);
            await network.provider.send("evm_mine");
          } else if (action === "custodyProbe") {
            await assertNoUnprivilegedAccess(ctx, vault, token, vaultAddr, pick(rng, outsiders), state);
            custodyChecks++;
          }
        } catch (e) {
          if (/bbv [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break
          // Legal reverts on edge amounts are fine; battery still runs on unchanged state.
        }

        await assertInvariants(ctx, token, vault, vaultAddr, funders, state);
        steps++;
      }

      // Finale custody probe: no outsider can extract the remaining balance.
      await assertNoUnprivilegedAccess(`c${c} finale`, vault, token, vaultAddr, pick(rng, outsiders), state);
      custodyChecks++;
      await assertInvariants(`c${c} finale`, token, vault, vaultAddr, funders, state);
    }

    console.log(
      `      [bbv gen] steps=${steps} fund=${funds} donate=${donations} payOk=${paysOk} ` +
        `overCapRev=${paysCapReverted} propose=${proposals} accept=${accepts} cancel=${cancels} custodyProbes=${custodyChecks}`
    );
    expect(steps).to.be.greaterThan(400);
    expect(funds + donations).to.be.greaterThan(60);
    expect(paysOk).to.be.greaterThan(20);
    expect(paysCapReverted).to.be.greaterThan(20);
    expect(proposals).to.be.greaterThan(10); // rotation surface genuinely exercised
    expect(accepts + cancels).to.be.greaterThan(0);
    expect(custodyChecks).to.be.greaterThan(40);
  });

  // -------------------------------------------------------------------------
  // ROTATION / TIMELOCK, deterministic: acceptance is gated by the 14-day
  // window, only the current triager can drive it, and custody transfers cleanly.
  it("triager rotation honours the 14-day timelock and transfers custody cleanly", async function () {
    const triager = signers[1];
    const nextTriager = signers[10];
    const outsider = signers[6];
    const reporter = signers[7];
    const { token, vault, vaultAddr } = await deployStack(500, triager);
    await (await token.mint(triager.address, E(1000))).wait();
    await (await token.connect(triager).approve(vaultAddr, MAXU)).wait();
    await (await vault.connect(triager).fund(E(1000))).wait();

    // Only the current triager may propose.
    await expect(vault.connect(outsider).proposeTriager(nextTriager.address))
      .to.be.revertedWithCustomError(vault, "NotTriager");
    await (await vault.connect(triager).proposeTriager(nextTriager.address)).wait();
    expect(await vault.pendingTriager()).to.equal(nextTriager.address);

    // Accept BEFORE the window elapses must revert (13 days < 14).
    await time.increase(13 * 24 * 60 * 60);
    await network.provider.send("evm_mine");
    await expect(vault.connect(triager).acceptTriager())
      .to.be.revertedWithCustomError(vault, "TimelockNotElapsed");
    // The proposed (not-yet-accepted) triager also cannot accept early or ever
    // drive rotation while not the current triager.
    await expect(vault.connect(nextTriager).acceptTriager())
      .to.be.revertedWithCustomError(vault, "NotTriager");

    // Cross the 14-day boundary; now acceptance succeeds and custody moves.
    await time.increase(2 * 24 * 60 * 60);
    await network.provider.send("evm_mine");
    await (await vault.connect(triager).acceptTriager()).wait();
    expect(await vault.triager()).to.equal(nextTriager.address);
    expect(await vault.pendingTriager()).to.equal(ethers.ZeroAddress);

    // Old triager has lost custody: it can no longer pay.
    await expect(vault.connect(triager).pay(ethers.id("x"), reporter.address, 1))
      .to.be.revertedWithCustomError(vault, "NotTriager");
    // New triager can pay within cap.
    const cap = await vault.singlePayoutCap();
    const before = await token.balanceOf(vaultAddr);
    await (await vault.connect(nextTriager).pay(ethers.id("y"), reporter.address, cap)).wait();
    expect(before - (await token.balanceOf(vaultAddr))).to.equal(cap);
    expect(await vault.payoutNonce()).to.equal(1n);
    console.log(`      [bbv rotation] timelock enforced; custody transferred; old key locked out`);
  });

  // -------------------------------------------------------------------------
  // Cumulative-drain check: even a fully-cooperating (or compromised) triager
  // can only ever move out what came in. Over MANY max-cap payouts the vault
  // trends toward dust but conservation + non-negativity always hold, and the
  // per-call 5% cap means it is never emptied in a single call.
  it("drain resistance: repeated max-cap payouts never over-draw, never single-call empty", async function () {
    const rng = rngFor("drain", 0);
    const triager = signers[1];
    const reporter = signers[6];
    const { token, vault, vaultAddr } = await deployStack(500, triager); // live 5%
    await (await token.mint(triager.address, E("1000000"))).wait();
    await (await token.connect(triager).approve(vaultAddr, MAXU)).wait();

    const state = { inflow: 0n, outflow: 0n, payCount: 0, bps: 500, triager };
    await (await vault.connect(triager).fund(E(1000))).wait();
    state.inflow += E(1000);

    let maxCapPays = 0, singleCallEmptied = 0;
    for (let i = 0; i < 200; i++) {
      const balBefore = await token.balanceOf(vaultAddr);
      const cap = await vault.singlePayoutCap();
      if (cap === 0n) break; // dust: 5% of a tiny balance floors to 0
      await (await vault.connect(triager).pay(ethers.id(`drain-${i}`), reporter.address, cap)).wait();
      const moved = balBefore - (await token.balanceOf(vaultAddr));
      expect(moved, `[bbv DRAIN-CAP] moved ${moved} exceeded cap ${cap} at i=${i}`).to.be.lte(cap);
      // A single max payout is at most 5% -> it can never zero a non-dust balance.
      if (balBefore > 20n && (await token.balanceOf(vaultAddr)) === 0n) singleCallEmptied++;
      state.outflow += moved;
      state.payCount++;
      maxCapPays++;
      await assertInvariants(`drain i${i}`, token, vault, vaultAddr, [], state);
    }

    expect(state.outflow, "[bbv DRAIN-CONSERVE] paid out more than funded").to.be.lte(state.inflow);
    expect(singleCallEmptied, "[bbv DRAIN-SINGLECALL] a single 5% payout emptied a non-dust vault").to.equal(0);
    expect(maxCapPays).to.be.greaterThan(50);
    console.log(`      [bbv drain] maxCapPays=${maxCapPays} finalBal=${await token.balanceOf(vaultAddr)} outflow=${state.outflow}`);
  });

  // -------------------------------------------------------------------------
  // RE-ENTRANCY: a malicious bounty token whose transfer() re-enters pay()
  // cannot cause a second payout. The ReentrancyGuard reverts the nested call;
  // exactly one payout of the requested amount leaves the vault.
  it("no re-entrancy drain: token-callback re-entry into pay() is blocked by the guard", async function () {
    const funder = signers[2];
    const reporter = signers[6];
    const rtoken = await reentF.deploy();
    await rtoken.waitForDeployment();
    // The malicious TOKEN is itself the triager, so its nested re-entry passes
    // onlyTriager and genuinely reaches the ReentrancyGuard (onlyTriager runs
    // before nonReentrant, so any other caller would revert on onlyTriager first).
    const vault = await vaultF.deploy(await rtoken.getAddress(), await rtoken.getAddress(), 5000); // 50% cap
    await vault.waitForDeployment();
    const vaultAddr = await vault.getAddress();
    await (await rtoken.setVault(vaultAddr)).wait();

    // Fund the vault to 100 tokens via the permissionless fund() path (unarmed).
    await (await rtoken.mint(funder.address, E(1000))).wait();
    await (await rtoken.connect(funder).approve(vaultAddr, MAXU)).wait();
    await (await vault.connect(funder).fund(E(100))).wait();
    expect(await rtoken.balanceOf(vaultAddr)).to.equal(E(100));

    // Arm the token to re-enter pay() from inside transfer(); drive the OUTER
    // pay() through the token (the triager) so the nested pay() is authorised.
    await (await rtoken.arm(true, false)).wait();
    const bal0 = await rtoken.balanceOf(vaultAddr);
    const rpt0 = await rtoken.balanceOf(reporter.address);
    await (await rtoken.triggerPay(ethers.id("legit"), reporter.address, E(10))).wait();

    // The nested pay() must have been attempted AND reverted by the guard.
    expect(await rtoken.reentryAttempted(), "[bbv REENTRY] token callback never fired").to.equal(true);
    expect(await rtoken.reentryReverted(), "[bbv REENTRY] nested pay() was NOT reverted by the guard").to.equal(true);
    expect(await rtoken.lastRevert(), "[bbv REENTRY] wrong revert reason").to.equal("ReentrancyGuard: reentrant call");

    // Exactly ONE payout of 10 happened (no double-spend via re-entry).
    expect(bal0 - (await rtoken.balanceOf(vaultAddr)), "[bbv REENTRY] more than one payout left the vault").to.equal(E(10));
    expect((await rtoken.balanceOf(reporter.address)) - rpt0, "[bbv REENTRY] reporter got != one payout").to.equal(E(10));
    expect(await vault.payoutNonce(), "[bbv REENTRY] nonce shows more than one pay").to.equal(1n);

    // Same shared guard also blocks a nested fund() re-entered from within an
    // outer pay()'s transfer() (nonReentrant reverts before transferFrom runs).
    await (await rtoken.arm(true, true)).wait();
    const balF0 = await rtoken.balanceOf(vaultAddr);
    await (await rtoken.triggerPay(ethers.id("legit2"), reporter.address, E(10))).wait();
    expect(await rtoken.reentryReverted(), "[bbv REENTRY-FUND] nested fund() not reverted").to.equal(true);
    expect(await rtoken.lastRevert(), "[bbv REENTRY-FUND] wrong revert reason").to.equal("ReentrancyGuard: reentrant call");
    // Only the single legitimate 10-token payout left; the nested fund() added nothing.
    expect(balF0 - (await rtoken.balanceOf(vaultAddr)), "[bbv REENTRY-FUND] balance delta != one payout").to.equal(E(10));
    expect(await vault.payoutNonce(), "[bbv REENTRY-FUND] extra pay slipped in").to.equal(2n);

    console.log(`      [bbv reentry] pay-guard + fund-guard both held; single payout only`);
  });
});
