Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
709 lines
35 KiB
JavaScript
709 lines
35 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)
|
|
//
|
|
// STATUS OF THE `FINDING(...)` TESTS BELOW: see ../KNOWN-ISSUES.md. Both findings
|
|
// affect V1 paymasters that are deployed but DEPRECATED and superseded by V2, and
|
|
// both hold zero native balance as measured at head block 10,572,989. Read that
|
|
// file before quoting a finding from this one. Note also that where a test group
|
|
// below says "LIVE wiring", it means against real contract implementations rather
|
|
// than mocks, on a local hardhat network. It does NOT mean against mainnet 2800.
|
|
//
|
|
// 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");
|
|
});
|
|
});
|