// Proof suite for the paymaster V2 bug-fixes (roadmap #8 follow-up). // // Proves — against the ACTUAL AereStaking + AereLockedStaking sources (byte- // identical to the live chain-2800 deployments, verified on-chain: real // AereStaking has NO balanceOf, real AereLockedStaking.getLock returns amount // first) — that: // // AereStakeQuotaPaymasterV2 // * reads a REAL AereLockedStaking lock correctly where V1 hard-REVERTS // * reads a REAL AereStaking delegation where V1 sees 0 (balanceOf missing) // * bounds its lock scan at MAX_LOCKS_SCANNED (F3) // * keeps V1's quota math / daily reset / per-op cap // // AereTokenPaymasterV2 // * withdrawToken(transfer) SUCCEEDS where V1's transferFrom(self) reverts // // Runs LOCAL (hardhat). Nothing here 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); 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", ]; } 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; } // ═════════════════════════════════════════════════════════════════════════════ describe("AereStakeQuotaPaymasterV2 — fixes vs REAL AereStaking + AereLockedStaking", () => { let ep, epS, realStaking, realLocked, v1, v2, owner, user, val, del; beforeEach(async () => { [owner, user, val, del] = await ethers.getSigners(); ep = await deployEP(); epS = await impersonate(await ep.getAddress()); realStaking = await (await ethers.getContractFactory("AereStaking")).deploy(); await realStaking.waitForDeployment(); realLocked = await (await ethers.getContractFactory("AereLockedStaking")).deploy(); await realLocked.waitForDeployment(); // V1 (buggy) and V2 (fixed) wired to the SAME real staking addresses. v1 = await (await ethers.getContractFactory("AereStakeQuotaPaymaster")).deploy( await ep.getAddress(), await realStaking.getAddress(), await realLocked.getAddress() ); await v1.waitForDeployment(); v2 = await (await ethers.getContractFactory("AereStakeQuotaPaymasterV2")).deploy( await ep.getAddress(), await realStaking.getAddress(), await realLocked.getAddress() ); await v2.waitForDeployment(); }); it("REAL lock present: V1.stakedOf REVERTS, V2.stakedOf returns the real amount", async () => { await realLocked.connect(user).stake(0, { value: 1000n * ONE }); // 30-day, 1,000 AERE const lock = await realLocked.getLock(user.address, 0); expect(lock.amount).to.equal(1000n * ONE); // V1: mis-decodes the real struct → hard revert (the live bug). await expect(v1.stakedOf(user.address)).to.be.reverted; // V2: matched field order → reads the lock correctly. expect(await v2.stakedOf(user.address)).to.equal(1000n * ONE); expect(await v2.dailyQuotaOf(user.address)).to.equal(20n); }); it("REAL lock present: V1 validate REVERTS (DoS), V2 validate SPONSORS", async () => { await realLocked.connect(user).stake(0, { value: 1000n * ONE }); await expect( v1.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0) ).to.be.reverted; await expect( v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0) ).to.emit(v2, "Sponsored"); }); it("REAL delegation: V1 sees 0 (balanceOf missing), V2 counts the real delegation", async () => { await realStaking.connect(val).registerValidator(1000, { value: 500n * ONE }); // 500 AERE self-stake await realStaking.connect(del).delegate(val.address, { value: 250n * ONE }); // delegate 250 AERE // V1: balanceOf() reverts (caught → 0); delegator has no locks → 0. Delegation invisible. expect(await v1.stakedOf(del.address)).to.equal(0n); // V2: sums delegations across validatorList → sees the 250 AERE. expect(await v2.stakedOf(del.address)).to.equal(250n * ONE); // V2 also counts a validator's own self-stake. expect(await v2.stakedOf(val.address)).to.equal(500n * ONE); }); it("REAL delegation + REAL lock combine in V2.stakedOf", async () => { await realStaking.connect(val).registerValidator(1000, { value: 500n * ONE }); await realStaking.connect(del).delegate(val.address, { value: 250n * ONE }); await realLocked.connect(del).stake(0, { value: 100n * ONE }); expect(await v2.stakedOf(del.address)).to.equal(350n * ONE); // 250 delegated + 100 locked }); it("V2 ignores a withdrawn / expired lock (same rule as V1 intended)", async () => { await realLocked.connect(user).stake(0, { value: 1000n * ONE }); expect(await v2.stakedOf(user.address)).to.equal(1000n * ONE); // early-exit the lock → withdrawn=true → no longer counted await realLocked.connect(user).earlyExit(0); expect(await v2.stakedOf(user.address)).to.equal(0n); }); it("F3: MAX_LOCKS_SCANNED bounds the lock loop (150 real-shaped locks → counts 100)", async () => { // MockLockedStaking has the REAL amount-first struct + a batch seed helper. const mock = await (await ethers.getContractFactory("MockLockedStaking")).deploy(); await mock.waitForDeployment(); const pm = await (await ethers.getContractFactory("AereStakeQuotaPaymasterV2")).deploy( await ep.getAddress(), await realStaking.getAddress(), await mock.getAddress() ); await pm.waitForDeployment(); const far = BigInt(Math.floor(Date.now() / 1000) + 3650 * DAY); // 150 active locks of 1 AERE each. await mock.seed(user.address, 60, ONE, far, { gasLimit: 15_000_000 }); await mock.seed(user.address, 60, ONE, far, { gasLimit: 15_000_000 }); await mock.seed(user.address, 30, ONE, far, { gasLimit: 15_000_000 }); expect(await mock.lockCount(user.address)).to.equal(150n); const cap = await pm.MAX_LOCKS_SCANNED(); expect(cap).to.equal(100n); // Only the first 100 are scanned → 100 AERE counted (not 150). expect(await pm.stakedOf(user.address)).to.equal(100n * ONE); }); // ── quota logic preserved (driven via a REAL lock of known size) ── it("quota = staked*20/1000; full quota consumable; (quota+1)-th rejected; resets next day", async () => { await realLocked.connect(user).stake(0, { value: 100n * ONE }); // quota = 2/day expect(await v2.dailyQuotaOf(user.address)).to.equal(2n); await v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0); await v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0); await expect( v2.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 v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0); // ok again }); it("zero-stake sender rejected; per-op gas cap enforced", async () => { await expect( v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, 0) ).to.be.revertedWith("StakePM: stake more AERE"); await realLocked.connect(user).stake(0, { value: 1000n * ONE }); const capWei = await v2.maxSponsoredGasWei(); await expect( v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, capWei + 1n) ).to.be.revertedWith("StakePM: tx too expensive"); await v2.connect(epS).validatePaymasterUserOp(UO({ sender: user.address }), ZERO32, capWei); // boundary ok }); it("stakedOf tolerates a reverting locked-staking source (try/catch)", async () => { const mock = await (await ethers.getContractFactory("MockLockedStaking")).deploy(); await mock.waitForDeployment(); const pm = await (await ethers.getContractFactory("AereStakeQuotaPaymasterV2")).deploy( await ep.getAddress(), await realStaking.getAddress(), await mock.getAddress() ); await pm.waitForDeployment(); const far = BigInt(Math.floor(Date.now() / 1000) + 3650 * DAY); await mock.seed(user.address, 1, 1000n * ONE, far); expect(await pm.stakedOf(user.address)).to.equal(1000n * ONE); await mock.setBoom(true); // lockCount now reverts expect(await pm.stakedOf(user.address)).to.equal(0n); // degrades to 0, no revert }); }); // ═════════════════════════════════════════════════════════════════════════════ describe("AereTokenPaymasterV2 — withdrawToken(transfer) fix", () => { const AERE_USD = ethers.id("AERE/USD"); const USDT_USD = ethers.id("USDT/USD"); let ep, epS, oracle, token, owner, user; async function accumulate(pm) { // Drive one validate so the paymaster ends up holding `cost` tokens. await oracle.set(AERE_USD, 10_000_000); // $0.10 await oracle.set(USDT_USD, 100_000_000); // $1.00 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 ); return cost; } beforeEach(async () => { [owner, user] = await ethers.getSigners(); ep = await deployEP(); epS = await impersonate(await ep.getAddress()); oracle = await (await ethers.getContractFactory("MockAereOracle")).deploy(); await oracle.waitForDeployment(); token = await (await ethers.getContractFactory("MockERC20Lending")).deploy("USDT.e", "USDT.e", 6); await token.waitForDeployment(); }); it("V1 CANNOT withdraw (transferFrom self reverts) — reproduces the finding", async () => { const pm = await (await ethers.getContractFactory("AereTokenPaymaster")).deploy( await ep.getAddress(), await oracle.getAddress() ); await pm.waitForDeployment(); await pm.setToken(await token.getAddress(), USDT_USD, 6, true); const cost = await accumulate(pm); expect(await token.balanceOf(await pm.getAddress())).to.equal(cost); await expect(pm.withdrawToken(await token.getAddress(), owner.address, cost)) .to.be.revertedWith("ERC20: insufficient allowance"); }); it("V2 CAN withdraw accumulated tokens via transfer()", async () => { const pm = await (await ethers.getContractFactory("AereTokenPaymasterV2")).deploy( await ep.getAddress(), await oracle.getAddress() ); await pm.waitForDeployment(); await pm.setToken(await token.getAddress(), USDT_USD, 6, true); const cost = await accumulate(pm); const pmAddr = await pm.getAddress(); expect(await token.balanceOf(pmAddr)).to.equal(cost); const before = await token.balanceOf(owner.address); await expect(pm.withdrawToken(await token.getAddress(), owner.address, cost)) .to.emit(pm, "Withdrawn").withArgs(await token.getAddress(), owner.address, cost); expect(await token.balanceOf(owner.address)).to.equal(before + cost); expect(await token.balanceOf(pmAddr)).to.equal(0n); }); it("V2 withdrawToken is onlyOwner", async () => { const pm = await (await ethers.getContractFactory("AereTokenPaymasterV2")).deploy( await ep.getAddress(), await oracle.getAddress() ); await pm.waitForDeployment(); await expect(pm.connect(user).withdrawToken(await token.getAddress(), user.address, 1)) .to.be.revertedWith("Ownable: caller is not the owner"); }); it("V2 quote math unchanged from V1 (6-dec, $0.10 AERE, 5% markup → 2100)", async () => { const pm = await (await ethers.getContractFactory("AereTokenPaymasterV2")).deploy( await ep.getAddress(), await oracle.getAddress() ); await pm.waitForDeployment(); await pm.setToken(await token.getAddress(), USDT_USD, 6, true); await oracle.set(AERE_USD, 10_000_000); await oracle.set(USDT_USD, 100_000_000); const got = await pm.quoteTokenAmount(await token.getAddress(), ONE / 50n); expect(got).to.equal(2100n); }); });