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.
702 lines
34 KiB
JavaScript
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");
|
|
});
|
|
});
|