From 8fa5ff1a103aae8e73e6ffd80254bca439af1f9f Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Thu, 16 Jan 2025 17:11:25 +0100 Subject: [PATCH] Fix Bundler Sponsorship Policy (#121) Previously this was filtering by this.chainId which was really returning `policiesForChainId`. Note that this method is chain agnostic although the the chainId and entryPointAddress are required by the constructor. This hinted at somewhat bad design that could be improved. We split the Transaction Bundler from the Pimlico Service so that users with a pimlico key can make those requests without having to specify all the other irrelevant details. Tests Added. --- src/lib/bundler.ts | 82 +++++++----------------------- src/lib/pimlico.ts | 93 ++++++++++++++++++++++++++++++++++ src/near-safe.ts | 5 +- tests/e2e.spec.ts | 28 ++++++++-- tests/unit/lib/bundler.spec.ts | 3 +- 5 files changed, 139 insertions(+), 72 deletions(-) create mode 100644 src/lib/pimlico.ts 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";