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