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