diff --git a/src/lib/bundler.ts b/src/lib/bundler.ts index 7b99637..73a4f1f 100644 --- a/src/lib/bundler.ts +++ b/src/lib/bundler.ts @@ -6,14 +6,11 @@ import { PublicClient, rpcSchema, Transport, - RpcError, - HttpRequestError, } from "viem"; import { GasPrices, PaymasterData, - SponsorshipPoliciesResponse, SponsorshipPolicyData, UnsignedUserOperation, UserOperation, @@ -21,10 +18,7 @@ import { UserOperationReceipt, } from "../types"; import { PLACEHOLDER_SIG } from "../util"; - -function bundlerUrl(chainId: number, apikey: string): string { - return `https://api.pimlico.io/v2/${chainId}/rpc?apikey=${apikey}`; -} +import { Pimlico } from "./pimlico"; type SponsorshipPolicy = { sponsorshipPolicyId: string }; @@ -61,15 +55,15 @@ type BundlerRpcSchema = [ export class Erc4337Bundler { client: PublicClient; entryPointAddress: Address; - apiKey: string; + pimlico: Pimlico; chainId: number; constructor(entryPointAddress: Address, apiKey: string, chainId: number) { this.entryPointAddress = entryPointAddress; - this.apiKey = apiKey; + this.pimlico = new Pimlico(apiKey); this.chainId = chainId; this.client = createPublicClient({ - transport: http(bundlerUrl(chainId, this.apiKey)), + transport: http(this.pimlico.bundlerUrl(chainId)), rpcSchema: rpcSchema(), }); } @@ -81,7 +75,7 @@ export class Erc4337Bundler { const userOp = { ...rawUserOp, signature: PLACEHOLDER_SIG }; if (sponsorshipPolicy) { console.log("Requesting paymaster data..."); - return handleRequest(() => + return this.pimlico.handleRequest(() => this.client.request({ method: "pm_sponsorUserOperation", params: [ @@ -93,7 +87,7 @@ export class Erc4337Bundler { ); } console.log("Estimating user operation gas..."); - return handleRequest(() => + return this.pimlico.handleRequest(() => this.client.request({ method: "eth_estimateUserOperationGas", params: [userOp, this.entryPointAddress], @@ -102,7 +96,7 @@ export class Erc4337Bundler { } async sendUserOperation(userOp: UserOperation): Promise { - return handleRequest(() => + return this.pimlico.handleRequest(() => this.client.request({ method: "eth_sendUserOperation", params: [userOp, this.entryPointAddress], @@ -112,7 +106,7 @@ export class Erc4337Bundler { } async getGasPrice(): Promise { - return handleRequest(() => + return this.pimlico.handleRequest(() => this.client.request({ method: "pimlico_getUserOperationGasPrice", params: [], @@ -130,64 +124,22 @@ export class Erc4337Bundler { return userOpReceipt; } + async getSponsorshipPolicies(): Promise { + // Chain ID doesn't matter for this bundler endpoint. + const allPolicies = await this.pimlico.getSponsorshipPolicies(); + return allPolicies.filter((p) => + p.chain_ids.allowlist.includes(this.chainId) + ); + } + private async _getUserOpReceiptInner( userOpHash: Hash ): Promise { - return handleRequest(() => + return this.pimlico.handleRequest(() => this.client.request({ method: "eth_getUserOperationReceipt", params: [userOpHash], }) ); } - - // New method to query sponsorship policies - async getSponsorshipPolicies(): Promise { - const url = `https://api.pimlico.io/v2/account/sponsorship_policies?apikey=${this.apiKey}`; - const allPolocies = await handleRequest( - async () => { - const response = await fetch(url); - - if (!response.ok) { - throw new Error( - `HTTP error! status: ${response.status}: ${response.statusText}` - ); - } - return response.json(); - } - ); - return allPolocies.data.filter((p) => - p.chain_ids.allowlist.includes(this.chainId) - ); - } -} - -async function handleRequest(clientMethod: () => Promise): Promise { - try { - return await clientMethod(); - } catch (error) { - const message = stripApiKey(error); - if (error instanceof HttpRequestError) { - if (error.status === 401) { - throw new Error( - "Unauthorized request. Please check your Pimlico API key." - ); - } else { - throw new Error(`Pimlico: ${message}`); - } - } else if (error instanceof RpcError) { - throw new Error(`Failed to send user op with: ${message}`); - } - throw new Error(`Bundler Request: ${message}`); - } -} - -export function stripApiKey(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return message.replace(/(apikey=)[^\s&]+/, "$1***"); - // Could also do this with slicing. - // const keyStart = message.indexOf("apikey=") + 7; - // // If no apikey in the message, return it as is. - // if (keyStart === -1) return message; - // return `${message.slice(0, keyStart)}***${message.slice(keyStart + 36)}`; } diff --git a/src/lib/pimlico.ts b/src/lib/pimlico.ts new file mode 100644 index 0000000..b8141c8 --- /dev/null +++ b/src/lib/pimlico.ts @@ -0,0 +1,93 @@ +import { HttpRequestError, RpcError } from "viem"; + +import { SponsorshipPoliciesResponse, SponsorshipPolicyData } from "../types"; + +export class Pimlico { + private apiKey: string; + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + bundlerUrl(chainId: number): string { + return `https://api.pimlico.io/v2/${chainId}/rpc?apikey=${this.apiKey}`; + } + + // New method to query sponsorship policies + async getSponsorshipPolicies(): Promise { + const url = `https://api.pimlico.io/v2/account/sponsorship_policies?apikey=${this.apiKey}`; + const allPolicies = await this.handleRequest( + async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `HTTP error! status: ${response.status}: ${response.statusText}` + ); + } + return response.json(); + } + ); + return allPolicies.data; + } + + async getSponsorshipPolicyByName( + name: string + ): Promise { + const allPolicies = await this.getSponsorshipPolicies(); + const result = allPolicies.filter((t) => t.policy_name === name); + if (result.length === 0) { + throw new Error( + `No policy found with policy_name=${name}: try ${allPolicies.map((t) => t.policy_name)}` + ); + } else if (result.length > 1) { + throw new Error( + `Multiple Policies with same policy_name=${name}: ${JSON.stringify(result)}` + ); + } + + return result[0]!; + } + + async getSponsorshipPolicyById(id: string): Promise { + const allPolicies = await this.getSponsorshipPolicies(); + const result = allPolicies.filter((t) => t.id === id); + if (result.length === 0) { + throw new Error( + `No policy found with id=${id}: try ${allPolicies.map((t) => t.id)}` + ); + } + // We assume that ids are unique so that result.length > 1 need not be handled. + + return result[0]!; + } + + async handleRequest(clientMethod: () => Promise): Promise { + try { + return await clientMethod(); + } catch (error) { + const message = stripApiKey(error); + if (error instanceof HttpRequestError) { + if (error.status === 401) { + throw new Error( + "Unauthorized request. Please check your Pimlico API key." + ); + } else { + throw new Error(`Pimlico: ${message}`); + } + } else if (error instanceof RpcError) { + throw new Error(`Failed to send user op with: ${message}`); + } + throw new Error(`Bundler Request: ${message}`); + } + } +} + +export function stripApiKey(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/(apikey=)[^\s&]+/, "$1***"); + // Could also do this with slicing. + // const keyStart = message.indexOf("apikey=") + 7; + // // If no apikey in the message, return it as is. + // if (keyStart === -1) return message; + // return `${message.slice(0, keyStart)}***${message.slice(keyStart + 36)}`; +} diff --git a/src/near-safe.ts b/src/near-safe.ts index 6a232ce..36a560c 100644 --- a/src/near-safe.ts +++ b/src/near-safe.ts @@ -480,9 +480,8 @@ export class NearSafe { return [this.address.toLowerCase(), lowerZero].includes(lowerFrom); } - async policyForChainId(chainId: number): Promise { - const bundler = this.bundlerForChainId(chainId); - return bundler.getSponsorshipPolicies(); + async policiesForChainId(chainId: number): Promise { + return this.bundlerForChainId(chainId).getSponsorshipPolicies(); } deploymentRequest(chainId: number): SignRequestData { diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index be39d3b..cc77586 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -3,6 +3,7 @@ import { isHex, zeroAddress } from "viem"; import { DEFAULT_SAFE_SALT_NONCE, NearSafe } from "../src"; import { decodeTxData } from "../src/decode"; +import { Pimlico } from "../src/lib/pimlico"; dotenv.config(); @@ -58,8 +59,30 @@ describe("Near Safe Requests", () => { ).rejects.toThrow(); }); - it("bundler: getSponsorshipPolicy", async () => { - await expect(adapter.policyForChainId(100)).resolves.not.toThrow(); + it("pimlico: getSponsorshipPolicies", async () => { + const pimlico = new Pimlico(process.env.PIMLICO_KEY!); + await expect(pimlico.getSponsorshipPolicies()).resolves.not.toThrow(); + await expect( + pimlico.getSponsorshipPolicyByName("bitte-policy") + ).resolves.not.toThrow(); + }); + + it("pimlico: getSponsorshipPolicies failures", async () => { + await expect( + new Pimlico("Invalid Key").getSponsorshipPolicies() + ).rejects.toThrow(); + + const pimlico = new Pimlico(process.env.PIMLICO_KEY!); + await expect( + pimlico.getSponsorshipPolicyByName("poop-policy") + ).rejects.toThrow("No policy found with policy_name="); + await expect( + pimlico.getSponsorshipPolicyById("invalid id") + ).rejects.toThrow("No policy found with id="); + }); + + it("bundler: policiesForChainId", async () => { + await expect(adapter.policiesForChainId(100)).resolves.not.toThrow(); }); it("adapter: encodeEvmTx", async () => { @@ -105,7 +128,6 @@ describe("Near Safe Requests", () => { ], chainId, }); - console.log(request); expect(() => decodeTxData({ evmMessage: request.evmMessage, chainId }) ).not.toThrow(); diff --git a/tests/unit/lib/bundler.spec.ts b/tests/unit/lib/bundler.spec.ts index e4a37cb..eb104f3 100644 --- a/tests/unit/lib/bundler.spec.ts +++ b/tests/unit/lib/bundler.spec.ts @@ -1,4 +1,5 @@ -import { Erc4337Bundler, stripApiKey } from "../../../src/lib/bundler"; +import { Erc4337Bundler } from "../../../src/lib/bundler"; +import { stripApiKey } from "../../../src/lib/pimlico"; describe("Safe Pack", () => { const entryPoint = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";