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.
280 lines
13 KiB
JavaScript
280 lines
13 KiB
JavaScript
// =============================================================================
|
|
// AereEntryPointV2 — PAYMASTER-CONSENT security tests.
|
|
//
|
|
// Reproduces and locks the fix for the adversarial-review CRITICAL:
|
|
// handleOps charged an ARBITRARY paymaster's deposit with NO paymaster
|
|
// validation. `paymaster` was read straight from the ATTACKER-supplied
|
|
// op.paymasterAndData, and `_deposits[paymaster] -= actualGasCost` was sent to
|
|
// a caller-chosen beneficiary. Any caller could therefore name a victim
|
|
// paymaster and drain its EntryPoint deposit to themselves, with no consent,
|
|
// no validatePaymasterUserOp, no stake, no postOp.
|
|
//
|
|
// The fix: during the validation phase the EntryPoint calls the named
|
|
// paymaster's validatePaymasterUserOp(op, userOpHash, maxCost). A paymaster is
|
|
// only charged if IT actively approved THIS exact userOpHash. Reverting, not a
|
|
// contract, wrong interface, wrong EntryPoint, or a nonzero validationData ->
|
|
// the op is rejected and the paymaster's deposit is never touched.
|
|
//
|
|
// Run in isolation:
|
|
// npx hardhat test test/entrypoint-v2-paymaster-consent.test.js
|
|
//
|
|
// No deploy to any live node. No em-dashes anywhere in this file.
|
|
// =============================================================================
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
|
|
const abi = ethers.AbiCoder.defaultAbiCoder();
|
|
const E = (n) => ethers.parseEther(String(n));
|
|
const ONE_GWEI = 1_000_000_000n;
|
|
const BAL_1000 = "0x3635C9ADC5DEA00000"; // 1000 ether
|
|
|
|
// SignatureWrapper{uint256 ownerIndex, bytes signatureData}
|
|
function wrap(ownerIndex, signatureData) {
|
|
return abi.encode(["tuple(uint256,bytes)"], [[ownerIndex, signatureData]]);
|
|
}
|
|
function eoaOwnerBytes(addr) {
|
|
return abi.encode(["address"], [addr]);
|
|
}
|
|
// accountGasLimits = verificationGas (high 128) || callGas (low 128)
|
|
function packGasLimits(verifGas, callGas) {
|
|
return ethers.zeroPadValue(ethers.toBeHex((BigInt(verifGas) << 128n) | BigInt(callGas)), 32);
|
|
}
|
|
// gasFees = maxPriorityFee (high 128) || maxFee (low 128)
|
|
function packGasFees(maxPriority, maxFee) {
|
|
return ethers.zeroPadValue(ethers.toBeHex((BigInt(maxPriority) << 128n) | BigInt(maxFee)), 32);
|
|
}
|
|
|
|
function makeOp(sender, nonce, callData, paymasterAndData, maxFee) {
|
|
return {
|
|
sender,
|
|
nonce,
|
|
initCode: "0x",
|
|
callData,
|
|
accountGasLimits: packGasLimits(500000, 500000),
|
|
preVerificationGas: 50000,
|
|
gasFees: packGasFees(0, maxFee),
|
|
paymasterAndData: paymasterAndData || "0x",
|
|
signature: "0x",
|
|
};
|
|
}
|
|
|
|
async function signOwner(entry, wallet, ownerIndex, op) {
|
|
const hash = await entry.getUserOpHash(op);
|
|
const raw = await wallet.signMessage(ethers.getBytes(hash));
|
|
return wrap(ownerIndex, raw);
|
|
}
|
|
|
|
describe("AereEntryPointV2 - paymaster consent (drain fix)", function () {
|
|
this.timeout(120000);
|
|
|
|
let epF, factoryF, pmF;
|
|
let deployer, beneficiary;
|
|
|
|
before(async () => {
|
|
[deployer, beneficiary] = await ethers.getSigners();
|
|
epF = await ethers.getContractFactory("AereEntryPointV2");
|
|
factoryF = await ethers.getContractFactory("AerePasskeyAccountFactoryV2");
|
|
pmF = await ethers.getContractFactory("MockConsentPaymaster");
|
|
});
|
|
|
|
// Deploy a fresh EntryPoint + factory, and a passkey account owned by `ownerWallet`.
|
|
async function freshAccount(ownerWallet, salt) {
|
|
const entry = await epF.deploy();
|
|
await entry.waitForDeployment();
|
|
const factory = await factoryF.deploy(await entry.getAddress());
|
|
await factory.waitForDeployment();
|
|
|
|
const owners = [eoaOwnerBytes(ownerWallet.address)];
|
|
const predicted = await factory.predictAddress(owners, salt);
|
|
await (await factory.createAccount(owners, salt)).wait();
|
|
const acct = await ethers.getContractAt("AerePasskeyAccountV2", predicted);
|
|
// Fund the account so the self-pay prefund path has balance to draw on.
|
|
await network.provider.send("hardhat_setBalance", [predicted, BAL_1000]);
|
|
return { entry, factory, acct, acctAddr: predicted };
|
|
}
|
|
|
|
// A harmless inner call: account.executeFromEntryPoint(sink, 0, "0x").
|
|
function innerCall(acct, sink) {
|
|
return acct.interface.encodeFunctionData("executeFromEntryPoint", [sink, 0, "0x"]);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 1. THE EXPLOIT (pre-fix drain) is PREVENTED: a funded paymaster that
|
|
// did NOT consent cannot be charged by an attacker who merely names it.
|
|
// ------------------------------------------------------------------
|
|
it("EXPLOIT PREVENTED: naming a non-consenting funded paymaster reverts, deposit untouched", async () => {
|
|
const attacker = ethers.Wallet.createRandom().connect(ethers.provider);
|
|
const { entry, acct, acctAddr } = await freshAccount(attacker, 1);
|
|
|
|
// Victim paymaster: bound to THIS EntryPoint but configured to refuse consent.
|
|
const victim = await pmF.deploy(await entry.getAddress());
|
|
await victim.waitForDeployment();
|
|
await victim.setConsent(false);
|
|
const victimAddr = await victim.getAddress();
|
|
|
|
// Foundation pre-funds the victim paymaster's EntryPoint deposit.
|
|
await entry.depositTo(victimAddr, { value: E(5) });
|
|
const depositBefore = await entry.balanceOf(victimAddr);
|
|
expect(depositBefore).to.equal(E(5));
|
|
|
|
const sink = ethers.Wallet.createRandom().address;
|
|
const nonce = await entry.getNonce(acctAddr);
|
|
const op = makeOp(acctAddr, nonce, innerCall(acct, sink), victimAddr, ONE_GWEI);
|
|
op.signature = await signOwner(entry, attacker, 0, op);
|
|
|
|
// Pre-fix, this handleOps drained the victim to `beneficiary`. Now it reverts.
|
|
await expect(
|
|
entry.handleOps([op], beneficiary.address)
|
|
).to.be.revertedWithCustomError(entry, "PaymasterValidationFailed");
|
|
|
|
// The victim's deposit is completely untouched.
|
|
expect(await entry.balanceOf(victimAddr)).to.equal(depositBefore);
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// 2. Paymaster bound to a DIFFERENT EntryPoint cannot be charged here
|
|
// (its onlyEntryPoint-style check reverts our validate call).
|
|
// ------------------------------------------------------------------
|
|
it("EXPLOIT PREVENTED: paymaster bound to another EntryPoint cannot be charged", async () => {
|
|
const attacker = ethers.Wallet.createRandom().connect(ethers.provider);
|
|
const { entry, acct, acctAddr } = await freshAccount(attacker, 2);
|
|
|
|
const otherEntryPoint = ethers.Wallet.createRandom().address;
|
|
const victim = await pmF.deploy(otherEntryPoint); // consents, but only for the OTHER EP
|
|
await victim.waitForDeployment();
|
|
const victimAddr = await victim.getAddress();
|
|
|
|
await entry.depositTo(victimAddr, { value: E(3) });
|
|
const depositBefore = await entry.balanceOf(victimAddr);
|
|
|
|
const sink = ethers.Wallet.createRandom().address;
|
|
const nonce = await entry.getNonce(acctAddr);
|
|
const op = makeOp(acctAddr, nonce, innerCall(acct, sink), victimAddr, ONE_GWEI);
|
|
op.signature = await signOwner(entry, attacker, 0, op);
|
|
|
|
await expect(
|
|
entry.handleOps([op], beneficiary.address)
|
|
).to.be.revertedWithCustomError(entry, "PaymasterValidationFailed");
|
|
expect(await entry.balanceOf(victimAddr)).to.equal(depositBefore);
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// 3. A NON-CONTRACT address that happens to hold a deposit cannot be
|
|
// drained by naming it as a paymaster.
|
|
// ------------------------------------------------------------------
|
|
it("EXPLOIT PREVENTED: a non-contract 'paymaster' with a deposit cannot be charged", async () => {
|
|
const attacker = ethers.Wallet.createRandom().connect(ethers.provider);
|
|
const { entry, acct, acctAddr } = await freshAccount(attacker, 3);
|
|
|
|
const victimEOA = ethers.Wallet.createRandom().address; // no code
|
|
await entry.depositTo(victimEOA, { value: E(2) });
|
|
const depositBefore = await entry.balanceOf(victimEOA);
|
|
|
|
const sink = ethers.Wallet.createRandom().address;
|
|
const nonce = await entry.getNonce(acctAddr);
|
|
const op = makeOp(acctAddr, nonce, innerCall(acct, sink), victimEOA, ONE_GWEI);
|
|
op.signature = await signOwner(entry, attacker, 0, op);
|
|
|
|
await expect(
|
|
entry.handleOps([op], beneficiary.address)
|
|
).to.be.revertedWithCustomError(entry, "PaymasterValidationFailed");
|
|
expect(await entry.balanceOf(victimEOA)).to.equal(depositBefore);
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// 4. A paymaster that consents but returns a nonzero validationData
|
|
// (signature/time failure) is treated as no-consent -> rejected.
|
|
// ------------------------------------------------------------------
|
|
it("EXPLOIT PREVENTED: nonzero paymaster validationData is rejected", async () => {
|
|
const attacker = ethers.Wallet.createRandom().connect(ethers.provider);
|
|
const { entry, acct, acctAddr } = await freshAccount(attacker, 4);
|
|
|
|
const victim = await pmF.deploy(await entry.getAddress());
|
|
await victim.waitForDeployment();
|
|
await victim.setReturnNonzero(true); // returns validationData = 1
|
|
const victimAddr = await victim.getAddress();
|
|
await entry.depositTo(victimAddr, { value: E(2) });
|
|
const depositBefore = await entry.balanceOf(victimAddr);
|
|
|
|
const sink = ethers.Wallet.createRandom().address;
|
|
const nonce = await entry.getNonce(acctAddr);
|
|
const op = makeOp(acctAddr, nonce, innerCall(acct, sink), victimAddr, ONE_GWEI);
|
|
op.signature = await signOwner(entry, attacker, 0, op);
|
|
|
|
await expect(
|
|
entry.handleOps([op], beneficiary.address)
|
|
).to.be.revertedWithCustomError(entry, "PaymasterValidationFailed");
|
|
expect(await entry.balanceOf(victimAddr)).to.equal(depositBefore);
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// 5. LEGITIMATE, CONSENTING paymaster IS charged, and the charge is
|
|
// linked to the exact userOpHash it approved.
|
|
// ------------------------------------------------------------------
|
|
it("LEGIT PATH: a consenting paymaster is charged for exactly the op it approved", async () => {
|
|
const owner = ethers.Wallet.createRandom().connect(ethers.provider);
|
|
const { entry, acct, acctAddr } = await freshAccount(owner, 5);
|
|
|
|
const pm = await pmF.deploy(await entry.getAddress()); // consent = true by default
|
|
await pm.waitForDeployment();
|
|
const pmAddr = await pm.getAddress();
|
|
await entry.depositTo(pmAddr, { value: E(5) });
|
|
const pmDepositBefore = await entry.balanceOf(pmAddr);
|
|
const acctDepositBefore = await entry.balanceOf(acctAddr);
|
|
|
|
const sink = ethers.Wallet.createRandom().address;
|
|
const nonce = await entry.getNonce(acctAddr);
|
|
const op = makeOp(acctAddr, nonce, innerCall(acct, sink), pmAddr, ONE_GWEI);
|
|
op.signature = await signOwner(entry, owner, 0, op);
|
|
const opHash = await entry.getUserOpHash(op);
|
|
|
|
const beneBefore = await ethers.provider.getBalance(beneficiary.address);
|
|
await (await entry.handleOps([op], beneficiary.address)).wait();
|
|
|
|
// Paymaster consent hook was called for THIS exact op.
|
|
expect(await pm.validateCalls()).to.equal(1n);
|
|
expect(await pm.lastUserOpHash()).to.equal(opHash);
|
|
|
|
// Paymaster deposit dropped by a positive actualGasCost; account deposit untouched.
|
|
const pmDepositAfter = await entry.balanceOf(pmAddr);
|
|
const charged = pmDepositBefore - pmDepositAfter;
|
|
expect(charged, "paymaster must be charged a positive gas cost").to.be.greaterThan(0n);
|
|
expect(await entry.balanceOf(acctAddr), "sponsored op must not touch the account deposit")
|
|
.to.equal(acctDepositBefore);
|
|
|
|
// Beneficiary received exactly the collected amount.
|
|
const beneAfter = await ethers.provider.getBalance(beneficiary.address);
|
|
expect(beneAfter - beneBefore).to.equal(charged);
|
|
|
|
// Nonce advanced.
|
|
expect(await entry.getNonce(acctAddr)).to.equal(nonce + 1n);
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// 6. SELF-PAY path is unchanged: no paymaster -> the account's own
|
|
// deposit funds the op (account-prefund path preserved).
|
|
// ------------------------------------------------------------------
|
|
it("SELF-PAY PATH: an op with no paymaster charges the account's own deposit", async () => {
|
|
const owner = ethers.Wallet.createRandom().connect(ethers.provider);
|
|
const { entry, acct, acctAddr } = await freshAccount(owner, 6);
|
|
|
|
const sink = ethers.Wallet.createRandom().address;
|
|
const nonce = await entry.getNonce(acctAddr);
|
|
// No paymasterAndData -> self-paying. The account prefunds maxCost into its
|
|
// own EntryPoint deposit during validation, then is charged actualGasCost.
|
|
const op = makeOp(acctAddr, nonce, innerCall(acct, sink), "0x", ONE_GWEI);
|
|
op.signature = await signOwner(entry, owner, 0, op);
|
|
|
|
const beneBefore = await ethers.provider.getBalance(beneficiary.address);
|
|
await (await entry.handleOps([op], beneficiary.address)).wait();
|
|
|
|
// The account ends with a positive residual deposit (prefund minus actual cost),
|
|
// proving it (not any paymaster) paid.
|
|
const acctDeposit = await entry.balanceOf(acctAddr);
|
|
expect(acctDeposit, "account should hold residual prefund deposit").to.be.greaterThan(0n);
|
|
expect(await ethers.provider.getBalance(beneficiary.address)).to.be.greaterThan(beneBefore);
|
|
expect(await entry.getNonce(acctAddr)).to.equal(nonce + 1n);
|
|
});
|
|
});
|