Skip to content

Commit

Permalink
Fix Bundler Sponsorship Policy (#121)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bh2smith authored Jan 16, 2025
1 parent 9ccc898 commit 8fa5ff1
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 72 deletions.
82 changes: 17 additions & 65 deletions src/lib/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,19 @@ import {
PublicClient,
rpcSchema,
Transport,
RpcError,
HttpRequestError,
} from "viem";

import {
GasPrices,
PaymasterData,
SponsorshipPoliciesResponse,
SponsorshipPolicyData,
UnsignedUserOperation,
UserOperation,
UserOperationGas,
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 };

Expand Down Expand Up @@ -61,15 +55,15 @@ type BundlerRpcSchema = [
export class Erc4337Bundler {
client: PublicClient<Transport, undefined, undefined, BundlerRpcSchema>;
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<BundlerRpcSchema>(),
});
}
Expand All @@ -81,7 +75,7 @@ export class Erc4337Bundler {
const userOp = { ...rawUserOp, signature: PLACEHOLDER_SIG };
if (sponsorshipPolicy) {
console.log("Requesting paymaster data...");
return handleRequest<PaymasterData>(() =>
return this.pimlico.handleRequest<PaymasterData>(() =>
this.client.request({
method: "pm_sponsorUserOperation",
params: [
Expand All @@ -93,7 +87,7 @@ export class Erc4337Bundler {
);
}
console.log("Estimating user operation gas...");
return handleRequest<UserOperationGas>(() =>
return this.pimlico.handleRequest<UserOperationGas>(() =>
this.client.request({
method: "eth_estimateUserOperationGas",
params: [userOp, this.entryPointAddress],
Expand All @@ -102,7 +96,7 @@ export class Erc4337Bundler {
}

async sendUserOperation(userOp: UserOperation): Promise<Hash> {
return handleRequest<Hash>(() =>
return this.pimlico.handleRequest<Hash>(() =>
this.client.request({
method: "eth_sendUserOperation",
params: [userOp, this.entryPointAddress],
Expand All @@ -112,7 +106,7 @@ export class Erc4337Bundler {
}

async getGasPrice(): Promise<GasPrices> {
return handleRequest<GasPrices>(() =>
return this.pimlico.handleRequest<GasPrices>(() =>
this.client.request({
method: "pimlico_getUserOperationGasPrice",
params: [],
Expand All @@ -130,64 +124,22 @@ export class Erc4337Bundler {
return userOpReceipt;
}

async getSponsorshipPolicies(): Promise<SponsorshipPolicyData[]> {
// 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<UserOperationReceipt | null> {
return handleRequest<UserOperationReceipt | null>(() =>
return this.pimlico.handleRequest<UserOperationReceipt | null>(() =>
this.client.request({
method: "eth_getUserOperationReceipt",
params: [userOpHash],
})
);
}

// New method to query sponsorship policies
async getSponsorshipPolicies(): Promise<SponsorshipPolicyData[]> {
const url = `https://api.pimlico.io/v2/account/sponsorship_policies?apikey=${this.apiKey}`;
const allPolocies = await handleRequest<SponsorshipPoliciesResponse>(
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<T>(clientMethod: () => Promise<T>): Promise<T> {
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)}`;
}
93 changes: 93 additions & 0 deletions src/lib/pimlico.ts
Original file line number Diff line number Diff line change
@@ -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<SponsorshipPolicyData[]> {
const url = `https://api.pimlico.io/v2/account/sponsorship_policies?apikey=${this.apiKey}`;
const allPolicies = await this.handleRequest<SponsorshipPoliciesResponse>(
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<SponsorshipPolicyData> {
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<SponsorshipPolicyData> {
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<T>(clientMethod: () => Promise<T>): Promise<T> {
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)}`;
}
5 changes: 2 additions & 3 deletions src/near-safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,8 @@ export class NearSafe {
return [this.address.toLowerCase(), lowerZero].includes(lowerFrom);
}

async policyForChainId(chainId: number): Promise<SponsorshipPolicyData[]> {
const bundler = this.bundlerForChainId(chainId);
return bundler.getSponsorshipPolicies();
async policiesForChainId(chainId: number): Promise<SponsorshipPolicyData[]> {
return this.bundlerForChainId(chainId).getSponsorshipPolicies();
}

deploymentRequest(chainId: number): SignRequestData {
Expand Down
28 changes: 25 additions & 3 deletions tests/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -105,7 +128,6 @@ describe("Near Safe Requests", () => {
],
chainId,
});
console.log(request);
expect(() =>
decodeTxData({ evmMessage: request.evmMessage, chainId })
).not.toThrow();
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/lib/bundler.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down

0 comments on commit 8fa5ff1

Please sign in to comment.