aere-contracts/test/paymaster-stack.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

702 lines
34 KiB
JavaScript

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