diff --git a/.changeset/cyan-crabs-grow.md b/.changeset/cyan-crabs-grow.md new file mode 100644 index 0000000..0218e1a --- /dev/null +++ b/.changeset/cyan-crabs-grow.md @@ -0,0 +1,5 @@ +--- +"@coinbase-platform/paymaster": minor +--- + +feat: add `paymaster` package diff --git a/packages/paymaster/package.json b/packages/paymaster/package.json new file mode 100644 index 0000000..91785d8 --- /dev/null +++ b/packages/paymaster/package.json @@ -0,0 +1,53 @@ +{ + "name": "@coinbase-platform/paymaster", + "version": "0.0.0", + "description": "Coinbase platform Paymaster package", + "author": "roushou ", + "license": "MIT", + "homepage": "https://github.com/roushou/coinbase-platform#readme", + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "typings": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/roushou/coinbase-platform.git", + "directory": "packages/paymaster" + }, + "bugs": { + "url": "https://github.com/roushou/coinbase-platform/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": ["coinbase", "coinbase platform", "paymaster"], + "scripts": { + "build": "pnpm clean && tsup", + "clean": "rimraf ./dist", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@coinbase-platform/utils": "workspace:*" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": ["src", "dist"], + "engine": { + "node": "^18.0.0 || >=20.0.0" + } +} diff --git a/packages/paymaster/src/index.ts b/packages/paymaster/src/index.ts new file mode 100644 index 0000000..0509176 --- /dev/null +++ b/packages/paymaster/src/index.ts @@ -0,0 +1,2 @@ +export { createRpcClient } from "./rpc"; +export type { RpcClient, RpcClientConfig } from "./rpc"; diff --git a/packages/paymaster/src/mocks/handlers.ts b/packages/paymaster/src/mocks/handlers.ts new file mode 100644 index 0000000..637b8c8 --- /dev/null +++ b/packages/paymaster/src/mocks/handlers.ts @@ -0,0 +1,85 @@ +import { RPC_URL } from "@coinbase-platform/utils/constants"; +import { http, HttpResponse } from "msw"; +import type { DefaultBodyType, PathParams } from "msw"; +import { withRpcMethod } from "./predicates"; + +export const handlers = [ + http.post( + `${RPC_URL}/API_KEY`, + withRpcMethod({ method: "eth_supportedEntryPoints" }, async () => { + return HttpResponse.json({ + id: 1, + jsonrpc: "2.0", + result: ["0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"], + }); + }), + ), + http.post( + `${RPC_URL}/API_KEY`, + withRpcMethod({ method: "eth_getUserOperationByHash" }, async () => { + return HttpResponse.json({ + id: 1, + jsonrpc: "2.0", + // TODO: set value + result: [], + }); + }), + ), + http.post( + `${RPC_URL}/API_KEY`, + withRpcMethod({ method: "eth_getUserOperationReceipt" }, async () => { + return HttpResponse.json({ + id: 1, + jsonrpc: "2.0", + result: { + userOpHash: + "0x13574b2256b73bdc33fb121052f64b3803161e5ec602a6dc9e56177ba387e700", + entryPoint: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", + sender: "0x023fEF87894773DF227587d9B29af8D17b4dBB5A", + nonce: "0x1", + paymaster: null, + actualGasCost: "0x6f75ef8d", + actualGasUsed: "0x329af", + success: true, + reason: "", + logs: [ + { + address: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", + topics: [ + "0xbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972", + ], + data: "0x", + blockNumber: "0x27fb22e", + transactionHash: + "0x0f9b0e5868beaf345d8d55895c8037ae85adb91c422c00badcdcae8a0bf247a1", + transactionIndex: "0x4", + blockHash: + "0x965e08190b1093c078bde81f67362203834784e34cf499d516f1a7b9c7a7b29e", + logIndex: "0x13", + removed: false, + }, + ], + receipt: { + blockHash: + "0x965e08190b1093c078bde81f67362203834784e34cf499d516f1a7b9c7a7b29e", + blockNumber: "0x27fb22e", + from: "0x425d190ef5F561aFc8728593cA13EAf2FD9E3380", + to: "0x25aD59adbe00C2d80c86d01e2E05e1294DA84823", + cumulativeGasUsed: "0xe13e1", + gasUsed: "0x329af", + contractAddress: null, + logs: [null], + logsBloom: + "0x000000010000000000000000800000000000000000000008000000000200000000080000020000020002080100010000001080000000000000100210000000000000000000000008000000000000808010000000000000000001000000000000000000000e000000000000000000080000002200000000408880000000000040000020000000000001000000080000002040000000040000000000000008000020000000000100000040000000000000000000000000000000000220000000400000000000000000000100000010000044000000800020000a100000010020000000000040000081000000000000000000000000000000400000000000100000", + status: 1, + type: "0x2", + transactionHash: + "0x0f9b0e5868beaf345d8d55895c8037ae85adb91c422c00badcdcae8a0bf247a1", + transactionIndex: "0x4", + effectiveGasPrice: "0x6f75ef8d", + }, + }, + }); + }), + ), +]; diff --git a/packages/paymaster/src/mocks/node.ts b/packages/paymaster/src/mocks/node.ts new file mode 100644 index 0000000..7b37f2a --- /dev/null +++ b/packages/paymaster/src/mocks/node.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/packages/paymaster/src/mocks/predicates.ts b/packages/paymaster/src/mocks/predicates.ts new file mode 100644 index 0000000..e0562eb --- /dev/null +++ b/packages/paymaster/src/mocks/predicates.ts @@ -0,0 +1,35 @@ +import type { DefaultBodyType, HttpResponseResolver, PathParams } from "msw"; +import type { RpcRequestConfig } from "../rpc"; + +// https://mswjs.io/docs/best-practices/custom-request-predicate + +export function withRpcMethod< + Params extends PathParams, + RequestBodyType extends DefaultBodyType, + ResponseBodyType extends DefaultBodyType, +>( + expectedBody: { method: RpcRequestConfig["method"] }, + resolver: HttpResponseResolver, +): HttpResponseResolver { + return async (args) => { + const { request } = args; + + // Ignore requests that have a non-JSON body. + const contentType = request.headers.get("Content-Type") || ""; + if (!contentType.includes("application/json")) { + return; + } + + // Ignore requests from handlers that don't specify the rpc method + if (!expectedBody || !("method" in expectedBody)) { + return; + } + + const body = await request.clone().json(); + if (body?.method !== expectedBody.method) { + return; + } + + return resolver(args); + }; +} diff --git a/packages/paymaster/src/rpc.test.ts b/packages/paymaster/src/rpc.test.ts new file mode 100644 index 0000000..38be379 --- /dev/null +++ b/packages/paymaster/src/rpc.test.ts @@ -0,0 +1,50 @@ +import { RPC_URL } from "@coinbase-platform/utils/constants"; +import { describe, expect, test } from "vitest"; +import { createRpcClient } from "./rpc"; + +describe("rpc", () => { + test("should set RPC url", async () => { + const rpc = createRpcClient({ + apiKey: "API_KEY", + url: "https://coinbase.com/other/rpc", + }); + expect(rpc.__url).toEqual("https://coinbase.com/other/rpc"); + }); + + test(`should default RPC url to ${RPC_URL}`, async () => { + const rpc = createRpcClient({ apiKey: "API_KEY" }); + expect(rpc.__url).toEqual(RPC_URL); + }); + + test("should get supported entrypoints", async () => { + const rpc = createRpcClient({ apiKey: "API_KEY" }); + const response = await rpc.request({ + method: "eth_supportedEntryPoints", + }); + expect(response).toEqual({ + id: 1, + jsonrpc: "2.0", + result: ["0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"], + }); + }); + + test("should get user operation by hash", async () => { + const rpc = createRpcClient({ apiKey: "API_KEY" }); + await rpc.request({ + method: "eth_getUserOperationByHash", + parameters: [ + "0x77c0b560eb0b042902abc5613f768d2a6b2d67481247e9663bf4d68dec0ca122", + ], + }); + }); + + test("should get user operation receipt", async () => { + const rpc = createRpcClient({ apiKey: "API_KEY" }); + await rpc.request({ + method: "eth_getUserOperationReceipt", + parameters: [ + "0x77c0b560eb0b042902abc5613f768d2a6b2d67481247e9663bf4d68dec0ca122", + ], + }); + }); +}); diff --git a/packages/paymaster/src/rpc.ts b/packages/paymaster/src/rpc.ts new file mode 100644 index 0000000..10ef8da --- /dev/null +++ b/packages/paymaster/src/rpc.ts @@ -0,0 +1,175 @@ +import { RPC_URL } from "@coinbase-platform/utils/constants"; +import * as http from "@coinbase-platform/utils/http"; + +export type RpcClient = { + __url: string; + request: < + TConfig extends RpcRequestConfig, + TMethod extends RpcRequestConfig["method"], + >( + config: TConfig extends { + method: TMethod; + parameters: infer TParameters; + } + ? { method: TMethod; parameters: TParameters } + : { method: TMethod; parameters?: never }, + ) => Promise< + TConfig extends { method: TMethod; response: infer TResponse } + ? TResponse + : never + >; +}; + +export type RpcClientConfig = { + apiKey: string; + /** + * Coinbase platform RPC endpoint. + * Defaults to `https://api.developer.coinbase.com/rpc/v1/base` + */ + url?: string; +}; + +/** + * @returns The RPC client + * + * @param apiKey - Your API key + * @param url - Your RPC url. Defaults to `https://api.developer.coinbase.com/rpc/v1/base` + * + * @example + * + * const rpcClient = createRpcClient({ + * apiKey: API_KEY, + * rpcUrl: "https://api.developer.coinbase.com/rpc/v1/base", + * }); + * + */ +export function createRpcClient({ + apiKey, + url = RPC_URL, +}: RpcClientConfig): RpcClient { + return { + __url: url, + request: async (config) => { + return await http.post(`${url}/${apiKey}`, { + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: config.method, + params: config.parameters, + }), + }); + }, + }; +} + +export type RpcRequestConfig = + | { + method: "eth_supportedEntryPoints"; + response: RpcResponse; + } + | { + method: "eth_getUserOperationByHash"; + parameters: string[]; + response: RpcResponse<{ + sender: string; + nonce: string; + initCode: string; + callData: string; + callGasLimit: string; + verificationGasLimit: string; + preVerificationGas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + signature: string; + paymasterAndData: string; + blockNumber: number; + blockHash: string; + transactionHash: string; + }>; + } + | { + method: "eth_getUserOperationReceipt"; + parameters: string[]; + response: RpcResponse<{ + userOpHash: string; + entryPoint: string; + sender: string; + nonce: string; + paymaster: string | null; + actualGasCost: string; + actualGasUsed: string; + success: boolean; + reason: string; + logs: Array<{ + address: string; + topics: string; + data: string; + blockNumber: string; + transactionHash: string; + transactionIndex: string; + blockHash: string; + logIndex: string; + removed: boolean; + }>; + receipt: { + blockHash: string; + blockNumber: string; + from: string; + to: string; + cumulativeGasUsed: string; + gasUsed: string; + contractAddress: string | null; + logs: [null]; + logsBloom: string; + status: number; + type: string; + transactionHash: string; + transactionIndex: string; + effectiveGasPrice: string; + }; + }>; + } + | { + method: "eth_sendUserOperation"; + parameters: [UserOperation, string]; + response: RpcResponse; + } + | { + method: "eth_estimateUserOperationGas"; + parameters: [UserOperation, string]; + response: RpcResponse<{ + preVerificationGas: string; + verificationGasLimit: string; + callGasLimit: string; + }>; + } + | { + method: "pm_sponsorUserOperation"; + parameters: [UserOperation, string]; + response: RpcResponse<{ + paymasterAndData: string; + preVerificationGas: string; + verificationGasLimit: string; + callGasLimit: string; + }>; + }; + +type UserOperation = { + sender: string; + nonce: number; + initCode: string; + callData: string; + callGasLimit: string; + verificationGasLimit: string; + preVerificationGas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + signature: string; + paymasterAndData: string; +}; + +type RpcResponse = { + id: number; + jsonrpc: string; + result: TResult; +}; diff --git a/packages/paymaster/vitest.config.ts b/packages/paymaster/vitest.config.ts new file mode 100644 index 0000000..ef1aaf1 --- /dev/null +++ b/packages/paymaster/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + root: __dirname, + environment: "node", + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/packages/paymaster/vitest.setup.ts b/packages/paymaster/vitest.setup.ts new file mode 100644 index 0000000..aaebb1c --- /dev/null +++ b/packages/paymaster/vitest.setup.ts @@ -0,0 +1,14 @@ +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./src/mocks/node"; + +beforeAll(() => { + server.listen(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa57c4..1fcdae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,16 @@ importers: specifier: workspace:* version: link:../utils + packages/paymaster: + dependencies: + typescript: + specifier: '>=5.0.4' + version: 5.4.5 + devDependencies: + '@coinbase-platform/utils': + specifier: workspace:* + version: link:../utils + packages/utils: dependencies: typescript: