diff --git a/.env.example b/.env.example index 1e4344c0..d75915e2 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,12 @@ GREETER_ADDRESS= ZERODEV_PROJECT_ID= ZERODEV_PAYMASTER_RPC_HOST= ZERODEV_BUNDLER_RPC_HOST= -ZERODEV_API_KEY= \ No newline at end of file +ZERODEV_API_KEY= + +# For fallback e2e tests +ZERODEV_RPC_URL= +PIMLICO_RPC_URL= +STACKUP_RPC_URL= +ZERODEV_PAYMASTER_RPC_URL= +PIMLICO_PAYMASTER_RPC_URL= +STACKUP_PAYMASTER_RPC_URL= \ No newline at end of file diff --git a/packages/core/clients/fallbackKernelAccountClient.ts b/packages/core/clients/fallbackKernelAccountClient.ts new file mode 100644 index 00000000..d101f01a --- /dev/null +++ b/packages/core/clients/fallbackKernelAccountClient.ts @@ -0,0 +1,62 @@ +import type { EntryPoint } from "permissionless/types" +import type { Chain, Transport } from "viem" +import type { KernelSmartAccount } from "../accounts/index.js" +import type { KernelAccountClient } from "./kernelAccountClient.js" + +export const createFallbackKernelAccountClient = < + TEntryPoint extends EntryPoint, + TTransport extends Transport, + TChain extends Chain | undefined, + TSmartAccount extends KernelSmartAccount | undefined +>( + clients: Array< + KernelAccountClient + > +): KernelAccountClient => { + const proxyClient = new Proxy(clients[0], { + get(_target, prop, receiver) { + for (const client of clients) { + const value = Reflect.get(client, prop, receiver) + if (value !== undefined) { + // If the property is a function, wrap it to add fallback logic + if (typeof value === "function") { + // biome-ignore lint/suspicious/noExplicitAny: + return async (...args: any[]) => { + for (let i = 0; i < clients.length; i++) { + try { + const method = Reflect.get( + clients[i], + prop, + receiver + ) + if (typeof method === "function") { + // Attempt to call the function on the current client + return await method(...args) + } + } catch (error) { + console.error( + `Action ${String( + prop + )} failed with client ${ + client.transport.url + }, trying next if available.`, + error + ) + if (i === clients.length - 1) { + throw error + } + } + } + } + } + // For non-function properties, return the first defined value found + return value + } + } + // If no clients have a defined value for the property, return undefined + return undefined + } + }) + + return proxyClient +} diff --git a/packages/core/clients/index.ts b/packages/core/clients/index.ts index 782ac0cc..ab253148 100644 --- a/packages/core/clients/index.ts +++ b/packages/core/clients/index.ts @@ -7,3 +7,5 @@ export { createKernelAccountClient, type KernelAccountClient } from "./kernelAccountClient.js" + +export { createFallbackKernelAccountClient } from "./fallbackKernelAccountClient.js" diff --git a/packages/core/index.ts b/packages/core/index.ts index a021c5fb..5d431e90 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -26,6 +26,7 @@ export { createKernelAccountClient, type KernelAccountClient } from "./clients/kernelAccountClient.js" +export { createFallbackKernelAccountClient } from "./clients/fallbackKernelAccountClient.js" export { type KernelValidator, type ZeroDevPaymasterRpcSchema, diff --git a/packages/test/fallbackClient.test.ts b/packages/test/fallbackClient.test.ts new file mode 100644 index 00000000..146eef9f --- /dev/null +++ b/packages/test/fallbackClient.test.ts @@ -0,0 +1,1126 @@ +// @ts-expect-error +import { beforeAll, describe, expect, test } from "bun:test" +import { verifyMessage } from "@ambire/signature-validator" +import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator" +import { + EIP1271Abi, + type KernelAccountClient, + type KernelSmartAccount, + createFallbackKernelAccountClient, + createKernelAccount, + createKernelAccountClient, + createZeroDevPaymasterClient, + verifyEIP6492Signature +} from "@zerodev/sdk" +import dotenv from "dotenv" +import { ethers } from "ethers" +import type { BundlerClient } from "permissionless" +import { SignTransactionNotSupportedBySmartAccount } from "permissionless/accounts" +import { + createPimlicoBundlerClient, + createPimlicoPaymasterClient +} from "permissionless/clients/pimlico" +import { createStackupPaymasterClient } from "permissionless/clients/stackup" +import type { EntryPoint } from "permissionless/types/entrypoint" +import { + http, + type Address, + type Chain, + type GetContractReturnType, + type PublicClient, + type Transport, + createPublicClient, + decodeEventLog, + encodeFunctionData, + erc20Abi, + getContract, + hashMessage, + hashTypedData, + zeroAddress +} from "viem" +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts" +import { sepolia } from "viem/chains" +import { EntryPointAbi } from "./abis/EntryPoint" +import { GreeterAbi, GreeterBytecode } from "./abis/Greeter.js" +import { config } from "./config.js" +import { + createHttpServer, + findUserOperationEvent, + getEntryPoint, + getKernelBundlerClient, + waitForNonceUpdate +} from "./utils.js" + +dotenv.config() + +const requiredEnvVars = [ + "RPC_URL", + "ZERODEV_RPC_URL", + "PIMLICO_RPC_URL", + "STACKUP_RPC_URL", + "ZERODEV_PAYMASTER_RPC_URL", + "PIMLICO_PAYMASTER_RPC_URL", + "STACKUP_PAYMASTER_RPC_URL" +] + +const validateEnvironmentVariables = (envVars: string[]): void => { + const unsetEnvVars = envVars.filter((envVar) => !process.env[envVar]) + if (unsetEnvVars.length > 0) { + throw new Error( + `The following environment variables are not set: ${unsetEnvVars.join( + ", " + )}` + ) + } +} + +validateEnvironmentVariables(requiredEnvVars) + +const ETHEREUM_ADDRESS_LENGTH = 42 +const ETHEREUM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/ +const SIGNATURE_LENGTH = 132 +const SIGNATURE_REGEX = /^0x[0-9a-fA-F]{130}$/ +const TX_HASH_LENGTH = 66 +const TX_HASH_REGEX = /^0x[0-9a-fA-F]{64}$/ +const TEST_TIMEOUT = 1000000 + +describe("fallback client e2e", () => { + const RPC_URL = process.env.RPC_URL + + const ZERODEV_RPC_URL = process.env.ZERODEV_RPC_URL + const PIMLICO_RPC_URL = process.env.PIMLICO_RPC_URL + const STACKUP_RPC_URL = process.env.STACKUP_RPC_URL + + const ZERODEV_PAYMASTER_RPC_URL = process.env.ZERODEV_PAYMASTER_RPC_URL + const PIMLICO_PAYMASTER_RPC_URL = process.env.PIMLICO_PAYMASTER_RPC_URL + const STACKUP_PAYMASTER_RPC_URL = process.env.STACKUP_PAYMASTER_RPC_URL + + let publicClient: PublicClient + let bundlerClient: BundlerClient + let kernelAccount: KernelSmartAccount + let unavailableServer: { close: () => Promise; url: string } + + let greeterContract: GetContractReturnType< + typeof GreeterAbi, + KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + >, + Address + > + + beforeAll(async () => { + publicClient = createPublicClient({ + transport: http(RPC_URL) + }) + bundlerClient = getKernelBundlerClient() + + const signer = privateKeyToAccount(generatePrivateKey()) + const ecdsaValidatorPlugin = await signerToEcdsaValidator( + publicClient, + { + entryPoint: getEntryPoint(), + signer + } + ) + + kernelAccount = await createKernelAccount(publicClient, { + entryPoint: getEntryPoint(), + plugins: { + sudo: ecdsaValidatorPlugin + } + }) + + unavailableServer = await createHttpServer((_req, res) => { + res.writeHead(500) + res.end() + }) + }) + + describe("when all clients are available", async () => { + let fallbackKernelClient: KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + + beforeAll(() => { + const zeroDevPaymasterClient = createZeroDevPaymasterClient({ + chain: sepolia, + transport: http(ZERODEV_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const pimlicoPaymasterClient = createPimlicoPaymasterClient({ + chain: sepolia, + transport: http(PIMLICO_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const stackupPaymasterClient = createStackupPaymasterClient({ + chain: sepolia, + transport: http(STACKUP_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const zerodevKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(ZERODEV_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return zeroDevPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint() + }) + } + }, + entryPoint: getEntryPoint() + }) + + const pimlicoBundlerClient = createPimlicoBundlerClient({ + transport: http(PIMLICO_RPC_URL), + entryPoint: getEntryPoint() + }) + + const pimlicoKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(PIMLICO_RPC_URL), + middleware: { + gasPrice: async () => { + return ( + await pimlicoBundlerClient.getUserOperationGasPrice() + ).fast + }, + sponsorUserOperation: async ({ userOperation }) => { + return pimlicoPaymasterClient.sponsorUserOperation({ + userOperation + }) + } + }, + entryPoint: getEntryPoint() + }) + + const stackupKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(STACKUP_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return stackupPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint(), + context: { + type: "payg" + } + }) + } + }, + entryPoint: getEntryPoint() + }) + + fallbackKernelClient = createFallbackKernelAccountClient([ + zerodevKernelClient, + pimlicoKernelClient, + stackupKernelClient + ]) as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + test("Account address should be a valid Ethereum address", async () => { + const address = fallbackKernelClient.account.address + expect(address).toBeString() + expect(address).toHaveLength(ETHEREUM_ADDRESS_LENGTH) + expect(address).toMatch(ETHEREUM_ADDRESS_REGEX) + expect(address).not.toEqual(zeroAddress) + console.log("account.address: ", address) + }) + + test("Account should throw when trying to sign a transaction", async () => { + await expect(async () => { + await fallbackKernelClient.account.signTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + }).toThrow(new SignTransactionNotSupportedBySmartAccount()) + }) + + test( + "Should validate message signatures for undeployed accounts (6492)", + async () => { + const message = "hello world" + const signature = + await fallbackKernelClient.account.signMessage({ + message + }) + + expect( + await verifyEIP6492Signature({ + signer: fallbackKernelClient.account.address, + hash: hashMessage(message), + signature: signature, + client: publicClient + }) + ).toBeTrue() + + // Try using Ambire as well + const ambireResult = await verifyMessage({ + signer: fallbackKernelClient.account.address, + message, + signature: signature, + provider: new ethers.providers.JsonRpcProvider( + config["v0.6"].sepolia.rpcUrl + ) + }) + expect(ambireResult).toBeTrue() + }, + TEST_TIMEOUT + ) + + test( + "Should validate typed data signatures for undeployed accounts (6492)", + async () => { + const domain = { + chainId: 1, + name: "Test", + verifyingContract: zeroAddress + } + + const primaryType = "Test" + + const types = { + Test: [ + { + name: "test", + type: "string" + } + ] + } + + const message = { + test: "hello world" + } + const typedHash = hashTypedData({ + domain, + primaryType, + types, + message + }) + + const signature = + await fallbackKernelClient.account.signTypedData({ + domain, + primaryType, + types, + message + }) + + expect( + await verifyEIP6492Signature({ + signer: fallbackKernelClient.account.address, + hash: typedHash, + signature: signature, + client: publicClient + }) + ).toBeTrue() + + // Try using Ambire as well + const ambireResult = await verifyMessage({ + signer: fallbackKernelClient.account.address, + typedData: { + domain, + types, + message + }, + signature: signature, + provider: new ethers.providers.JsonRpcProvider( + config["v0.6"].sepolia.rpcUrl + ) + }) + expect(ambireResult).toBeTrue() + }, + TEST_TIMEOUT + ) + + test( + "Client signMessage should return a valid signature", + async () => { + // to make sure kernel is deployed + await fallbackKernelClient.sendTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + const message = "hello world" + const response = await fallbackKernelClient.signMessage({ + message + }) + const ambireResult = await verifyMessage({ + signer: fallbackKernelClient.account.address, + message, + signature: response, + provider: new ethers.providers.JsonRpcProvider( + config["v0.6"].sepolia.rpcUrl + ) + }) + expect(ambireResult).toBeTrue() + + const eip1271response = await publicClient.readContract({ + address: fallbackKernelClient.account.address, + abi: EIP1271Abi, + functionName: "isValidSignature", + args: [hashMessage(message), response] + }) + expect(eip1271response).toEqual("0x1626ba7e") + expect(response).toBeString() + expect(response).toHaveLength(SIGNATURE_LENGTH) + expect(response).toMatch(SIGNATURE_REGEX) + }, + TEST_TIMEOUT + ) + + test( + "Smart account client signTypedData", + async () => { + const domain = { + chainId: 1, + name: "Test", + verifyingContract: zeroAddress + } + + const primaryType = "Test" + + const types = { + Test: [ + { + name: "test", + type: "string" + } + ] + } + + const message = { + test: "hello world" + } + const typedHash = hashTypedData({ + domain, + primaryType, + types, + message + }) + + const response = await fallbackKernelClient.signTypedData({ + domain, + primaryType, + types, + message + }) + + const eip1271response = await publicClient.readContract({ + address: fallbackKernelClient.account.address, + abi: EIP1271Abi, + functionName: "isValidSignature", + args: [typedHash, response] + }) + expect(eip1271response).toEqual("0x1626ba7e") + expect(response).toBeString() + expect(response).toHaveLength(SIGNATURE_LENGTH) + expect(response).toMatch(SIGNATURE_REGEX) + }, + TEST_TIMEOUT + ) + + test( + "Client deploy contract", + async () => { + const response = await fallbackKernelClient.deployContract({ + abi: GreeterAbi, + bytecode: GreeterBytecode + }) + + expect(response).toBeString() + expect(response).toHaveLength(TX_HASH_LENGTH) + expect(response).toMatch(TX_HASH_REGEX) + + const transactionReceipt = + await publicClient.waitForTransactionReceipt({ + hash: response + }) + + expect( + findUserOperationEvent(transactionReceipt.logs) + ).toBeTrue() + }, + TEST_TIMEOUT + ) + + test( + "Smart account client send multiple transactions", + async () => { + greeterContract = getContract({ + abi: GreeterAbi, + address: process.env.GREETER_ADDRESS as Address, + client: fallbackKernelClient as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + const response = await fallbackKernelClient.sendTransactions({ + transactions: [ + { + to: zeroAddress, + value: 0n, + data: "0x" + }, + { + to: zeroAddress, + value: 0n, + data: "0x" + }, + { + to: process.env.GREETER_ADDRESS as Address, + value: 0n, + data: encodeFunctionData({ + abi: GreeterAbi, + functionName: "setGreeting", + args: ["hello world batched"] + }) + } + ] + }) + const newGreet = await greeterContract.read.greet() + + expect(newGreet).toBeString() + expect(newGreet).toEqual("hello world batched") + expect(response).toBeString() + expect(response).toHaveLength(TX_HASH_LENGTH) + expect(response).toMatch(TX_HASH_REGEX) + }, + TEST_TIMEOUT + ) + + test( + "Write contract", + async () => { + greeterContract = getContract({ + abi: GreeterAbi, + address: process.env.GREETER_ADDRESS as Address, + client: fallbackKernelClient as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + const oldGreet = await greeterContract.read.greet() + + expect(oldGreet).toBeString() + + const txHash = await greeterContract.write.setGreeting([ + "hello world" + ]) + + expect(txHash).toBeString() + expect(txHash).toHaveLength(66) + + const newGreet = await greeterContract.read.greet() + + expect(newGreet).toBeString() + expect(newGreet).toEqual("hello world") + }, + TEST_TIMEOUT + ) + + test( + "Client signs and then sends UserOp with paymaster", + async () => { + const userOp = await fallbackKernelClient.signUserOperation({ + userOperation: { + callData: + await fallbackKernelClient.account.encodeCallData({ + to: process.env.GREETER_ADDRESS as Address, + value: 0n, + data: encodeFunctionData({ + abi: GreeterAbi, + functionName: "setGreeting", + args: ["hello world"] + }) + }) + } + }) + expect(userOp.signature).not.toBe("0x") + + const userOpHash = await bundlerClient.sendUserOperation({ + userOperation: userOp + }) + expect(userOpHash).toHaveLength(66) + await bundlerClient.waitForUserOperationReceipt({ + hash: userOpHash + }) + + await waitForNonceUpdate() + }, + TEST_TIMEOUT + ) + + test( + "Client send Transaction with paymaster", + async () => { + const response = await fallbackKernelClient.sendTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + + expect(response).toBeString() + expect(response).toHaveLength(TX_HASH_LENGTH) + expect(response).toMatch(TX_HASH_REGEX) + + const transactionReceipt = + await publicClient.waitForTransactionReceipt({ + hash: response + }) + + expect( + findUserOperationEvent(transactionReceipt.logs) + ).toBeTrue() + }, + TEST_TIMEOUT + ) + + test( + "Client send multiple Transactions with paymaster", + async () => { + const response = await fallbackKernelClient.sendTransactions({ + transactions: [ + { + to: zeroAddress, + value: 0n, + data: "0x" + }, + { + to: zeroAddress, + value: 0n, + data: "0x" + } + ] + }) + console.log("TransactionHash:", response) + + expect(response).toBeString() + expect(response).toHaveLength(66) + expect(response).toMatch(/^0x[0-9a-fA-F]{64}$/) + + const transactionReceipt = + await publicClient.waitForTransactionReceipt({ + hash: response + }) + + let eventFound = false + + for (const log of transactionReceipt.logs) { + // Encapsulated inside a try catch since if a log isn't wanted from this abi it will throw an error + try { + const event = decodeEventLog({ + abi: EntryPointAbi, + ...log + }) + if (event.eventName === "UserOperationEvent") { + eventFound = true + const userOperation = + await bundlerClient.getUserOperationByHash({ + hash: event.args.userOpHash + }) + expect( + userOperation?.userOperation.paymasterAndData + ).not.toBe("0x") + } + } catch {} + } + + expect(eventFound).toBeTrue() + }, + TEST_TIMEOUT + ) + }) + + describe("when zerodev paymaster client is unavailable", () => { + let fallbackKernelClient: KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + + beforeAll(() => { + const zeroDevPaymasterClient = createZeroDevPaymasterClient({ + chain: sepolia, + transport: http(unavailableServer.url), + entryPoint: getEntryPoint() + }) + + const pimlicoPaymasterClient = createPimlicoPaymasterClient({ + chain: sepolia, + transport: http(PIMLICO_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const stackupPaymasterClient = createStackupPaymasterClient({ + chain: sepolia, + transport: http(STACKUP_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const zerodevKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(ZERODEV_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return zeroDevPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint() + }) + } + }, + entryPoint: getEntryPoint() + }) + + const pimlicoKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(PIMLICO_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return pimlicoPaymasterClient.sponsorUserOperation({ + userOperation + }) + } + }, + entryPoint: getEntryPoint() + }) + + const stackupKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(STACKUP_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return stackupPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint(), + context: { + type: "payg" + } + }) + } + }, + entryPoint: getEntryPoint() + }) + + fallbackKernelClient = createFallbackKernelAccountClient([ + zerodevKernelClient, + pimlicoKernelClient, + stackupKernelClient + ]) as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + test( + "can send transaction", + async () => { + const response = await fallbackKernelClient.sendTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + + expect(response).toBeString() + expect(response).toHaveLength(TX_HASH_LENGTH) + expect(response).toMatch(TX_HASH_REGEX) + + const transactionReceipt = + await publicClient.waitForTransactionReceipt({ + hash: response + }) + + expect( + findUserOperationEvent(transactionReceipt.logs) + ).toBeTrue() + }, + TEST_TIMEOUT + ) + }) + + describe("when zerodev bundler client is unavailable", () => { + let fallbackKernelClient: KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + + beforeAll(() => { + const zeroDevPaymasterClient = createZeroDevPaymasterClient({ + chain: sepolia, + transport: http(ZERODEV_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const pimlicoPaymasterClient = createPimlicoPaymasterClient({ + chain: sepolia, + transport: http(PIMLICO_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const stackupPaymasterClient = createStackupPaymasterClient({ + chain: sepolia, + transport: http(STACKUP_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const zerodevKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(unavailableServer.url), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return zeroDevPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint() + }) + } + }, + entryPoint: getEntryPoint() + }) + + const pimlicoBundlerClient = createPimlicoBundlerClient({ + transport: http(PIMLICO_RPC_URL), + entryPoint: getEntryPoint() + }) + + const pimlicoKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(PIMLICO_RPC_URL), + middleware: { + gasPrice: async () => { + return ( + await pimlicoBundlerClient.getUserOperationGasPrice() + ).fast + }, + sponsorUserOperation: async ({ userOperation }) => { + return pimlicoPaymasterClient.sponsorUserOperation({ + userOperation + }) + } + }, + entryPoint: getEntryPoint() + }) + + const stackupKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(STACKUP_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return stackupPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint(), + context: { + type: "payg" + } + }) + } + }, + entryPoint: getEntryPoint() + }) + + fallbackKernelClient = createFallbackKernelAccountClient([ + zerodevKernelClient, + pimlicoKernelClient, + stackupKernelClient + ]) as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + test( + "can send transaction", + async () => { + const response = await fallbackKernelClient.sendTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + + expect(response).toBeString() + expect(response).toHaveLength(TX_HASH_LENGTH) + expect(response).toMatch(TX_HASH_REGEX) + + const transactionReceipt = + await publicClient.waitForTransactionReceipt({ + hash: response + }) + + expect( + findUserOperationEvent(transactionReceipt.logs) + ).toBeTrue() + }, + TEST_TIMEOUT + ) + }) + + describe("when zerodev client and pimlico client is unavailable", () => { + let fallbackKernelClient: KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + + beforeAll(() => { + const zeroDevPaymasterClient = createZeroDevPaymasterClient({ + chain: sepolia, + transport: http(unavailableServer.url), + entryPoint: getEntryPoint() + }) + + const pimlicoPaymasterClient = createPimlicoPaymasterClient({ + chain: sepolia, + transport: http(PIMLICO_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const stackupPaymasterClient = createStackupPaymasterClient({ + chain: sepolia, + transport: http(STACKUP_PAYMASTER_RPC_URL), + entryPoint: getEntryPoint() + }) + + const zerodevKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(ZERODEV_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return zeroDevPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint() + }) + } + }, + entryPoint: getEntryPoint() + }) + + const pimlicoBundlerClient = createPimlicoBundlerClient({ + transport: http(PIMLICO_RPC_URL), + entryPoint: getEntryPoint() + }) + + const pimlicoKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(unavailableServer.url), + middleware: { + gasPrice: async () => { + return ( + await pimlicoBundlerClient.getUserOperationGasPrice() + ).fast + }, + sponsorUserOperation: async ({ userOperation }) => { + return pimlicoPaymasterClient.sponsorUserOperation({ + userOperation + }) + } + }, + entryPoint: getEntryPoint() + }) + + const stackupKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(STACKUP_RPC_URL), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return stackupPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint(), + context: { + type: "payg" + } + }) + } + }, + entryPoint: getEntryPoint() + }) + + fallbackKernelClient = createFallbackKernelAccountClient([ + zerodevKernelClient, + pimlicoKernelClient, + stackupKernelClient + ]) as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + test( + "can send transaction", + async () => { + const response = await fallbackKernelClient.sendTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + + expect(response).toBeString() + expect(response).toHaveLength(TX_HASH_LENGTH) + expect(response).toMatch(TX_HASH_REGEX) + + const transactionReceipt = + await publicClient.waitForTransactionReceipt({ + hash: response + }) + + expect( + findUserOperationEvent(transactionReceipt.logs) + ).toBeTrue() + }, + TEST_TIMEOUT + ) + }) + + describe("when all clients is unavailable", () => { + let fallbackKernelClient: KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + + beforeAll(() => { + const zeroDevPaymasterClient = createZeroDevPaymasterClient({ + chain: sepolia, + transport: http(unavailableServer.url), + entryPoint: getEntryPoint() + }) + + const pimlicoPaymasterClient = createPimlicoPaymasterClient({ + chain: sepolia, + transport: http(unavailableServer.url), + entryPoint: getEntryPoint() + }) + + const stackupPaymasterClient = createStackupPaymasterClient({ + chain: sepolia, + transport: http(unavailableServer.url), + entryPoint: getEntryPoint() + }) + + const zerodevKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(unavailableServer.url), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return zeroDevPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint() + }) + } + }, + entryPoint: getEntryPoint() + }) + + const pimlicoBundlerClient = createPimlicoBundlerClient({ + transport: http(unavailableServer.url), + entryPoint: getEntryPoint() + }) + + const pimlicoKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(unavailableServer.url), + middleware: { + gasPrice: async () => { + return ( + await pimlicoBundlerClient.getUserOperationGasPrice() + ).fast + }, + sponsorUserOperation: async ({ userOperation }) => { + return pimlicoPaymasterClient.sponsorUserOperation({ + userOperation + }) + } + }, + entryPoint: getEntryPoint() + }) + + const stackupKernelClient = createKernelAccountClient({ + account: kernelAccount, + chain: sepolia, + bundlerTransport: http(unavailableServer.url), + middleware: { + sponsorUserOperation: async ({ userOperation }) => { + return stackupPaymasterClient.sponsorUserOperation({ + userOperation, + entryPoint: getEntryPoint(), + context: { + type: "payg" + } + }) + } + }, + entryPoint: getEntryPoint() + }) + + fallbackKernelClient = createFallbackKernelAccountClient([ + zerodevKernelClient, + pimlicoKernelClient, + stackupKernelClient + ]) as KernelAccountClient< + EntryPoint, + Transport, + Chain, + KernelSmartAccount + > + }) + + test( + "should throw error", + async () => { + await expect( + fallbackKernelClient.sendTransaction({ + to: zeroAddress, + value: 0n, + data: "0x" + }) + ).rejects.toThrow() + }, + TEST_TIMEOUT + ) + }) +}) diff --git a/packages/test/utils.ts b/packages/test/utils.ts index 3159b504..9cf3f448 100644 --- a/packages/test/utils.ts +++ b/packages/test/utils.ts @@ -1,3 +1,5 @@ +import { type RequestListener, createServer } from "http" +import type { AddressInfo } from "net" import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator" import { type KernelAccountClient, @@ -654,3 +656,21 @@ export const findUserOperationEvent = (logs: Log[]): boolean => { } }) } + +export function createHttpServer( + handler: RequestListener +): Promise<{ close: () => Promise; url: string }> { + const server = createServer(handler) + + const closeAsync = () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve(undefined))) + ) + + return new Promise((resolve) => { + server.listen(() => { + const { port } = server.address() as AddressInfo + resolve({ close: closeAsync, url: `http://localhost:${port}` }) + }) + }) +} diff --git a/packages/test/v0.7/utils.ts b/packages/test/v0.7/utils.ts index d3516653..81e6c424 100644 --- a/packages/test/v0.7/utils.ts +++ b/packages/test/v0.7/utils.ts @@ -56,6 +56,9 @@ import { TEST_ERC20Abi } from "../abis/Test_ERC20Abi.js" import { config } from "../config.js" import { Test_ERC20Address } from "../utils.js" +import { type RequestListener, createServer } from "http" +import type { AddressInfo } from "net" + // export const index = 43244782332432423423n export const index = 4323343754343332434365532464445487823332432423423n const DEFAULT_PROVIDER = "PIMLICO" @@ -600,6 +603,24 @@ export async function mintToAccount( } } +export function createHttpServer( + handler: RequestListener +): Promise<{ close: () => Promise; url: string }> { + const server = createServer(handler) + + const closeAsync = () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve(undefined))) + ) + + return new Promise((resolve) => { + server.listen(() => { + const { port } = server.address() as AddressInfo + resolve({ close: closeAsync, url: `http://localhost:${port}` }) + }) + }) +} + export const getEcdsaKernelAccountWithRemoteSigner = async < entryPoint extends EntryPoint >(