UNPKG

@owlprotocol/contracts-account-abstraction

Version:

ERC4337 Account Abstraction support for deploying relevant smart contracts and interacting with UserOps.

642 lines (567 loc) 26.5 kB
import { describe, test, beforeEach, expect } from "vitest"; import { createPublicClient, http, PrivateKeyAccount, encodeFunctionData, Hex, padHex, Address, createWalletClient, encodeAbiParameters, concatHex, parseEther, LocalAccount, zeroAddress, toHex, Chain, nonceManager, Account, HttpTransport, } from "viem"; import { localhost } from "viem/chains"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { getUserOperationHash, entryPoint07Address, UserOperation, GetUserOperationParameters, GetUserOperationReceiptParameters, waitForUserOperationReceipt, } from "viem/account-abstraction"; import { getDeployDeterministicFunctionData, getLocalAccount, getUtilityAccount } from "@owlprotocol/viem-utils"; import { NODE_ENV } from "@owlprotocol/envvars"; import { optimismSepolia } from "@owlprotocol/chains"; import { estimateUserOperationGas, EstimateUserOperationGasParameters07, getPaymasterSlot, } from "./estimateUserOperationGas.js"; import { sendUserOperation } from "./sendUserOperation.js"; import { getUserOperation } from "./getUserOperation.js"; import { getUserOperationReceipt } from "./getUserOperationReceipt.js"; import { port } from "../../test/constants.js"; import { getSimpleAccountAddress } from "../../SimpleAccount.js"; import { erc4337Contracts, setupVerifyingPaymaster } from "../../setupERC4337Contracts.js"; import { ERC1967Proxy } from "../../artifacts/ERC1967Proxy.js"; import { createAccount as createAccountAbi } from "../../artifacts/SimpleAccountFactory.js"; import { execute as executeAbi } from "../../artifacts/SimpleAccount.js"; import { MyContract } from "../../artifacts/MyContract.js"; import { deposit as depositAbi, getHash as getHashAbi } from "../../artifacts/VerifyingPaymaster.js"; import { dummySignature, encodeUserOp } from "../../models/UserOperation.js"; import { toPackedUserOperation } from "../../models/PackedUserOperation.js"; import { ENTRYPOINT_ADDRESS_V07 } from "../../constants.js"; import { getUserOperationTotalGas } from "../../utils/getUserOperationTotalGas.js"; /** * The tests below simulate submitting a UserOp using the viem actions: * - Encode callData (simple transaction or MyContract deploy) * - Estimate UserOp gas (gas price set to 0, optional paymaster gas values) * - Construct UserOp with current gas price and gas estimations * - Sign UserOp with paymaster * - Sign UserOp with smart account owner * - Prefund smart account OR paymaster * - Submit to EntryPoint * * Important to note is the "prefund" step is done AFTER gas estimation, which highlights * how irregardless of current balance, `estimateUserOperationGas` will succeed. Checking * current smart account / paymaster balance is left as a responsibility to the user. **/ describe("actions/bundler/sendUserOperation.test.ts", function () { let chain: Chain; let account: Account; let transport: HttpTransport; if (NODE_ENV === "test") { chain = { ...localhost, rpcUrls: { default: { http: [`http://127.0.0.1:${port}`], }, }, }; account = getLocalAccount(0, { nonceManager }); transport = http(chain.rpcUrls.default.http[0]); } else { chain = optimismSepolia as unknown as Chain; account = getUtilityAccount({ nonceManager }); transport = http(optimismSepolia.rpcUrls.drpc!.http[0]); } const publicClient = createPublicClient({ chain, transport, }); const bundlerClient = publicClient.extend((client) => { return { getUserOperation: (parameters: GetUserOperationParameters) => getUserOperation(client, parameters), getUserOperationReceipt: (parameters: GetUserOperationReceiptParameters) => getUserOperationReceipt(client, parameters), }; }); const walletClient = createWalletClient({ account, chain, transport, }); // const testClient = createTestClient({ mode: "anvil", chain, transport }); // Contracts const entryPointSimulationsAddress = erc4337Contracts.pimlicoEntrypointSimulations; const factoryAddress = erc4337Contracts.simpleAccountFactory; // Generated account on each test let owner: PrivateKeyAccount; let smartAccountAddress: Address; let factoryData: Hex; beforeEach(async () => { owner = privateKeyToAccount(generatePrivateKey()); smartAccountAddress = getSimpleAccountAddress( { owner: owner.address, salt: 0n, }, { factoryAddress, proxyBytecode: ERC1967Proxy.bytecode, }, ); factoryData = encodeFunctionData({ abi: [createAccountAbi], functionName: "createAccount", args: [owner.address, 0n], }); }); describe("smart account prefund", () => { describe("simple transaction", () => { let callData: Hex; beforeEach(async () => { // Encode smart account tx, send to random address const to = privateKeyToAccount(generatePrivateKey()).address; const value = 0n; const data = "0x"; callData = encodeFunctionData({ abi: [executeAbi], args: [to, value, data], }); }); test("sendUserOperation", async () => { // Construct UserOp const gasPrice = await publicClient.estimateFeesPerGas(); const userOpData: EstimateUserOperationGasParameters07 = { sender: smartAccountAddress, nonce: 0n, callData, factory: factoryAddress, factoryData, maxFeePerGas: gasPrice.maxFeePerGas, }; // Estimate UserOp gas const { preVerificationGas, verificationGasLimit, callGasLimit, paymasterVerificationGasLimit, paymasterPostOpGasLimit, } = await estimateUserOperationGas({ ...publicClient, entryPointSimulationsAddress }, userOpData); expect(preVerificationGas).toBeGreaterThan(0n); expect(verificationGasLimit).toBeGreaterThan(0n); expect(callGasLimit).toBeGreaterThan(0n); expect(paymasterVerificationGasLimit).toBeUndefined(); expect(paymasterPostOpGasLimit).toBeUndefined(); const userOp: UserOperation<"0.7"> = { ...userOpData, signature: dummySignature, callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas: gasPrice.maxFeePerGas!, maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas!, }; console.log({ userOp }); // Sign UserOp const userOpHash = getUserOperationHash({ userOperation: userOp, entryPointAddress: entryPoint07Address, entryPointVersion: "0.7", chainId: chain.id, }); console.log({ userOpHash }); const signature = await owner.signMessage({ message: { raw: userOpHash }, }); userOp.signature = signature; //Pre-fund wallet const prefundHash = await walletClient.sendTransaction({ to: smartAccountAddress, value: getUserOperationTotalGas(userOp), }); console.log({ prefundHash }); await publicClient.waitForTransactionReceipt({ hash: prefundHash }); // Send UserOp const userOpHashSent = await sendUserOperation(walletClient, userOp); expect(userOpHashSent).toBe(userOpHash); console.log("waitingForUserOpReceipt"); // Wait for UserOp confirmation // We use the extended "bundlerClient" that has `getUserOperationReceipt` action const userOpReceipt = await waitForUserOperationReceipt(bundlerClient, { hash: userOpHash }); expect(userOpReceipt.userOpHash).toBe(userOpHash); expect(userOpReceipt.success).toBe(true); // Get UserOp const userOpSent = await bundlerClient.getUserOperation({ hash: userOpHash }); expect(userOpSent.userOperation).toStrictEqual({ ...userOp, callData: userOp.callData.toLowerCase(), paymaster: zeroAddress, paymasterData: "0x", paymasterPostOpGasLimit: 0n, paymasterVerificationGasLimit: 0n, }); }); }); describe("contract deploy", () => { let callData: Hex; beforeEach(() => { //Deploy hello world contract const deployParams = { // "random" salt salt: padHex(owner.address, { size: 32 }), bytecode: MyContract.bytecode, }; const contractDeployTransaction = getDeployDeterministicFunctionData(deployParams); callData = encodeFunctionData({ abi: [executeAbi], args: [contractDeployTransaction.to, 0n, contractDeployTransaction.data], }); }); test("sendUserOperation", async () => { const gasPrice = await publicClient.estimateFeesPerGas(); const userOpData: EstimateUserOperationGasParameters07 = { sender: smartAccountAddress, nonce: 0n, callData, factory: factoryAddress, factoryData, maxFeePerGas: gasPrice.maxFeePerGas!, }; // Estimate UserOp gas const { preVerificationGas, verificationGasLimit, callGasLimit, paymasterVerificationGasLimit, paymasterPostOpGasLimit, } = await estimateUserOperationGas({ ...publicClient, entryPointSimulationsAddress }, userOpData); expect(preVerificationGas).toBeGreaterThan(0n); expect(verificationGasLimit).toBeGreaterThan(0n); expect(callGasLimit).toBeGreaterThan(0n); expect(paymasterVerificationGasLimit).toBeUndefined(); expect(paymasterPostOpGasLimit).toBeUndefined(); // Construct UserOp const userOp: UserOperation<"0.7"> = { ...userOpData, signature: dummySignature, callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas: gasPrice.maxFeePerGas!, maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas!, }; // Sign UserOp const userOpHash = getUserOperationHash({ userOperation: userOp, entryPointAddress: entryPoint07Address, entryPointVersion: "0.7", chainId: chain.id, }); const signature = await owner.signMessage({ message: { raw: userOpHash }, }); userOp.signature = signature; //Pre-fund wallet const prefundHash = await walletClient.sendTransaction({ to: smartAccountAddress, value: getUserOperationTotalGas(userOp), }); await publicClient.waitForTransactionReceipt({ hash: prefundHash }); // Send UserOp const userOpHashSent = await sendUserOperation(walletClient, userOp); expect(userOpHashSent).toBe(userOpHash); // Wait for UserOp confirmation // We use the extended "bundlerClient" that has `getUserOperationReceipt` action const userOpReceipt = await waitForUserOperationReceipt(bundlerClient, { hash: userOpHash }); expect(userOpReceipt.userOpHash).toBe(userOpHash); expect(userOpReceipt.success).toBe(true); // Get UserOp const userOpSent = await bundlerClient.getUserOperation({ hash: userOpHash }); expect(userOpSent.userOperation).toStrictEqual({ ...userOp, callData: userOp.callData.toLowerCase(), paymaster: zeroAddress, paymasterData: "0x", paymasterPostOpGasLimit: 0n, paymasterVerificationGasLimit: 0n, }); }); }); }); describe("paymaster", () => { let paymasterSigner: LocalAccount; let paymasterAddress: Address; let validUntil: number; let validAfter: number; let paymasterDataUnsigned: Hex; let paymasterDataDummySignature: Hex; beforeEach(async () => { // Deploy a UNIQUE paymaster each time so it can have 0 stake at gas estimation time // Paymaster paymasterSigner = privateKeyToAccount(generatePrivateKey()); paymasterAddress = ( await setupVerifyingPaymaster(walletClient, { verifyingSignerAddress: paymasterSigner.address, }) ).address; // Paymaster stub data validUntil = Date.now() + 3600; validAfter = 0; paymasterDataUnsigned = encodeAbiParameters( [ { name: "validUntil", type: "uint48" }, { name: "validAfter", type: "uint48" }, ], [validUntil, validAfter], ); paymasterDataDummySignature = concatHex([paymasterDataUnsigned, dummySignature]); }); test.skipIf(NODE_ENV != "test")("paymaster deposit", async () => { const paymasterSlot = getPaymasterSlot(paymasterAddress); const slotValueBefore = await publicClient.getStorageAt({ address: ENTRYPOINT_ADDRESS_V07, slot: paymasterSlot, }); expect(slotValueBefore).toEqual(toHex(0, { size: 32 })); const topupAmount = parseEther("0.0000001"); const paymasterDeposit = await publicClient.simulateContract({ account: walletClient.account, address: paymasterAddress, abi: [depositAbi], functionName: "deposit", value: topupAmount, args: [], }); const paymasterDepositHash = await walletClient.writeContract(paymasterDeposit.request); await publicClient.waitForTransactionReceipt({ hash: paymasterDepositHash }); const slotValueAfter = await publicClient.getStorageAt({ address: ENTRYPOINT_ADDRESS_V07, slot: paymasterSlot, }); expect(slotValueAfter).toEqual(toHex(topupAmount, { size: 32 })); }); describe("simple transaction", () => { let callData: Hex; beforeEach(() => { // Encode smart account tx, send to random address const to = privateKeyToAccount(generatePrivateKey()).address; const value = 0n; const data = "0x"; callData = encodeFunctionData({ abi: [executeAbi], args: [to, value, data], }); }); test("sendUserOperation", async () => { const gasPrice = await publicClient.estimateFeesPerGas(); const userOpData: EstimateUserOperationGasParameters07 = { sender: smartAccountAddress, nonce: 0n, callData, factory: factoryAddress, factoryData, paymaster: paymasterAddress, paymasterData: paymasterDataDummySignature, maxFeePerGas: gasPrice.maxFeePerGas!, }; // Estimate UserOp gas const { preVerificationGas, verificationGasLimit, callGasLimit, paymasterVerificationGasLimit, paymasterPostOpGasLimit, } = await estimateUserOperationGas({ ...publicClient, entryPointSimulationsAddress }, userOpData); expect(preVerificationGas).toBeGreaterThan(0n); expect(verificationGasLimit).toBeGreaterThan(0n); expect(callGasLimit).toBeGreaterThan(0n); expect(paymasterVerificationGasLimit).toBeGreaterThan(0n); expect(paymasterPostOpGasLimit).toBeGreaterThan(0n); // Construct UserOp const userOp: UserOperation<"0.7"> = { ...userOpData, signature: dummySignature, callGasLimit, verificationGasLimit, preVerificationGas, paymasterVerificationGasLimit, paymasterPostOpGasLimit, maxFeePerGas: gasPrice.maxFeePerGas!, maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas!, }; // Sign Paymaster Data const userOpPaymasterPacked = toPackedUserOperation(encodeUserOp(userOp)); const userOpPaymasterHash = await publicClient.readContract({ address: paymasterAddress, abi: [getHashAbi], functionName: "getHash", args: [userOpPaymasterPacked as any, validUntil, validAfter], }); const paymasterSignature = await paymasterSigner.signMessage({ message: { raw: userOpPaymasterHash, }, }); const paymasterDataSigned = concatHex([paymasterDataUnsigned, paymasterSignature]); userOp.paymasterData = paymasterDataSigned; // Sign UserOp const userOpHash = getUserOperationHash({ userOperation: userOp, entryPointAddress: entryPoint07Address, entryPointVersion: "0.7", chainId: chain.id, }); const signature = await owner.signMessage({ message: { raw: userOpHash }, }); userOp.signature = signature; // Pre-fund paymaster const paymasterDeposit = await publicClient.simulateContract({ account: walletClient.account, address: paymasterAddress, abi: [depositAbi], functionName: "deposit", value: getUserOperationTotalGas(userOp), args: [], }); const paymasterDepositHash = await walletClient.writeContract(paymasterDeposit.request); await publicClient.waitForTransactionReceipt({ hash: paymasterDepositHash }); // Send UserOp const userOpHashSent = await sendUserOperation(walletClient, userOp); expect(userOpHashSent).toBe(userOpHash); // Wait for UserOp confirmation // We use the extended "bundlerClient" that has `getUserOperationReceipt` action const userOpReceipt = await waitForUserOperationReceipt(bundlerClient, { hash: userOpHash }); expect(userOpReceipt.userOpHash).toBe(userOpHash); expect(userOpReceipt.success).toBe(true); // Get UserOp const userOpSent = await bundlerClient.getUserOperation({ hash: userOpHash }); expect(userOpSent.userOperation).toStrictEqual({ ...userOp, callData: userOp.callData.toLowerCase(), }); }); }); describe("contract deploy", () => { let callData: Hex; beforeEach(() => { //Deploy hello world contract const deployParams = { // "random" salt salt: padHex(owner.address, { size: 32 }), bytecode: MyContract.bytecode, }; const contractDeployTransaction = getDeployDeterministicFunctionData(deployParams); callData = encodeFunctionData({ abi: [executeAbi], args: [contractDeployTransaction.to, 0n, contractDeployTransaction.data], }); }); test("sendUserOperation", async () => { const gasPrice = await publicClient.estimateFeesPerGas(); const userOpData: EstimateUserOperationGasParameters07 = { sender: smartAccountAddress, nonce: 0n, callData, factory: factoryAddress, factoryData, paymaster: paymasterAddress, paymasterData: paymasterDataDummySignature, maxFeePerGas: gasPrice.maxFeePerGas, }; // Estimate UserOp gas const { preVerificationGas, verificationGasLimit, callGasLimit, paymasterVerificationGasLimit, paymasterPostOpGasLimit, } = await estimateUserOperationGas({ ...publicClient, entryPointSimulationsAddress }, userOpData); expect(preVerificationGas).toBeGreaterThan(0n); expect(verificationGasLimit).toBeGreaterThan(0n); expect(callGasLimit).toBeGreaterThan(0n); expect(paymasterVerificationGasLimit).toBeGreaterThan(0n); expect(paymasterPostOpGasLimit).toBeGreaterThan(0n); // Construct UserOp const userOp: UserOperation<"0.7"> = { ...userOpData, signature: dummySignature, callGasLimit, verificationGasLimit, preVerificationGas, paymasterVerificationGasLimit, paymasterPostOpGasLimit, maxFeePerGas: gasPrice.maxFeePerGas!, maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas!, }; // Sign Paymaster Data const userOpPaymasterPacked = toPackedUserOperation(encodeUserOp(userOp)); const userOpPaymasterHash = await publicClient.readContract({ address: paymasterAddress, abi: [getHashAbi], functionName: "getHash", args: [userOpPaymasterPacked as any, validUntil, validAfter], }); const paymasterSignature = await paymasterSigner.signMessage({ message: { raw: userOpPaymasterHash, }, }); const paymasterDataSigned = concatHex([paymasterDataUnsigned, paymasterSignature]); userOp.paymasterData = paymasterDataSigned; // Sign UserOp const userOpHash = getUserOperationHash({ userOperation: userOp, entryPointAddress: entryPoint07Address, entryPointVersion: "0.7", chainId: chain.id, }); const signature = await owner.signMessage({ message: { raw: userOpHash }, }); userOp.signature = signature; // Pre-fund paymaster const paymasterDeposit = await publicClient.simulateContract({ account: walletClient.account, address: paymasterAddress, abi: [depositAbi], functionName: "deposit", value: getUserOperationTotalGas(userOp), args: [], }); const paymasterDepositHash = await walletClient.writeContract(paymasterDeposit.request); console.log({ paymasterDepositHash }); await publicClient.waitForTransactionReceipt({ hash: paymasterDepositHash }); // Send UserOp const userOpHashSent = await sendUserOperation(walletClient, userOp); expect(userOpHashSent).toBe(userOpHash); console.log({ userOpHash }); // Wait for UserOp confirmation // We use the extended "bundlerClient" that has `getUserOperationReceipt` action const userOpReceipt = await waitForUserOperationReceipt(bundlerClient, { hash: userOpHash }); expect(userOpReceipt.userOpHash).toBe(userOpHash); expect(userOpReceipt.success).toBe(true); console.log({ userOpTxHash: userOpReceipt.receipt.transactionHash }); // Get UserOp const userOpSent = await bundlerClient.getUserOperation({ hash: userOpHash }); expect(userOpSent.userOperation).toStrictEqual({ ...userOp, callData: userOp.callData.toLowerCase(), }); }); }); }); });