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