aere-contracts/test/paymaster-nodrain-invariant-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

554 lines
28 KiB
JavaScript

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