@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
text/typescript
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(),
});
});
});
});
});