// Comprehensive test + audit pass for AERE's ERC-4337 paymaster stack (roadmap #8).
//
// Contracts under test (deployed + live on chain 2800, previously ZERO coverage):
//   - PaymasterBase / AereEntryPoint       (contracts/paymaster/*)
//   - AereOnboardingPaymaster              (per-sender lifetime + daily sitewide caps)
//   - AereTokenPaymaster                   (pay gas in ERC-20, oracle pricing)
//   - AereStakeQuotaPaymaster              (stake AERE -> daily free-tx quota)
//   - AereAppPaymaster / Factory           (per-dApp paymaster)
//
// Tests run LOCAL (hardhat network) against the ACTUAL source (which matches the
// deployed bytecode per the source-verification manifest). No source is modified
// to make a test pass; failing tests that reflect real contract bugs are KEPT and
// asserted as findings. Nothing is deployed to mainnet.

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

const ONE = 10n ** 18n;
const DAY = 86400;
const ZERO32 = "0x" + "00".repeat(32);

// ── userOp helper: PackedUserOperation as a positional tuple ─────────────────
function UO(o = {}) {
  return [
    o.sender ?? ethers.ZeroAddress,       // sender
    o.nonce ?? 0n,                        // nonce
    o.initCode ?? "0x",                   // initCode
    o.callData ?? "0x",                   // callData
    o.accountGasLimits ?? ZERO32,         // accountGasLimits (bytes32)
    o.preVerificationGas ?? 0n,           // preVerificationGas
    o.gasFees ?? ZERO32,                  // gasFees (bytes32)
    o.paymasterAndData ?? "0x",           // paymasterAndData
    o.signature ?? "0x",                  // signature
  ];
}

// callData whose bytes[16:36] encode `target` (SimpleAccount execute layout).
function execCallData(target) {
  return ethers.concat(["0xb61d27f6", ethers.zeroPadValue(target, 32)]);
}

// paymasterAndData for AereTokenPaymaster: pm(20) | gasLimits(32) | token(20) | maxTokenCost(32)
function tokenPmData(pm, token, maxTokenCost) {
  return ethers.concat([
    pm,
    ZERO32,
    token,
    ethers.zeroPadValue(ethers.toBeHex(maxTokenCost), 32),
  ]);
}

async function impersonate(addr) {
  await network.provider.send("hardhat_setBalance", [addr, "0x3635C9ADC5DEA00000"]); // 1000 ETH
  return ethers.getImpersonatedSigner(addr);
}

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

// JS reference for AereTokenPaymaster.quoteTokenAmount (exact BigInt integer math).
function quoteRef(aereWei, aereUsd1e8, tokPrice1e8, decimals, markupBps) {
  const usdAmount = (aereWei * aereUsd1e8) / 10n ** 8n;
  const amount1e18 = (usdAmount * 10n ** 8n) / tokPrice1e8;
  let tokenAmount;
  if (decimals < 18n) tokenAmount = amount1e18 / 10n ** (18n - decimals);
  else tokenAmount = amount1e18 * 10n ** (decimals - 18n);
  tokenAmount = (tokenAmount * (10000n + markupBps)) / 10000n;
  return tokenAmount;
}

// ═════════════════════════════════════════════════════════════════════════════
describe("PaymasterBase / AereEntryPoint", () => {
  let ep, owner, a, b;
  beforeEach(async () => {
    [owner, a, b] = await ethers.getSigners();
    ep = await deployEP();
  });

  it("depositTo / balanceOf / withdrawTo accounting", async () => {
    await ep.depositTo(a.address, { value: ONE });
    expect(await ep.balanceOf(a.address)).to.equal(ONE);
    // withdrawTo pulls from msg.sender's own deposit
    await ep.connect(a).withdrawTo(b.address, ONE / 2n);
    expect(await ep.balanceOf(a.address)).to.equal(ONE / 2n);
  });

  it("withdrawTo reverts when deposit insufficient", async () => {
    await expect(ep.connect(a).withdrawTo(b.address, ONE)).to.be.revertedWith("EP: insufficient deposit");
  });

  it("nonReentrant blocks reentry into withdrawTo", async () => {
    const R = await (await ethers.getContractFactory("ReentrantWithdrawer")).deploy(await ep.getAddress());
    await R.waitForDeployment();
    await R.fund({ value: ONE });
    // The reentrant receive() re-calls withdrawTo -> nonReentrant reverts -> the
    // value transfer fails -> the whole outer withdraw reverts. Deposit preserved.
    await expect(R.attack(ONE / 2n)).to.be.revertedWith("EP: withdraw transfer failed");
    expect(await ep.balanceOf(await R.getAddress())).to.equal(ONE);
  });

  it("onlyEntryPoint: a random caller cannot invoke validatePaymasterUserOp directly", async () => {
    const OB = await (await ethers.getContractFactory("AereOnboardingPaymaster")).deploy(await ep.getAddress());
    await OB.waitForDeployment();
    await expect(
      OB.connect(a).validatePaymasterUserOp(UO({ sender: a.address }), ZERO32, 0)
    ).to.be.revertedWith("Paymaster: not from EntryPoint");
  });

  it("onlyOwner guards the deposit helpers", async () => {
    const OB = await (await ethers.getContractFactory("AereOnboardingPaymaster")).deploy(await ep.getAddress());
    await OB.waitForDeployment();
    await expect(OB.connect(a).withdrawDeposit(a.address, 1)).to.be.revertedWith("Ownable: caller is not the owner");
    await expect(OB.connect(a).addStake(1)).to.be.revertedWith("Ownable: caller is not the owner");
  });
});

// ═════════════════════════════════════════════════════════════════════════════
describe("AereOnboardingPaymaster", () => {
  let ep, epS, pm, owner, u1, u2, u3;
  beforeEach(async () => {
    [owner, u1, u2, u3] = await ethers.getSigners();
    ep = await deployEP();
    pm = await (await ethers.getContractFactory("AereOnboardingPaymaster")).deploy(await ep.getAddress());
    await pm.waitForDeployment();
    epS = await impersonate(await ep.getAddress());
  });

  it("defaults: 3 lifetime/sender, 100/day, whitelist off", async () => {
    expect(await pm.sponsoredOpsPerSender()).to.equal(3n);
    expect(await pm.dailySitewideCap()).to.equal(100n);
    expect(await pm.targetWhitelistEnabled()).to.equal(false);
  });

  it("per-sender lifetime cap: 3 ok, 4th rejected", async () => {
    for (let i = 0; i < 3; i++) {
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address }), ZERO32, 0);
    }
    expect(await pm.sponsoredCount(u1.address)).to.equal(3n);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address }), ZERO32, 0)
    ).to.be.revertedWith("OnboardingPM: sender exhausted");
  });

  it("lifetime cap does NOT reset after a day rollover", async () => {
    for (let i = 0; i < 3; i++) {
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address }), ZERO32, 0);
    }
    await network.provider.send("evm_increaseTime", [DAY + 1]);
    await network.provider.send("evm_mine");
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address }), ZERO32, 0)
    ).to.be.revertedWith("OnboardingPM: sender exhausted");
  });

  it("daily sitewide cap: boundary enforced, resets on new day", async () => {
    await pm.setLimits(100, 2, false); // 2/day sitewide
    await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address }), ZERO32, 0);
    await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u2.address }), ZERO32, 0);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u3.address }), ZERO32, 0)
    ).to.be.revertedWith("OnboardingPM: daily cap reached");
    // roll to next day -> sitewide counter (keyed by unix day) resets
    await network.provider.send("evm_increaseTime", [DAY + 1]);
    await network.provider.send("evm_mine");
    await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u3.address }), ZERO32, 0);
    expect(await pm.sponsoredCount(u3.address)).to.equal(1n);
  });

  it("target whitelist: allowed target passes, others rejected", async () => {
    const good = u2.address, bad = u3.address;
    await pm.setLimits(3, 100, true); // enable whitelist
    await pm.setWhitelist(good, true);
    await pm.connect(epS).validatePaymasterUserOp(
      UO({ sender: u1.address, callData: execCallData(good) }), ZERO32, 0
    );
    expect(await pm.sponsoredCount(u1.address)).to.equal(1n);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(
        UO({ sender: u1.address, callData: execCallData(bad) }), ZERO32, 0
      )
    ).to.be.revertedWith("OnboardingPM: target not allowed");
  });

  it("target whitelist: short callData rejected", async () => {
    await pm.setLimits(3, 100, true);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: "0x1234" }), ZERO32, 0)
    ).to.be.revertedWith("OnboardingPM: bad callData");
  });

  it("setLimits / setWhitelist are onlyOwner", async () => {
    await expect(pm.connect(u1).setLimits(1, 1, false)).to.be.revertedWith("Ownable: caller is not the owner");
    await expect(pm.connect(u1).setWhitelist(u1.address, true)).to.be.revertedWith("Ownable: caller is not the owner");
  });

  it("end-to-end via AereEntryPoint.relayUserOp: sponsors + charges deposit + emits", async () => {
    const pmAddr = await pm.getAddress();
    await ep.depositTo(pmAddr, { value: ONE });
    const before = await ep.balanceOf(pmAddr);
    // sender = an EOA; call with empty callData to an EOA succeeds. paymasterAndData
    // must carry the paymaster address in its first 20 bytes.
    await expect(ep.relayUserOp(UO({ sender: u1.address, paymasterAndData: pmAddr }))).to.emit(pm, "Sponsored");
    expect(await pm.sponsoredCount(u1.address)).to.equal(1n);
    const after = await ep.balanceOf(pmAddr);
    expect(after).to.be.lte(before); // deposit charged by actualGasCost (0 if gasprice 0)
  });

  it("end-to-end reject bubbles through relayUserOp", async () => {
    const pmAddr = await pm.getAddress();
    await ep.depositTo(pmAddr, { value: ONE });
    for (let i = 0; i < 3; i++) await ep.relayUserOp(UO({ sender: u1.address, paymasterAndData: pmAddr }));
    await expect(ep.relayUserOp(UO({ sender: u1.address, paymasterAndData: pmAddr })))
      .to.be.revertedWith("OnboardingPM: sender exhausted");
  });
});

// ═════════════════════════════════════════════════════════════════════════════
describe("AereTokenPaymaster", () => {
  const AERE_USD = ethers.id("AERE/USD");
  const USDT_USD = ethers.id("USDT/USD");
  let ep, epS, pm, oracle, token, owner, user;

  async function setup(dec = 6n) {
    [owner, user] = await ethers.getSigners();
    ep = await deployEP();
    oracle = await (await ethers.getContractFactory("MockAereOracle")).deploy();
    await oracle.waitForDeployment();
    pm = await (await ethers.getContractFactory("AereTokenPaymaster")).deploy(
      await ep.getAddress(), await oracle.getAddress()
    );
    await pm.waitForDeployment();
    token = await (await ethers.getContractFactory("MockERC20Lending")).deploy("USDT.e", "USDT.e", Number(dec));
    await token.waitForDeployment();
    await pm.setToken(await token.getAddress(), USDT_USD, Number(dec), true);
    epS = await impersonate(await ep.getAddress());
  }

  it("quoteTokenAmount: correct decimal scaling + markup (6-dec token)", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000); // $0.10
    await oracle.set(USDT_USD, 100_000_000); // $1.00
    // 0.02 AERE gas @ $0.10 = $0.002 => 0.002 USDT = 2000 (6dec) base, +5% = 2100
    const aereWei = ONE / 50n; // 0.02 AERE
    const got = await pm.quoteTokenAmount(await token.getAddress(), aereWei);
    const ref = quoteRef(aereWei, 10_000_000n, 100_000_000n, 6n, 500n);
    expect(got).to.equal(ref);
    expect(got).to.equal(2100n);
  });

  it("uses the hardcoded $0.10 fallback when oracle has no AERE/USD (price 0)", async () => {
    await setup(6n);
    // AERE_USD not set -> getPrice returns 0 -> fallback 1e7 ($0.10)
    await oracle.set(USDT_USD, 100_000_000);
    const aereWei = ONE / 50n;
    const got = await pm.quoteTokenAmount(await token.getAddress(), aereWei);
    expect(got).to.equal(quoteRef(aereWei, 10_000_000n, 100_000_000n, 6n, 500n));
    expect(await pm.fallbackAereUsd1e8()).to.equal(10_000_000n);
  });

  it("falls back when the AERE/USD oracle call REVERTS (try/catch branch)", async () => {
    await setup(6n);
    await oracle.set(USDT_USD, 100_000_000);
    await oracle.set(AERE_USD, 55_000_000); // would be $0.55 if read...
    await oracle.setRevert(AERE_USD, true); // ...but the call reverts -> fallback $0.10
    const aereWei = ONE / 50n;
    const got = await pm.quoteTokenAmount(await token.getAddress(), aereWei);
    expect(got).to.equal(quoteRef(aereWei, 10_000_000n, 100_000_000n, 6n, 500n));
  });

  it("reverts when token disabled or token oracle price missing", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    // token price not set -> tokPrice 0
    await expect(pm.quoteTokenAmount(await token.getAddress(), ONE)).to.be.revertedWith("TokenPM: oracle missing");
    // disable token
    await pm.setToken(await token.getAddress(), USDT_USD, 6, false);
    await oracle.set(USDT_USD, 100_000_000);
    await expect(pm.quoteTokenAmount(await token.getAddress(), ONE)).to.be.revertedWith("TokenPM: token disabled");
  });

  it("validate: pulls tokens on success, respects maxTokenCost", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    await oracle.set(USDT_USD, 100_000_000);
    const maxCost = ONE / 50n;
    const cost = await pm.quoteTokenAmount(await token.getAddress(), maxCost);
    await token.mint(user.address, cost * 10n);
    await token.connect(user).approve(await pm.getAddress(), ethers.MaxUint256);
    const pmAddr = await pm.getAddress();
    await pm.connect(epS).validatePaymasterUserOp(
      UO({ sender: user.address, paymasterAndData: tokenPmData(pmAddr, await token.getAddress(), cost) }),
      ZERO32, maxCost
    );
    expect(await token.balanceOf(pmAddr)).to.equal(cost);
  });

  it("validate: reverts when quoted cost exceeds caller's maxTokenCost", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    await oracle.set(USDT_USD, 100_000_000);
    const maxCost = ONE / 50n;
    const cost = await pm.quoteTokenAmount(await token.getAddress(), maxCost);
    const pmAddr = await pm.getAddress();
    await expect(
      pm.connect(epS).validatePaymasterUserOp(
        UO({ sender: user.address, paymasterAndData: tokenPmData(pmAddr, await token.getAddress(), cost - 1n) }),
        ZERO32, maxCost
      )
    ).to.be.revertedWith("TokenPM: token cost exceeds max");
  });

  it("validate: reverts (OZ) when user has not approved", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    await oracle.set(USDT_USD, 100_000_000);
    const maxCost = ONE / 50n;
    const cost = await pm.quoteTokenAmount(await token.getAddress(), maxCost);
    await token.mint(user.address, cost * 10n);
    const pmAddr = await pm.getAddress();
    await expect(
      pm.connect(epS).validatePaymasterUserOp(
        UO({ sender: user.address, paymasterAndData: tokenPmData(pmAddr, await token.getAddress(), cost) }),
        ZERO32, maxCost
      )
    ).to.be.revertedWith("ERC20: insufficient allowance");
  });

  it("validate: 'pull failed' path when transferFrom returns false", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    await oracle.set(USDT_USD, 100_000_000);
    const ft = await (await ethers.getContractFactory("MockERC20FalseTransferFrom")).deploy("FT", "FT", 6);
    await ft.waitForDeployment();
    await pm.setToken(await ft.getAddress(), USDT_USD, 6, true);
    const maxCost = ONE / 50n;
    const cost = await pm.quoteTokenAmount(await ft.getAddress(), maxCost);
    await ft.mint(user.address, cost * 10n);
    await ft.connect(user).approve(await pm.getAddress(), ethers.MaxUint256);
    await ft.setLie(true); // transferFrom returns false without reverting
    const pmAddr = await pm.getAddress();
    await expect(
      pm.connect(epS).validatePaymasterUserOp(
        UO({ sender: user.address, paymasterAndData: tokenPmData(pmAddr, await ft.getAddress(), cost) }),
        ZERO32, maxCost
      )
    ).to.be.revertedWith("TokenPM: pull failed");
  });

  it("validate: bad (too-short) paymasterAndData rejected", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    await oracle.set(USDT_USD, 100_000_000);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address, paymasterAndData: "0xdead" }), ZERO32, ONE)
    ).to.be.revertedWith("TokenPM: bad paymasterData");
  });

  it("setMarkup capped at 3000 bps; setToken/withdrawToken onlyOwner", async () => {
    await setup(6n);
    await expect(pm.setMarkup(3001)).to.be.revertedWith("TokenPM: markup too high");
    await pm.setMarkup(3000);
    expect(await pm.markupBps()).to.equal(3000n);
    await expect(pm.connect(user).setToken(await token.getAddress(), USDT_USD, 6, true)).to.be.revertedWith("Ownable: caller is not the owner");
    await expect(pm.connect(user).withdrawToken(await token.getAddress(), user.address, 1)).to.be.revertedWith("Ownable: caller is not the owner");
  });

  // FINDING: withdrawToken uses transferFrom(address(this), ...) which requires a
  // self-allowance that never exists -> accumulated token revenue is stuck.
  it("FINDING(MED): owner cannot withdraw accumulated tokens (withdrawToken is broken)", async () => {
    await setup(6n);
    await oracle.set(AERE_USD, 10_000_000);
    await oracle.set(USDT_USD, 100_000_000);
    const maxCost = ONE / 50n;
    const cost = await pm.quoteTokenAmount(await token.getAddress(), maxCost);
    await token.mint(user.address, cost * 10n);
    await token.connect(user).approve(await pm.getAddress(), ethers.MaxUint256);
    const pmAddr = await pm.getAddress();
    await pm.connect(epS).validatePaymasterUserOp(
      UO({ sender: user.address, paymasterAndData: tokenPmData(pmAddr, await token.getAddress(), cost) }),
      ZERO32, maxCost
    );
    expect(await token.balanceOf(pmAddr)).to.equal(cost); // tokens are in the paymaster
    // Owner tries to withdraw them -> reverts because the paymaster never approved itself.
    await expect(pm.withdrawToken(await token.getAddress(), owner.address, cost))
      .to.be.revertedWith("ERC20: insufficient allowance");
  });

  it("FUZZ: on-chain quoteTokenAmount matches BigInt reference across decimals/prices/markup", async () => {
    await setup(6n);
    const decs = [6n, 8n, 18n, 24n];
    let zeroCostHits = 0;
    for (let i = 0; i < 200; i++) {
      const dec = decs[Math.floor(Math.random() * decs.length)];
      const markup = BigInt(Math.floor(Math.random() * 3001));
      const aereUsd = BigInt(1 + Math.floor(Math.random() * 2_000_000_000)); // up to $20
      const tokPrice = BigInt(1 + Math.floor(Math.random() * 2_000_000_000));
      const aereWei = BigInt(Math.floor(Math.random() * 1e15)) * 1000n + 1n; // up to ~1 AERE
      await pm.setMarkup(markup);
      await oracle.set(AERE_USD, aereUsd);
      await oracle.set(USDT_USD, tokPrice);
      const tk = await (await ethers.getContractFactory("MockERC20Lending")).deploy("T", "T", Number(dec));
      await tk.waitForDeployment();
      await pm.setToken(await tk.getAddress(), USDT_USD, Number(dec), true);
      const got = await pm.quoteTokenAmount(await tk.getAddress(), aereWei);
      const ref = quoteRef(aereWei, aereUsd, tokPrice, dec, markup);
      expect(got).to.equal(ref);
      if (got === 0n) zeroCostHits++;
    }
    // Informational: rounding-to-zero is reachable for tiny maxCost / cheap AERE.
    console.log(`      fuzz: 200 cases matched reference; ${zeroCostHits} rounded to 0 tokenCost`);
  });
});

// ═════════════════════════════════════════════════════════════════════════════
describe("AereStakeQuotaPaymaster", () => {
  let ep, epS, owner, user;

  beforeEach(async () => {
    [owner, user] = await ethers.getSigners();
    ep = await deployEP();
    epS = await impersonate(await ep.getAddress());
  });

  // ── Group A: proves the LIVE wiring is broken against the REAL staking sources ──
  describe("live wiring against REAL AereStaking + AereLockedStaking", () => {
    let realStaking, realLocked, pm;
    beforeEach(async () => {
      realStaking = await (await ethers.getContractFactory("AereStaking")).deploy();
      await realStaking.waitForDeployment();
      realLocked = await (await ethers.getContractFactory("AereLockedStaking")).deploy();
      await realLocked.waitForDeployment();
      pm = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy(
        await ep.getAddress(), await realStaking.getAddress(), await realLocked.getAddress()
      );
      await pm.waitForDeployment();
    });

    it("FINDING(HIGH): with a real lock present, stakedOf() REVERTS (not just 0)", async () => {
      // Create a genuine 30-day lock of 1,000 AERE for `user`.
      await realLocked.connect(user).stake(0, { value: 1000n * ONE });
      expect(await realLocked.lockCount(user.address)).to.equal(1n);
      const lock = await realLocked.getLock(user.address, 0);
      expect(lock.amount).to.equal(1000n * ONE); // the lock really holds 1,000 AERE

      // The paymaster's getLock interface has a mismatched field order; decoding the
      // real struct's first word (amount=1e21) as `uint8 tier` fails the ABI validity
      // check, and that revert is NOT swallowed by the surrounding try/catch.
      await expect(pm.stakedOf(user.address)).to.be.reverted;      // hard revert
      await expect(pm.dailyQuotaOf(user.address)).to.be.reverted;  // propagates
    });

    it("FINDING(HIGH): a staker with a lock can NEVER be sponsored (validate reverts)", async () => {
      await realLocked.connect(user).stake(0, { value: 5000n * ONE });
      // validate -> dailyQuotaOf -> stakedOf -> revert. Hard DoS for any locker.
      await expect(
        pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0)
      ).to.be.reverted;
    });

    it("FINDING(HIGH): a delegated-only staker (no locks) gets ZERO quota (balanceOf missing)", async () => {
      // With no locks, stakedOf does not revert, but AereStaking has no balanceOf, so
      // the delegated read is caught and contributes 0 -> quota 0 -> always rejected.
      expect(await pm.stakedOf(user.address)).to.equal(0n);
      await expect(
        pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0)
      ).to.be.revertedWith("StakePM: stake more AERE");
    });

    it("root cause 1: AereStaking exposes no balanceOf(address) -> typed call reverts", async () => {
      const asIf = new ethers.Contract(
        await realStaking.getAddress(),
        ["function balanceOf(address) view returns (uint256)"],
        ethers.provider
      );
      await expect(asIf.balanceOf(user.address)).to.be.reverted;
    });

    it("root cause 2 (isolation): a field-order-MATCHED locked-staking makes stakedOf work", async () => {
      // Same paymaster interface, but a locked-staking mock that returns fields in the
      // order the interface declares. Now stakedOf succeeds and counts the lock.
      const matched = await (await ethers.getContractFactory("MockLockedStakingMatched")).deploy();
      await matched.waitForDeployment();
      const pm2 = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy(
        await ep.getAddress(), await realStaking.getAddress(), await matched.getAddress()
      );
      await pm2.waitForDeployment();
      const far = BigInt(Math.floor(Date.now() / 1000) + 3650 * DAY);
      await matched.seed(user.address, 1, 1000n * ONE, far);
      expect(await pm2.stakedOf(user.address)).to.equal(1000n * ONE); // counted correctly
      expect(await pm2.dailyQuotaOf(user.address)).to.equal(20n);
    });
  });

  // ── Group B: proves the quota LOGIC is correct if the interface matched ──
  describe("quota logic (via a correctly-shaped MockStaking)", () => {
    let staking, locked, pm;
    beforeEach(async () => {
      staking = await (await ethers.getContractFactory("MockStaking")).deploy();
      await staking.waitForDeployment();
      locked = await (await ethers.getContractFactory("MockLockedStaking")).deploy();
      await locked.waitForDeployment();
      pm = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy(
        await ep.getAddress(), await staking.getAddress(), await locked.getAddress()
      );
      await pm.waitForDeployment();
    });

    it("quota = staked * 20 / 1000 per day", async () => {
      await staking.setBal(user.address, 1000n * ONE);
      expect(await pm.dailyQuotaOf(user.address)).to.equal(20n);
      await staking.setBal(user.address, 10000n * ONE);
      expect(await pm.dailyQuotaOf(user.address)).to.equal(200n);
      await staking.setBal(user.address, 50n * ONE);
      expect(await pm.dailyQuotaOf(user.address)).to.equal(1n);
      await staking.setBal(user.address, 49n * ONE);
      expect(await pm.dailyQuotaOf(user.address)).to.equal(0n);
    });

    it("full daily quota consumable; (quota+1)-th rejected; resets next day", async () => {
      await staking.setBal(user.address, 100n * ONE); // quota = 2/day
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0);
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0);
      await expect(
        pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0)
      ).to.be.revertedWith("StakePM: daily quota exhausted");
      await network.provider.send("evm_increaseTime", [DAY + 1]);
      await network.provider.send("evm_mine");
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0); // ok again
    });

    it("zero-stake sender rejected", async () => {
      await expect(
        pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0)
      ).to.be.revertedWith("StakePM: stake more AERE");
    });

    it("per-op gas cap enforced (maxCost <= maxSponsoredGasWei)", async () => {
      await staking.setBal(user.address, 1000n * ONE);
      const cap = await pm.maxSponsoredGasWei();
      await expect(
        pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, cap + 1n)
      ).to.be.revertedWith("StakePM: tx too expensive");
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, cap); // boundary ok
    });

    it("setQuotaParams onlyOwner; reconfig changes quota", async () => {
      await expect(pm.connect(user).setQuotaParams(40, ONE)).to.be.revertedWith("Ownable: caller is not the owner");
      await pm.setQuotaParams(40, ONE); // 40 tx / 1000 AERE
      await staking.setBal(user.address, 1000n * ONE);
      expect(await pm.dailyQuotaOf(user.address)).to.equal(40n);
    });

    it("stakedOf tolerates a reverting staking source (try/catch)", async () => {
      await staking.setBal(user.address, 1000n * ONE);
      await staking.setBoom(true); // balanceOf now reverts
      expect(await pm.stakedOf(user.address)).to.equal(0n); // gracefully falls to 0
    });
  });

  // ── Group C: the unbounded lock loop in stakedOf() — DoS / OOG quantification ──
  describe("unbounded lock loop in stakedOf() — gas / DoS", () => {
    let staking, probe;
    beforeEach(async () => {
      staking = await (await ethers.getContractFactory("MockStaking")).deploy();
      await staking.waitForDeployment();
      probe = await (await ethers.getContractFactory("StakeGasProbe")).deploy();
      await probe.waitForDeployment();
    });

    it("as deployed: the faithful (real field order) locked-staking makes stakedOf revert on lock #1", async () => {
      // Corroborates the real-AereLockedStaking result with a portable mock: a single
      // lock is enough to brick stakedOf, so the 'unbounded loop OOG' can never even be
      // reached in the deployed configuration - iteration 0 reverts first.
      const faithful = await (await ethers.getContractFactory("MockLockedStaking")).deploy();
      await faithful.waitForDeployment();
      const pm = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy(
        await ep.getAddress(), await staking.getAddress(), await faithful.getAddress()
      );
      await pm.waitForDeployment();
      const far = BigInt(Math.floor(Date.now() / 1000) + 3650 * DAY);
      await faithful.seed(user.address, 1, ONE, far);
      await expect(pm.stakedOf(user.address)).to.be.reverted;
    });

    it("if the interface were fixed: quantify per-lock gas and the self-DoS OOG threshold", async () => {
      // Uses a field-order-MATCHED locked-staking so the loop actually executes. This
      // answers: even after the decode bug is fixed, is the unbounded loop a problem?
      const matched = await (await ethers.getContractFactory("MockLockedStakingMatched")).deploy();
      await matched.waitForDeployment();
      const pm = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy(
        await ep.getAddress(), await staking.getAddress(), await matched.getAddress()
      );
      await pm.waitForDeployment();

      const far = BigInt(Math.floor(Date.now() / 1000) + 3650 * DAY);
      const seedChunk = async (m) => { // stay under the per-tx gas cap
        while (m > 0) { const c = Math.min(m, 100); await matched.seed(user.address, c, ONE, far, { gasLimit: 15_000_000 }); m -= c; }
      };
      const counts = [0, 1, 10, 50, 100, 250, 500];
      const rows = [];
      let prevN = 0, prevGas = 0n, perLock = 0n;
      for (const n of counts) {
        if (n > prevN) await seedChunk(n - prevN);
        const [gasUsed] = await probe.measure.staticCall(await pm.getAddress(), user.address);
        rows.push({ n, gas: gasUsed });
        if (n > 0) perLock = (gasUsed - prevGas) / BigInt(n - prevN);
        prevN = n; prevGas = gasUsed;
      }
      console.log("      locks -> stakedOf() gas (interface-fixed hypothetical):");
      for (const r of rows) console.log(`        ${String(r.n).padStart(5)} locks : ${r.gas} gas`);
      console.log(`      marginal cost ~ ${perLock} gas / lock`);
      const base = rows[0].gas;
      for (const budget of [150000n, 500000n, 1000000n, 30000000n]) {
        const maxLocks = perLock > 0n ? (budget - base) / perLock : 0n;
        console.log(`      ~OOG at ${budget} gas budget: ${maxLocks} locks (self-inflicted only)`);
      }
      // sanity: with `last` counted locks of 1 AERE each, quota = last*20/1000.
      const last = BigInt(counts[counts.length - 1]);
      expect(await pm.dailyQuotaOf(user.address)).to.equal((last * 20n) / 1000n);
      expect(perLock).to.be.greaterThan(0n);
    });
  });
});

// ═════════════════════════════════════════════════════════════════════════════
describe("AereAppPaymasterFactory / AereAppPaymaster", () => {
  let ep, epS, factory, owner, dev, u1, u2;
  beforeEach(async () => {
    [owner, dev, u1, u2] = await ethers.getSigners();
    ep = await deployEP();
    factory = await (await ethers.getContractFactory("AereAppPaymasterFactory")).deploy(await ep.getAddress());
    await factory.waitForDeployment();
    epS = await impersonate(await ep.getAddress());
  });

  async function createPM(signer) {
    const addr = await factory.connect(signer).createPaymaster.staticCall();
    await factory.connect(signer).createPaymaster();
    return ethers.getContractAt("AereAppPaymaster", addr);
  }

  it("createPaymaster: deployer becomes owner, entryPoint wired, distinct instances", async () => {
    const pm1 = await createPM(dev);
    const pm2 = await createPM(dev);
    expect(await pm1.owner()).to.equal(dev.address);
    expect(await pm1.entryPoint()).to.equal(await ep.getAddress());
    expect(await pm1.getAddress()).to.not.equal(await pm2.getAddress()); // `new`, not a shared clone
  });

  it("target whitelist is REQUIRED (unlisted target rejected, listed passes)", async () => {
    const pm = await createPM(dev);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: execCallData(u2.address) }), ZERO32, 0)
    ).to.be.revertedWith("AppPM: target not allowed");
    await pm.connect(dev).setTargetWhitelist(u2.address, true);
    await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: execCallData(u2.address) }), ZERO32, 0);
    expect(await pm.opsCountBySender(u1.address)).to.equal(1n);
  });

  it("bad (short) callData rejected", async () => {
    const pm = await createPM(dev);
    await pm.connect(dev).setTargetWhitelist(u2.address, true);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: "0x1234" }), ZERO32, 0)
    ).to.be.revertedWith("AppPM: bad callData");
  });

  it("sender allowlist gates when enabled", async () => {
    const pm = await createPM(dev);
    await pm.connect(dev).setTargetWhitelist(u2.address, true);
    await pm.connect(dev).setSenderAllowlist(true);
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: execCallData(u2.address) }), ZERO32, 0)
    ).to.be.revertedWith("AppPM: sender not allowed");
    await pm.connect(dev).setAllowedSender(u1.address, true);
    await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: execCallData(u2.address) }), ZERO32, 0);
    expect(await pm.opsCountBySender(u1.address)).to.equal(1n);
  });

  it("per-sender cap enforced when > 0", async () => {
    const pm = await createPM(dev);
    await pm.connect(dev).setTargetWhitelist(u2.address, true);
    await pm.connect(dev).setMaxOpsPerSender(2);
    for (let i = 0; i < 2; i++) {
      await pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: execCallData(u2.address) }), ZERO32, 0);
    }
    await expect(
      pm.connect(epS).validatePaymasterUserOp(UO({ sender: u1.address, callData: execCallData(u2.address) }), ZERO32, 0)
    ).to.be.revertedWith("AppPM: sender quota exhausted");
  });

  it("admin setters are onlyOwner (non-owner blocked)", async () => {
    const pm = await createPM(dev);
    await expect(pm.connect(u1).setTargetWhitelist(u2.address, true)).to.be.revertedWith("Ownable: caller is not the owner");
    await expect(pm.connect(u1).setSenderAllowlist(true)).to.be.revertedWith("Ownable: caller is not the owner");
    await expect(pm.connect(u1).setMaxOpsPerSender(5)).to.be.revertedWith("Ownable: caller is not the owner");
  });
});
