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.
554 lines
28 KiB
JavaScript
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);
|
|
});
|
|
});
|