// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the AERE ERC-4337
// PAYMASTER STACK (gas sponsorship), driven end-to-end through the deposit /
// charge path in AereEntryPoint.relayUserOp.
//
// Sources under test (match the LIVE addresses in sdk-js/src/addresses.ts,
// Tier-1.3 paymaster stack on chain 2800):
//   * contracts/paymaster/PaymasterBase.sol      (validate/postOp dispatch, deposit helpers)
//   * contracts/paymaster/AereEntryPoint.sol      (holds the deposit; relayUserOp charges it)
//   * contracts/AereOnboardingPaymaster.sol       (per-sender lifetime cap + daily sitewide cap)
//   * contracts/AereStakeQuotaPaymaster.sol        (per-op gas cap + stake-derived daily quota)
//   * contracts/AereAppPaymasterFactory.sol        (AereAppPaymaster: target whitelist + per-sender cap)
//
// 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/paymaster-nodrain-invariant-property.test.js
// No deploy to any live node, no contract-logic change, one new test file only.
//
// INVARIANTS FUZZED (exactly the paymaster brief):
//   * NO-DRAIN / SOLVENCY: the paymaster's EntryPoint deposit is monotonically
//     NON-INCREASING across sponsored ops and always equals fundedDeposit minus
//     the cumulative gas it has paid out; cumulative payout never exceeds the
//     funded deposit; the deposit never underflows (a userOp that would cost more
//     than the remaining deposit reverts "EP: paymaster underfunded", paying 0).
//   * ONLY-VALIDATED-OPS-ARE-SPONSORED: the per-sender / daily / quota counters
//     advance by exactly 1 iff the op is actually sponsored (deposit charged);
//     a rejected op (failed validation) and an unaffordable op (underfunded
//     revert) BOTH leave every counter and the deposit unchanged -> a crafted or
//     repeated userOp cannot burn a sender's quota nor drain the pool.
//   * PER-SENDER / DAILY / PER-OP LIMITS: sponsoredCount[sender] never exceeds
//     sponsoredOpsPerSender; dailyCount[day] never exceeds dailySitewideCap;
//     usedQuota never exceeds the stake-derived dailyQuota; a UserOp whose maxCost
//     exceeds maxSponsoredGasWei is never sponsored (per-op cap).
//   * POSTOP ACCOUNTING NEVER OVER-REFUNDS: the deposit delta of a sponsored op
//     is exactly the amount charged (postOp is a no-op; it never credits the
//     deposit back, so deposit == funded - totalCharged holds after every op).
//   * OWNER CANNOT BE GRIEFED INTO INSOLVENCY: repeated maximal-cost ops stop
//     being sponsored the instant the budget is exhausted, never going negative.
//
// No em-dashes anywhere in this file.
// =============================================================================

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

/* ------------------------------- seeded PRNG ------------------------------ */
function mulberry32(a) {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x9A17E4A5;
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 ONE = 10n ** 18n;
const DAY = 86400n;
const ZERO32 = "0x" + "00".repeat(32);

// PackedUserOperation as a positional tuple (matches paymaster-stack.test.js).
function UO(o = {}) {
  return [
    o.sender ?? ethers.ZeroAddress,
    o.nonce ?? 0n,
    o.initCode ?? "0x",
    o.callData ?? "0x",
    o.accountGasLimits ?? ZERO32,
    o.preVerificationGas ?? 0n,
    o.gasFees ?? ZERO32,
    o.paymasterAndData ?? "0x",
    o.signature ?? "0x",
  ];
}
// callData whose bytes[16:36] encode `target` (SimpleAccount execute layout).
function execCallData(target) {
  return ethers.concat(["0xb61d27f6", ethers.zeroPadValue(target, 32)]);
}

async function latestTs() {
  return BigInt((await ethers.provider.getBlock("latest")).timestamp);
}
async function curDay() {
  return (await latestTs()) / DAY;
}
// Pin the next block's base fee to 1 wei so a legacy `gasPrice` we pass becomes
// the exact `tx.gasprice` the EntryPoint sees (deterministic per-op cap math).
async function pinBaseFee() {
  await network.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x1"]);
}
async function advanceDays(n) {
  await network.provider.send("evm_increaseTime", [Number(DAY) * n + 1]);
  await network.provider.send("evm_mine");
}

// Attempt a relay; classify the outcome. Returns {ok, reason, before, after, charged}.
async function tryRelay(ep, relayer, uo, gasPrice) {
  const pmAddr = ethers.getAddress(ethers.dataSlice(uo[7], 0, 20)); // paymasterAndData[0:20]
  const before = await ep.balanceOf(pmAddr);
  await pinBaseFee();
  try {
    const tx = await ep.connect(relayer).relayUserOp(uo, { gasPrice });
    await tx.wait();
    const after = await ep.balanceOf(pmAddr);
    return { ok: true, reason: null, before, after, charged: before - after };
  } catch (e) {
    const after = await ep.balanceOf(pmAddr);
    return { ok: false, reason: String(e.message || e), before, after, charged: 0n };
  }
}

// Iteration counts (env-overridable for a quick smoke).
const N = {
  ONB_CAMPAIGNS: Number(process.env.PM_ONB_CAMPAIGNS || 4),
  ONB_STEPS: Number(process.env.PM_ONB_STEPS || 60),
  STK_CAMPAIGNS: Number(process.env.PM_STK_CAMPAIGNS || 4),
  STK_STEPS: Number(process.env.PM_STK_STEPS || 55),
  APP_CAMPAIGNS: Number(process.env.PM_APP_CAMPAIGNS || 3),
  APP_STEPS: Number(process.env.PM_APP_STEPS || 45),
};

// =============================================================================
describe("INVARIANT: ERC-4337 Paymaster stack (no-drain / limits / postOp accounting)", function () {
  this.timeout(0);

  let signers;
  before(async function () {
    signers = await ethers.getSigners();
    console.log(
      `      [pm] base seed 0x${BASE_SEED.toString(16)} | ` +
        `onboarding ${N.ONB_CAMPAIGNS}x${N.ONB_STEPS}, stakeQuota ${N.STK_CAMPAIGNS}x${N.STK_STEPS}, ` +
        `app ${N.APP_CAMPAIGNS}x${N.APP_STEPS}`
    );
  });

  async function deployEP() {
    const EP = await (await ethers.getContractFactory("AereEntryPoint")).deploy();
    await EP.waitForDeployment();
    return EP;
  }

  // ===========================================================================
  // CAMPAIGN A: AereOnboardingPaymaster
  //   per-sender lifetime cap + daily sitewide cap + deposit no-drain.
  // ===========================================================================
  it("AereOnboardingPaymaster: no-drain + per-sender/daily caps + no-grief on reject/underfunded", async function () {
    const relayer = signers[10];
    let nSuccess = 0, nExhausted = 0, nDailyCap = 0, nUnderfunded = 0, steps = 0;

    for (let c = 0; c < N.ONB_CAMPAIGNS; c++) {
      const rng = rngFor("onb", c);
      const ep = await deployEP();
      const pm = await (await ethers.getContractFactory("AereOnboardingPaymaster")).deploy(await ep.getAddress());
      await pm.waitForDeployment();
      const pmAddr = await pm.getAddress();

      // Half the campaigns: tight budget + loose caps (exercise the drain/underfunded
      // path). Half: generous budget + tight caps (exercise the cap-reject path).
      const tight = c % 2 === 0;
      const perSender = tight ? 1000 : ri(rng, 2, 4);
      const dailyCap = tight ? 1_000_000 : ri(rng, 4, 8);
      await (await pm.setLimits(perSender, dailyCap, false)).wait();

      const funded = tight ? BigInt(ri(rng, 3, 9)) * 10n ** 14n : ONE * 5n; // ~0.0003-0.0009 vs 5 AERE
      await (await ep.depositTo(pmAddr, { value: funded })).wait();

      const actors = [signers[1], signers[2], signers[3], signers[4], signers[5]];
      // Local mirror of the contract's counters.
      const sponsored = new Map(); // sender -> count (lifetime)
      const daily = new Map();     // dayKey -> count
      let totalCharged = 0n;

      const inv = async (ctx) => {
        const dep = await ep.balanceOf(pmAddr);
        // NO-DRAIN / conservation: deposit == funded - totalCharged, never underflows.
        expect(dep, `[onb NODRAIN] deposit != funded-charged ${ctx} dep=${dep} funded=${funded} charged=${totalCharged}`)
          .to.equal(funded - totalCharged);
        expect(totalCharged, `[onb BUDGET] cumulative payout ${totalCharged} exceeded funded ${funded} ${ctx}`)
          .to.be.lte(funded);
        // per-sender + daily caps never exceeded on-chain.
        for (const a of actors) {
          const sc = await pm.sponsoredCount(a.address);
          expect(sc, `[onb PERSENDER] sponsoredCount>${perSender} ${ctx} a=${a.address.slice(0, 10)} sc=${sc}`)
            .to.be.lte(BigInt(perSender));
          expect(sc, `[onb MIRROR-S] on-chain sponsoredCount != local ${ctx} a=${a.address.slice(0, 10)}`)
            .to.equal(BigInt(sponsored.get(a.address) || 0));
        }
      };

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

      for (let s = 0; s < N.ONB_STEPS; s++) {
        const actor = pick(rng, actors);
        const gasPrice = tight ? BigInt(ri(rng, 5, 20)) * 10n ** 8n : BigInt(ri(rng, 1, 50)) * 10n ** 6n;
        const ctx = `c${c} s${s} tight=${tight} actor=${actor.address.slice(0, 10)} gp=${gasPrice}`;
        const day = await curDay();
        const dayKey = String(day);

        const scLocal = sponsored.get(actor.address) || 0;
        const dcLocal = daily.get(dayKey) || 0;
        // Predicted VALIDATION-level rejection (checked before any gas cost).
        const predictReject =
          scLocal >= perSender ? "OnboardingPM: sender exhausted"
          : dcLocal >= dailyCap ? "OnboardingPM: daily cap reached"
          : null;

        const uo = UO({ sender: actor.address, paymasterAndData: pmAddr });
        const r = await tryRelay(ep, relayer, uo, gasPrice);

        if (r.ok) {
          // Success is only legal when validation should have passed.
          expect(predictReject, `[onb GHOST] op sponsored despite predicted reject "${predictReject}" ${ctx}`).to.equal(null);
          expect(r.charged, `[onb CHARGE-SIGN] deposit increased on a sponsored op ${ctx}`).to.be.gte(0n);
          totalCharged += r.charged;
          sponsored.set(actor.address, scLocal + 1);
          daily.set(dayKey, dcLocal + 1);
          nSuccess++;
        } else {
          if (predictReject) {
            expect(r.reason, `[onb REASON] expected "${predictReject}" ${ctx} got: ${r.reason}`).to.include(predictReject);
            if (predictReject.includes("exhausted")) nExhausted++; else nDailyCap++;
          } else {
            // Validation passed, so the ONLY legal revert is an unaffordable charge.
            // This is the anti-grief invariant: an unpayable op burns NO quota.
            expect(r.reason, `[onb UNDERFUNDED] validation passed but reverted for a non-budget reason ${ctx}: ${r.reason}`)
              .to.include("EP: paymaster underfunded");
            nUnderfunded++;
          }
          // State MUST be unchanged by any reverted op.
          expect(r.after, `[onb REVERT-DEP] deposit changed on a reverted op ${ctx}`).to.equal(r.before);
          expect(await pm.sponsoredCount(actor.address), `[onb REVERT-CNT] counter moved on a reverted op ${ctx}`)
            .to.equal(BigInt(scLocal));
        }

        await inv(ctx);

        // Occasionally roll the day so the sitewide counter resets but the lifetime
        // cap does not.
        if (chance(rng, 0.12)) await advanceDays(1);
        steps++;
      }
    }

    console.log(
      `      [onb] steps=${steps} success=${nSuccess} exhausted=${nExhausted} dailyCap=${nDailyCap} underfunded=${nUnderfunded}`
    );
    expect(steps, "onb: not enough steps").to.be.greaterThan(200);
    expect(nSuccess, "onb: never sponsored a single op").to.be.greaterThan(0);
    // The fuzz must actually exercise the budget-exhaustion (no-drain) path AND a
    // validation-cap rejection, else the invariants above are vacuous.
    expect(nUnderfunded, "onb: never hit the underfunded/no-drain boundary").to.be.greaterThan(0);
    expect(nExhausted + nDailyCap, "onb: never hit a per-sender/daily cap").to.be.greaterThan(0);
  });

  // ===========================================================================
  // DETERMINISTIC: a crafted / repeated userOp cannot drain the pool nor burn
  // quota. Tiny deposit + maximal gas price => every op reverts underfunded,
  // deposit is preserved to the wei, and no counter ever advances.
  // ===========================================================================
  it("AereOnboardingPaymaster: crafted repeated userOp cannot drain below deposit or burn quota", async function () {
    const relayer = signers[10];
    const ep = await deployEP();
    const pm = await (await ethers.getContractFactory("AereOnboardingPaymaster")).deploy(await ep.getAddress());
    await pm.waitForDeployment();
    const pmAddr = await pm.getAddress();
    await (await pm.setLimits(1000, 1_000_000, false)).wait(); // caps out of the way

    const funded = 1000n; // 1000 wei: far below any real gas cost
    await (await ep.depositTo(pmAddr, { value: funded })).wait();

    const attackers = [signers[1], signers[2], signers[3]];
    const gasPrice = 5n * 10n ** 9n; // 5 gwei -> actualGasCost >> 1000 wei
    let attempts = 0;
    for (let i = 0; i < 25; i++) {
      const a = attackers[i % attackers.length];
      const r = await tryRelay(ep, relayer, UO({ sender: a.address, paymasterAndData: pmAddr }), gasPrice);
      expect(r.ok, `[onb DRAIN] a crafted underfunded op was sponsored i=${i}`).to.equal(false);
      expect(r.reason, `[onb DRAIN] wrong revert i=${i}: ${r.reason}`).to.include("EP: paymaster underfunded");
      expect(await ep.balanceOf(pmAddr), `[onb DRAIN] deposit moved i=${i}`).to.equal(funded);
      expect(await pm.sponsoredCount(a.address), `[onb DRAIN] quota burned by an unpayable op i=${i}`).to.equal(0n);
      attempts++;
    }
    console.log(`      [onb drain-guard] ${attempts} crafted ops all reverted; deposit intact at ${funded} wei, 0 quota burned`);
    expect(attempts).to.equal(25);
  });

  // ===========================================================================
  // CAMPAIGN B: AereStakeQuotaPaymaster
  //   per-op gas cap (maxSponsoredGasWei) + stake-derived daily quota + no-drain.
  //   Uses the correctly-shaped MockStaking (balanceOf) with zero locks so the
  //   quota is deterministic: dailyQuota = floor(staked * txPerKilo / 1000e18).
  // ===========================================================================
  it("AereStakeQuotaPaymaster: per-op gas cap + daily quota never over-drawn + no-drain", async function () {
    const relayer = signers[10];
    let steps = 0, nSuccess = 0, nExpensive = 0, nExhausted = 0, nNoStake = 0;

    for (let c = 0; c < N.STK_CAMPAIGNS; c++) {
      const rng = rngFor("stk", c);
      const ep = await deployEP();
      const staking = await (await ethers.getContractFactory("MockStaking")).deploy();
      await staking.waitForDeployment();
      const locked = await (await ethers.getContractFactory("MockLockedStaking")).deploy();
      await locked.waitForDeployment(); // zero locks -> loop never runs
      const pm = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy(
        await ep.getAddress(), await staking.getAddress(), await locked.getAddress()
      );
      await pm.waitForDeployment();
      const pmAddr = await pm.getAddress();

      // Fund generously so this campaign isolates the CAP + QUOTA limits (the
      // no-drain/underfunded boundary is covered exhaustively in campaign A).
      const funded = ONE * 100n;
      await (await ep.depositTo(pmAddr, { value: funded })).wait();

      // txPerKilo is fixed for the campaign (lowering the RATE mid-day would
      // legitimately let a stale usedQuota exceed the new quota, since the
      // contract only checks used<quota at sponsorship time; that is not a
      // solvency invariant, so we keep the rate stable and only fuzz the gas cap).
      const txPerKilo = BigInt(ri(rng, 10, 40));
      let cap = BigInt(ri(rng, 4, 20)) * 10n ** 15n; // 0.004 .. 0.02 ether
      await (await pm.setQuotaParams(txPerKilo, cap)).wait();

      // Assign each actor a fixed stake giving a small, exhaustible quota (1..8).
      const actors = [signers[1], signers[2], signers[3], signers[4]];
      const staked = new Map();
      for (const a of actors) {
        // quota = floor(bal*txPerKilo/1000e18); pick bal for quota in 0..8 (0 = no-stake path).
        const q = BigInt(ri(rng, 0, 8));
        const bal = q === 0n ? (chance(rng, 0.5) ? 0n : 10n * ONE) : (q * 1000n * ONE) / txPerKilo + ONE; // +1 AERE slack
        await (await staking.setBal(a.address, bal)).wait();
        staked.set(a.address, bal);
      }
      const quotaOf = (a) => (staked.get(a.address) * txPerKilo) / (1000n * ONE);

      const used = new Map(); // `${sender}|${day}` -> count
      let totalCharged = 0n;

      const inv = async (ctx) => {
        const dep = await ep.balanceOf(pmAddr);
        expect(dep, `[stk NODRAIN] deposit != funded-charged ${ctx}`).to.equal(funded - totalCharged);
        expect(totalCharged, `[stk BUDGET] payout ${totalCharged} > funded ${funded} ${ctx}`).to.be.lte(funded);
        // usedQuota never exceeds the sender's daily quota.
        const day = await curDay();
        for (const a of actors) {
          const k = ethers.solidityPackedKeccak256(["address", "uint256"], [a.address, day]);
          const on = await pm.usedQuota(k);
          expect(on, `[stk QUOTA] usedQuota>${quotaOf(a)} ${ctx} a=${a.address.slice(0, 10)} used=${on}`)
            .to.be.lte(quotaOf(a));
          expect(on, `[stk MIRROR] on-chain usedQuota != local ${ctx} a=${a.address.slice(0, 10)}`)
            .to.equal(BigInt(used.get(`${a.address}|${day}`) || 0));
        }
      };

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

      for (let s = 0; s < N.STK_STEPS; s++) {
        const actor = pick(rng, actors);
        // gasPrice grid straddling the per-op cap boundary (cap/1e6 = 1e10 by default).
        const gasPrice = pick(rng, [
          1n, 10n ** 6n, 10n ** 8n, 10n ** 9n, 5n * 10n ** 9n,
          cap / 10n ** 6n,          // maxCost == cap exactly -> allowed (boundary)
          cap / 10n ** 6n + 1n,     // maxCost == cap+1e6 -> "tx too expensive"
          2n * cap / 10n ** 6n,     // well over the cap
        ]);
        const day = await curDay();
        const k = `${actor.address}|${day}`;
        const ctx = `c${c} s${s} actor=${actor.address.slice(0, 10)} gp=${gasPrice} cap=${cap} q=${quotaOf(actor)}`;

        const maxCost = gasPrice * 1_000_000n; // exactly what relayUserOp passes to validate
        const q = quotaOf(actor);
        const uLocal = used.get(k) || 0;
        const predictReject =
          maxCost > cap ? "StakePM: tx too expensive"
          : q === 0n ? "StakePM: stake more AERE"
          : BigInt(uLocal) >= q ? "StakePM: daily quota exhausted"
          : null;

        const uo = UO({ sender: actor.address, paymasterAndData: pmAddr });
        const r = await tryRelay(ep, relayer, uo, gasPrice);

        if (r.ok) {
          expect(predictReject, `[stk GHOST] sponsored despite predicted reject "${predictReject}" ${ctx}`).to.equal(null);
          expect(r.charged, `[stk CHARGE-SIGN] deposit increased on a sponsored op ${ctx}`).to.be.gte(0n);
          totalCharged += r.charged;
          used.set(k, uLocal + 1);
          nSuccess++;
        } else {
          if (predictReject) {
            expect(r.reason, `[stk REASON] expected "${predictReject}" ${ctx} got: ${r.reason}`).to.include(predictReject);
            if (predictReject.includes("too expensive")) nExpensive++;
            else if (predictReject.includes("stake more")) nNoStake++;
            else nExhausted++;
          } else {
            expect(r.reason, `[stk UNDERFUNDED] validation passed but non-budget revert ${ctx}: ${r.reason}`)
              .to.include("EP: paymaster underfunded");
          }
          expect(r.after, `[stk REVERT-DEP] deposit changed on a reverted op ${ctx}`).to.equal(r.before);
          const kk = ethers.solidityPackedKeccak256(["address", "uint256"], [actor.address, day]);
          expect(await pm.usedQuota(kk), `[stk REVERT-CNT] usedQuota moved on a reverted op ${ctx}`).to.equal(BigInt(uLocal));
        }

        await inv(ctx);

        // Occasionally reconfigure ONLY the per-op gas cap (owner); the stake rate
        // stays fixed so quotaOf remains a valid upper bound on usedQuota.
        if (chance(rng, 0.10)) {
          cap = BigInt(ri(rng, 1, 20)) * 10n ** 15n; // 0.001 .. 0.02 ether
          await (await pm.setQuotaParams(txPerKilo, cap)).wait();
        }
        if (chance(rng, 0.12)) await advanceDays(1);
        steps++;
      }
    }

    console.log(
      `      [stk] steps=${steps} success=${nSuccess} tooExpensive=${nExpensive} quotaExhausted=${nExhausted} noStake=${nNoStake}`
    );
    expect(steps, "stk: not enough steps").to.be.greaterThan(180);
    expect(nSuccess, "stk: never sponsored").to.be.greaterThan(0);
    expect(nExpensive, "stk: never exercised the per-op gas cap").to.be.greaterThan(0);
    expect(nExhausted, "stk: never exhausted a daily quota").to.be.greaterThan(0);
  });

  // ===========================================================================
  // CAMPAIGN C: AereAppPaymaster (from the factory)
  //   required target whitelist + optional sender allowlist + per-sender cap + no-drain.
  // ===========================================================================
  it("AereAppPaymaster: target whitelist + sender allowlist + per-sender cap + no-drain", async function () {
    const relayer = signers[10];
    let steps = 0, nSuccess = 0, nTarget = 0, nSender = 0, nQuota = 0;

    for (let c = 0; c < N.APP_CAMPAIGNS; c++) {
      const rng = rngFor("app", c);
      const ep = await deployEP();
      const factory = await (await ethers.getContractFactory("AereAppPaymasterFactory")).deploy(await ep.getAddress());
      await factory.waitForDeployment();
      const dev = signers[6];
      const addr = await factory.connect(dev).createPaymaster.staticCall();
      await (await factory.connect(dev).createPaymaster()).wait();
      const pm = await ethers.getContractAt("AereAppPaymaster", addr);
      const pmAddr = addr;

      const funded = ONE * 50n;
      await (await ep.depositTo(pmAddr, { value: funded })).wait();

      // Config: whitelist a subset of targets, maybe enable sender allowlist, set a cap.
      const targets = [signers[11], signers[12], signers[13]];
      const allowedTargets = new Set();
      for (const t of targets) {
        const allow = chance(rng, 0.6);
        if (allow) { await (await pm.connect(dev).setTargetWhitelist(t.address, true)).wait(); allowedTargets.add(t.address); }
      }
      // guarantee at least one allowed target so success is reachable.
      if (allowedTargets.size === 0) {
        await (await pm.connect(dev).setTargetWhitelist(targets[0].address, true)).wait();
        allowedTargets.add(targets[0].address);
      }
      const senderAllowlist = chance(rng, 0.5);
      const actors = [signers[1], signers[2], signers[3], signers[4]];
      const allowedSenders = new Set();
      if (senderAllowlist) {
        await (await pm.connect(dev).setSenderAllowlist(true)).wait();
        for (const a of actors) if (chance(rng, 0.6)) { await (await pm.connect(dev).setAllowedSender(a.address, true)).wait(); allowedSenders.add(a.address); }
        if (allowedSenders.size === 0) { await (await pm.connect(dev).setAllowedSender(actors[0].address, true)).wait(); allowedSenders.add(actors[0].address); }
      }
      const maxOps = chance(rng, 0.7) ? BigInt(ri(rng, 1, 4)) : 0n; // 0 = unlimited
      if (maxOps > 0n) await (await pm.connect(dev).setMaxOpsPerSender(maxOps)).wait();

      const ops = new Map(); // sender -> count
      let totalCharged = 0n;

      const inv = async (ctx) => {
        const dep = await ep.balanceOf(pmAddr);
        expect(dep, `[app NODRAIN] deposit != funded-charged ${ctx}`).to.equal(funded - totalCharged);
        expect(totalCharged, `[app BUDGET] payout ${totalCharged} > funded ${funded} ${ctx}`).to.be.lte(funded);
        for (const a of actors) {
          const on = await pm.opsCountBySender(a.address);
          if (maxOps > 0n) expect(on, `[app PERSENDER] opsCount>${maxOps} ${ctx} a=${a.address.slice(0, 10)}`).to.be.lte(maxOps);
          expect(on, `[app MIRROR] on-chain opsCount != local ${ctx} a=${a.address.slice(0, 10)}`)
            .to.equal(BigInt(ops.get(a.address) || 0));
        }
      };

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

      for (let s = 0; s < N.APP_STEPS; s++) {
        const actor = pick(rng, actors);
        const target = pick(rng, targets);
        const gasPrice = BigInt(ri(rng, 1, 40)) * 10n ** 6n;
        const ctx = `c${c} s${s} actor=${actor.address.slice(0, 10)} target=${target.address.slice(0, 10)}`;

        const oLocal = ops.get(actor.address) || 0;
        // Validation order: sender allowlist -> per-sender cap -> target whitelist.
        const predictReject =
          senderAllowlist && !allowedSenders.has(actor.address) ? "AppPM: sender not allowed"
          : maxOps > 0n && BigInt(oLocal) >= maxOps ? "AppPM: sender quota exhausted"
          : !allowedTargets.has(target.address) ? "AppPM: target not allowed"
          : null;

        const uo = UO({ sender: actor.address, callData: execCallData(target.address), paymasterAndData: pmAddr });
        const r = await tryRelay(ep, relayer, uo, gasPrice);

        if (r.ok) {
          expect(predictReject, `[app GHOST] sponsored despite predicted reject "${predictReject}" ${ctx}`).to.equal(null);
          expect(r.charged, `[app CHARGE-SIGN] deposit increased on a sponsored op ${ctx}`).to.be.gte(0n);
          totalCharged += r.charged;
          ops.set(actor.address, oLocal + 1);
          nSuccess++;
        } else {
          if (predictReject) {
            expect(r.reason, `[app REASON] expected "${predictReject}" ${ctx} got: ${r.reason}`).to.include(predictReject);
            if (predictReject.includes("target")) nTarget++;
            else if (predictReject.includes("sender not")) nSender++;
            else nQuota++;
          } else {
            expect(r.reason, `[app UNDERFUNDED] validation passed but non-budget revert ${ctx}: ${r.reason}`)
              .to.include("EP: paymaster underfunded");
          }
          expect(r.after, `[app REVERT-DEP] deposit changed on a reverted op ${ctx}`).to.equal(r.before);
          expect(await pm.opsCountBySender(actor.address), `[app REVERT-CNT] counter moved on a reverted op ${ctx}`)
            .to.equal(BigInt(oLocal));
        }

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

    console.log(
      `      [app] steps=${steps} success=${nSuccess} targetRej=${nTarget} senderRej=${nSender} quotaRej=${nQuota}`
    );
    expect(steps, "app: not enough steps").to.be.greaterThan(120);
    expect(nSuccess, "app: never sponsored").to.be.greaterThan(0);
    expect(nTarget + nSender + nQuota, "app: never exercised a gate").to.be.greaterThan(0);
  });
});
