diff --git a/packages/core/clients/fallback.ts b/packages/core/clients/fallback.ts new file mode 100644 index 00000000..80dd603a --- /dev/null +++ b/packages/core/clients/fallback.ts @@ -0,0 +1,154 @@ +import type { FallbackTransport, Transport, TransportConfig } from "viem" +import { + TransactionRejectedRpcError, + UserRejectedRequestError, + createTransport +} from "viem" + +export type OnResponseFn = ( + args: { + method: string + params: unknown[] + transport: ReturnType + } & ( + | { + error?: never + response: unknown + status: "success" + } + | { + error: Error + response?: never + status: "error" + } + ) +) => void + +function shouldThrow(error: Error) { + if ("code" in error && typeof error.code === "number") { + if ( + error.code === TransactionRejectedRpcError.code || + error.code === UserRejectedRequestError.code || + error.code === 5000 // CAIP UserRejectedRequestError + ) + return true + } + return false +} + +export type SuccessfulIndex = { + index: number +} + +export type TransportPair = { + bundlerTransport: Transport + paymasterTransport: Transport +} + +export type FallbackTransportConfig = { + /** The key of the Fallback transport. */ + key?: TransportConfig["key"] + /** The name of the Fallback transport. */ + name?: TransportConfig["name"] + /** The max number of times to retry. */ + retryCount?: TransportConfig["retryCount"] + /** The base delay (in ms) between retries. */ + retryDelay?: TransportConfig["retryDelay"] +} + +export function createFallbackTransport( + transportPairs: TransportPair[], + config: FallbackTransportConfig = {}, + successfulIndex: SuccessfulIndex = { index: -1 } +): { + bundlerFallbackTransport: FallbackTransport + paymasterFallbackTransport: FallbackTransport +} { + const { + key = "fallback", + name = "Fallback", + retryCount, // @dev when retryCount is undefined, it will default to 3 in viem + retryDelay + } = config + + const createTransportInstance = (isBundler: boolean): FallbackTransport => { + return ({ chain, pollingInterval = 2_000, timeout, ...rest }) => { + let onResponse: OnResponseFn = () => {} + + return createTransport( + { + key, + name, + async request({ method, params }) { + const fetch = async ( + i = Math.max(0, successfulIndex.index) + // biome-ignore lint/suspicious/noExplicitAny: + ): Promise => { + console.log("fetch", i) + const transport = isBundler + ? transportPairs[i].bundlerTransport + : transportPairs[i].paymasterTransport + const configuredTransport = transport({ + ...rest, + chain, + retryCount: 0, + timeout + }) + try { + const response = + await configuredTransport.request({ + method, + params + // biome-ignore lint/suspicious/noExplicitAny: + } as any) + onResponse({ + method, + params: params as unknown[], + response, + transport: configuredTransport, + status: "success" + }) + successfulIndex.index = i + return response + } catch (err) { + onResponse({ + error: err as Error, + method, + params: params as unknown[], + transport: configuredTransport, + status: "error" + }) + + if (shouldThrow(err as Error)) throw err + + if (i === transportPairs.length - 1) throw err + + return fetch(i + 1) + } + } + return fetch() + }, + retryCount, + retryDelay, + type: "fallback" + }, + { + // biome-ignore lint/suspicious/noAssignInExpressions: + onResponse: (fn: OnResponseFn) => (onResponse = fn), + transports: transportPairs + .map((pair) => + isBundler + ? pair.bundlerTransport + : pair.paymasterTransport + ) + .map((fn) => fn({ chain, retryCount: 0 })) + } + ) + } + } + + return { + bundlerFallbackTransport: createTransportInstance(true), + paymasterFallbackTransport: createTransportInstance(false) + } +} diff --git a/packages/core/clients/index.ts b/packages/core/clients/index.ts index 782ac0cc..29d8cbdc 100644 --- a/packages/core/clients/index.ts +++ b/packages/core/clients/index.ts @@ -7,3 +7,8 @@ export { createKernelAccountClient, type KernelAccountClient } from "./kernelAccountClient.js" + +export { + createFallbackTransport, + type TransportPair +} from "./fallback.js" diff --git a/packages/core/index.ts b/packages/core/index.ts index a021c5fb..071dd663 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -26,6 +26,10 @@ export { createKernelAccountClient, type KernelAccountClient } from "./clients/kernelAccountClient.js" +export { + createFallbackTransport, + type TransportPair +} from "./clients/fallback.js" export { type KernelValidator, type ZeroDevPaymasterRpcSchema, diff --git a/packages/test/v0.7/fallback.test.ts b/packages/test/v0.7/fallback.test.ts new file mode 100644 index 00000000..407cda4a --- /dev/null +++ b/packages/test/v0.7/fallback.test.ts @@ -0,0 +1,226 @@ +// @ts-expect-error +import { describe, expect, test } from "bun:test" +import { createFallbackTransport } from "@zerodev/sdk" +import { http } from "viem" +import { localhost } from "viem/chains" +import { createHttpServer } from "./utils" + +describe("request", () => { + test("default", async () => { + const bundlerServer = await createHttpServer((_req, res) => { + res.writeHead(200, { + "Content-Type": "application/json" + }) + res.end(JSON.stringify({ result: "0x1" })) + }) + + const paymasterServer = await createHttpServer((_req, res) => { + res.writeHead(200, { + "Content-Type": "application/json" + }) + res.end(JSON.stringify({ result: "0x1" })) + }) + + const bundlerHttpServer = http(bundlerServer.url) + const paymasterHttpServer = http(paymasterServer.url) + + const { bundlerFallbackTransport, paymasterFallbackTransport } = + createFallbackTransport([ + { + bundlerTransport: bundlerHttpServer, + paymasterTransport: paymasterHttpServer + } + ]) + + const bundlerTransport = bundlerFallbackTransport({ chain: localhost }) + const paymasterTransport = paymasterFallbackTransport({ + chain: localhost + }) + + expect( + await bundlerTransport.request({ method: "eth_blockNumber" }) + ).toBe("0x1") + expect( + await paymasterTransport.request({ method: "eth_blockNumber" }) + ).toBe("0x1") + }) + + test("when bundler servers failed, it should not try failed pairs for paymaster server", async () => { + let bundlerServerCount = 0 + let paymasterServerCount = 0 + + const bundlerServer1 = await createHttpServer((_req, res) => { + console.log("bundlerServer1") + bundlerServerCount++ + res.writeHead(500) + res.end() + }) + const bundlerServer2 = await createHttpServer((_req, res) => { + console.log("bundlerServer2") + bundlerServerCount++ + res.writeHead(500) + res.end() + }) + const bundlerServer3 = await createHttpServer((_req, res) => { + console.log("bundlerServer3") + bundlerServerCount++ + res.writeHead(200, { + "Content-Type": "application/json" + }) + res.end(JSON.stringify({ result: "0x1" })) + }) + + const paymasterServer1 = await createHttpServer((_req, res) => { + console.log("paymasterServer1") + paymasterServerCount++ + res.writeHead(200, { + "Content-Type": "application/json" + }) + res.end(JSON.stringify({ result: "0x11" })) + }) + + const paymasterServer2 = await createHttpServer((_req, res) => { + console.log("paymasterServer2") + paymasterServerCount++ + res.writeHead(200, { + "Content-Type": "application/json" + }) + res.end(JSON.stringify({ result: "0x12" })) + }) + + const paymasterServer3 = await createHttpServer((_req, res) => { + console.log("paymasterServer3") + paymasterServerCount++ + res.writeHead(200, { + "Content-Type": "application/json" + }) + res.end(JSON.stringify({ result: "0x13" })) + }) + + const bundlerHttpServer1 = http(bundlerServer1.url) + const bundlerHttpServer2 = http(bundlerServer2.url) + const bundlerHttpServer3 = http(bundlerServer3.url) + const paymasterHttpServer1 = http(paymasterServer1.url) + const paymasterHttpServer2 = http(paymasterServer2.url) + const paymasterHttpServer3 = http(paymasterServer3.url) + + const { bundlerFallbackTransport, paymasterFallbackTransport } = + createFallbackTransport([ + { + bundlerTransport: bundlerHttpServer1, + paymasterTransport: paymasterHttpServer1 + }, + { + bundlerTransport: bundlerHttpServer2, + paymasterTransport: paymasterHttpServer2 + }, + { + bundlerTransport: bundlerHttpServer3, + paymasterTransport: paymasterHttpServer3 + } + ]) + + const bundlerTransport = bundlerFallbackTransport({ chain: localhost }) + const paymasterTransport = paymasterFallbackTransport({ + chain: localhost + }) + + expect( + await bundlerTransport.request({ method: "eth_blockNumber" }) + ).toBe("0x1") + expect( + await paymasterTransport.request({ method: "eth_blockNumber" }) + ).toBe("0x13") + + expect(bundlerServerCount).toBe(3) + expect(paymasterServerCount).toBe(1) + }) + + test("it should throw an error if all servers failed", async () => { + let bundlerServerCount = 0 + let paymasterServerCount = 0 + + const bundlerServer1 = await createHttpServer((_req, res) => { + console.log("bundlerServer1") + bundlerServerCount++ + res.writeHead(500) + res.end() + }) + const bundlerServer2 = await createHttpServer((_req, res) => { + console.log("bundlerServer2") + bundlerServerCount++ + res.writeHead(500) + res.end() + }) + const bundlerServer3 = await createHttpServer((_req, res) => { + console.log("bundlerServer3") + bundlerServerCount++ + res.writeHead(500) + res.end() + }) + + const paymasterServer1 = await createHttpServer((_req, res) => { + console.log("paymasterServer1") + paymasterServerCount++ + res.writeHead(500) + res.end() + }) + + const paymasterServer2 = await createHttpServer((_req, res) => { + console.log("paymasterServer2") + paymasterServerCount++ + res.writeHead(500) + res.end() + }) + + const paymasterServer3 = await createHttpServer((_req, res) => { + console.log("paymasterServer3") + paymasterServerCount++ + res.writeHead(500) + res.end() + }) + + const bundlerHttpServer1 = http(bundlerServer1.url) + const bundlerHttpServer2 = http(bundlerServer2.url) + const bundlerHttpServer3 = http(bundlerServer3.url) + const paymasterHttpServer1 = http(paymasterServer1.url) + const paymasterHttpServer2 = http(paymasterServer2.url) + const paymasterHttpServer3 = http(paymasterServer3.url) + + const { bundlerFallbackTransport, paymasterFallbackTransport } = + createFallbackTransport( + [ + { + bundlerTransport: bundlerHttpServer1, + paymasterTransport: paymasterHttpServer1 + }, + { + bundlerTransport: bundlerHttpServer2, + paymasterTransport: paymasterHttpServer2 + }, + { + bundlerTransport: bundlerHttpServer3, + paymasterTransport: paymasterHttpServer3 + } + ], + { + retryCount: 0 + } + ) + + const bundlerTransport = bundlerFallbackTransport({ chain: localhost }) + const paymasterTransport = paymasterFallbackTransport({ + chain: localhost + }) + + await expect( + bundlerTransport.request({ method: "eth_blockNumber" }) + ).rejects.toThrowError() + await expect( + paymasterTransport.request({ method: "eth_blockNumber" }) + ).rejects.toThrowError() + + expect(bundlerServerCount).toBe(3) + expect(paymasterServerCount).toBe(3) + }) +}) diff --git a/packages/test/v0.7/utils.ts b/packages/test/v0.7/utils.ts index d9e7c059..de0c475b 100644 --- a/packages/test/v0.7/utils.ts +++ b/packages/test/v0.7/utils.ts @@ -50,6 +50,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" @@ -529,3 +532,21 @@ 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}` }) + }) + }) +}