// ============================================================================= // 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 (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); }); });