From a2dd714b746aa674a7faefe52e78332398fdd211 Mon Sep 17 00:00:00 2001 From: Fizzl <49376692+Fizzl13@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:53:28 +0200 Subject: [PATCH] feat: add x402 Doctor action provider with preflight_x402_endpoint Checks an x402 endpoint before the agent pays it, using the x402 Doctor preflight ($0.001 in USDC via x402 from the agent's wallet on Base or Solana mainnet, capped at $0.002 and only on the wallet's network). Returns go, caution or no_go with reasons, the recommended payment option and advice. Complements the x402 action provider between make_http_request and retry_http_request_with_x402. --- .../.changeset/x402-doctor-preflight.md | 5 + .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/x402Doctor/README.md | 59 ++++ .../src/action-providers/x402Doctor/index.ts | 3 + .../action-providers/x402Doctor/schemas.ts | 21 ++ .../x402DoctorActionProvider.test.ts | 208 ++++++++++++++ .../x402Doctor/x402DoctorActionProvider.ts | 255 ++++++++++++++++++ 7 files changed, 552 insertions(+) create mode 100644 typescript/.changeset/x402-doctor-preflight.md create mode 100644 typescript/agentkit/src/action-providers/x402Doctor/README.md create mode 100644 typescript/agentkit/src/action-providers/x402Doctor/index.ts create mode 100644 typescript/agentkit/src/action-providers/x402Doctor/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.ts diff --git a/typescript/.changeset/x402-doctor-preflight.md b/typescript/.changeset/x402-doctor-preflight.md new file mode 100644 index 000000000..ecf4aa0e3 --- /dev/null +++ b/typescript/.changeset/x402-doctor-preflight.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added an x402 Doctor action provider with a preflight_x402_endpoint action that checks an x402 endpoint before paying it diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..59779e528 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -36,6 +36,7 @@ export * from "./flaunch"; export * from "./onramp"; export * from "./vaultsfyi"; export * from "./x402"; +export * from "./x402Doctor"; export * from "./yelay"; export * from "./zerion"; export * from "./zerodev"; diff --git a/typescript/agentkit/src/action-providers/x402Doctor/README.md b/typescript/agentkit/src/action-providers/x402Doctor/README.md new file mode 100644 index 000000000..8322f28e7 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402Doctor/README.md @@ -0,0 +1,59 @@ +# x402 Doctor Action Provider + +This provider lets an agent check an x402 endpoint **before paying it**, using the [x402 Doctor](https://x402-doctor.onrender.com) preflight. It complements the [x402 action provider](../x402/README.md): after `make_http_request` returns a 402 for an endpoint the agent has not used before, `preflight_x402_endpoint` answers whether paying it is safe, and only then does the agent call `retry_http_request_with_x402`. + +## Directory Structure + +``` +x402Doctor/ +├── x402DoctorActionProvider.ts # Provider with the preflight action +├── x402DoctorActionProvider.test.ts # Unit tests +├── schemas.ts # Action input schema +├── index.ts # Exports +└── README.md # This file +``` + +## Setup + +No API key. The preflight costs **$0.001 in USDC**, paid via x402 from the agent's wallet on **Base mainnet or Solana mainnet** (never more than $0.002 per preflight, only on the wallet's own network). + +```typescript +import { AgentKit, x402ActionProvider, x402DoctorActionProvider } from "@coinbase/agentkit"; + +const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [x402ActionProvider(), x402DoctorActionProvider()], +}); +``` + +Optional configuration: `x402DoctorActionProvider({ doctorUrl })` (or the `X402_DOCTOR_URL` environment variable) to use another instance of x402 Doctor. + +## Actions + +| Action | Description | +|--------|-------------| +| `preflight_x402_endpoint` | Checks an x402 endpoint before paying it; returns `go`, `caution` or `no_go` with reasons, the recommended payment option and advice | + +Inputs: `url` (the endpoint about to be paid), `method` (`GET` or `POST`, default `GET`), `maxUsd` (optional budget per call in USD; above it the verdict is `no_go`). + +## What the preflight checks + +The preflight reads the endpoint's 402 payment requirements and checks: + +- the price against the budget and against what the service advertises +- whether the option is payable on the wallet's network (USDC, a valid payout address, a Solana payout account that exists) +- HTTPS +- the endpoint's payability track record +- whether the endpoint is listed in the CDP Bazaar + +| Verdict | What the agent should do | +|---------|--------------------------| +| `go` | Pay the recommended option with `retry_http_request_with_x402` | +| `caution` | Show the user the summary and pay only after they confirm | +| `no_go` | Do not pay | + +If the preflight fails, the action returns an error without a verdict, and the agent should not pay. The preflight checks whether a payment can succeed and is sensible; it does not verify what the service delivers after payment. + +## Network Support + +Base mainnet (`base-mainnet`) and Solana mainnet (`solana-mainnet`). diff --git a/typescript/agentkit/src/action-providers/x402Doctor/index.ts b/typescript/agentkit/src/action-providers/x402Doctor/index.ts new file mode 100644 index 000000000..5ae91c875 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402Doctor/index.ts @@ -0,0 +1,3 @@ +export { X402DoctorActionProvider, x402DoctorActionProvider } from "./x402DoctorActionProvider"; +export type { X402DoctorConfig } from "./x402DoctorActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/x402Doctor/schemas.ts b/typescript/agentkit/src/action-providers/x402Doctor/schemas.ts new file mode 100644 index 000000000..826da7fc2 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402Doctor/schemas.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const PreflightX402EndpointSchema = z + .object({ + url: z + .string() + .url() + .describe("The x402 endpoint you are about to pay, e.g. https://api.example.com/data?q=1"), + method: z + .enum(["GET", "POST"]) + .nullable() + .optional() + .describe("HTTP method you will call the endpoint with (default GET)"), + maxUsd: z + .number() + .positive() + .nullable() + .optional() + .describe("Your budget per call in USD; above it the verdict is no_go (e.g. 0.05)"), + }) + .describe("Check an x402 endpoint with x402 Doctor before paying it"); diff --git a/typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.test.ts b/typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.test.ts new file mode 100644 index 000000000..ccdd10e44 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.test.ts @@ -0,0 +1,208 @@ +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { EvmWalletProvider, WalletProvider } from "../../wallet-providers"; +import { x402DoctorActionProvider, X402_DOCTOR_URL } from "./x402DoctorActionProvider"; + +jest.mock("@x402/fetch"); +jest.mock("@x402/evm/exact/client"); +jest.mock("@x402/svm/exact/client"); + +const mockFetchWithPayment = jest.fn(); +let registeredPolicy: ((version: number, reqs: unknown[]) => unknown[]) | undefined; +const mockClient = { + registerPolicy: jest.fn((policy: typeof registeredPolicy) => { + registeredPolicy = policy; + return mockClient; + }), +}; +jest.mocked(x402Client).mockImplementation(() => mockClient as unknown as x402Client); +jest.mocked(wrapFetchWithPayment).mockReturnValue(mockFetchWithPayment); + +const makeWallet = (networkId: string) => { + const wallet = Object.create(EvmWalletProvider.prototype); + wallet.toSigner = jest.fn().mockReturnValue({ address: "0x1234" }); + wallet.readContract = jest.fn(); + wallet.getNetwork = jest.fn().mockReturnValue({ protocolFamily: "evm", networkId }); + return wallet as EvmWalletProvider; +}; + +const PREFLIGHT_GO = { + url: "https://api.example.com/data", + method: "GET", + verdict: "go", + safe_to_pay: true, + summary: "OK to pay: $0.02 on Base.", + recommended_option: 0, + options: [{ index: 0, network: "eip155:8453", usd: 0.02, payable: true, problems: [] }], + signals: { https: true, listed_in_cdp_bazaar: true }, + reasons: [], +}; + +const receipt = btoa( + JSON.stringify({ success: true, transaction: "0xabc", network: "eip155:8453" }), +); +const jsonResponse = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + +describe("X402DoctorActionProvider", () => { + const provider = x402DoctorActionProvider(); + + beforeEach(() => { + jest.clearAllMocks(); + registeredPolicy = undefined; + }); + + describe("supportsNetwork", () => { + it("supports Base mainnet and Solana mainnet only", () => { + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe( + true, + ); + expect(provider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" })).toBe( + true, + ); + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-sepolia" })).toBe( + false, + ); + expect(provider.supportsNetwork({ protocolFamily: "evm" })).toBe(false); + }); + }); + + describe("preflight_x402_endpoint", () => { + it("pays the preflight and returns the verdict, advice, option and receipt", async () => { + mockFetchWithPayment.mockResolvedValue( + jsonResponse(PREFLIGHT_GO, 200, { "payment-response": receipt }), + ); + + const result = JSON.parse( + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + method: "GET", + maxUsd: 0.05, + }), + ); + + const called = new URL(mockFetchWithPayment.mock.calls[0][0]); + expect(called.origin + called.pathname).toBe(`${X402_DOCTOR_URL}/api/v1/preflight`); + expect(Object.fromEntries(called.searchParams)).toEqual({ + url: "https://api.example.com/data", + method: "GET", + network: "eip155:8453", + max_usd: "0.05", + }); + expect(registerExactEvmScheme).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + success: true, + verdict: "go", + summary: "OK to pay: $0.02 on Base.", + recommendedOption: { network: "eip155:8453", usd: 0.02 }, + paymentProof: { transaction: "0xabc" }, + }); + expect(result.advice).toMatch(/retry_http_request_with_x402/); + }); + + it("tells the agent not to pay on no_go and to ask on caution", async () => { + mockFetchWithPayment.mockResolvedValueOnce( + jsonResponse({ + ...PREFLIGHT_GO, + verdict: "no_go", + summary: "Do not pay: over budget", + recommended_option: null, + }), + ); + const noGo = JSON.parse( + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + }), + ); + expect(noGo.verdict).toBe("no_go"); + expect(noGo.advice).toMatch(/Do not pay/); + expect(noGo.recommendedOption).toBeNull(); + + mockFetchWithPayment.mockResolvedValueOnce( + jsonResponse({ ...PREFLIGHT_GO, verdict: "caution" }), + ); + const caution = JSON.parse( + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + }), + ); + expect(caution.advice).toMatch(/confirm/); + }); + + it("defaults to GET and leaves out the budget when none is given", async () => { + mockFetchWithPayment.mockResolvedValue(jsonResponse(PREFLIGHT_GO)); + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + }); + const params = new URL(mockFetchWithPayment.mock.calls[0][0]).searchParams; + expect(params.get("method")).toBe("GET"); + expect(params.has("max_usd")).toBe(false); + }); + + it("only pays on the wallet's network and never more than $0.002", async () => { + mockFetchWithPayment.mockResolvedValue(jsonResponse(PREFLIGHT_GO)); + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + }); + const allowed = registeredPolicy!(2, [ + { network: "eip155:8453", amount: "1000" }, + { network: "eip155:8453", amount: "5000" }, + { network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", amount: "1000" }, + ]); + expect(allowed).toEqual([{ network: "eip155:8453", amount: "1000" }]); + }); + + it("returns an error without paying on an unsupported network", async () => { + const result = JSON.parse( + await provider.preflightX402Endpoint(makeWallet("base-sepolia"), { + url: "https://api.example.com/data", + }), + ); + expect(result).toMatchObject({ error: true, message: "Unsupported network" }); + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("returns an error for a wallet provider that cannot pay", async () => { + const wallet = { + getNetwork: () => ({ protocolFamily: "evm", networkId: "base-mainnet" }), + } as unknown as WalletProvider; + const result = JSON.parse( + await provider.preflightX402Endpoint(wallet, { url: "https://api.example.com/data" }), + ); + expect(result.message).toBe("Unsupported wallet provider"); + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("reports a failed preflight as no verdict", async () => { + mockFetchWithPayment.mockResolvedValueOnce(jsonResponse({ error: "boom" }, 502)); + const failed = JSON.parse( + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + }), + ); + expect(failed).toMatchObject({ error: true, message: "Preflight failed with status 502" }); + + mockFetchWithPayment.mockRejectedValueOnce(new Error("network down")); + const thrown = JSON.parse( + await provider.preflightX402Endpoint(makeWallet("base-mainnet"), { + url: "https://api.example.com/data", + }), + ); + expect(thrown).toMatchObject({ error: true, details: "network down" }); + }); + + it("uses a custom Doctor URL", async () => { + mockFetchWithPayment.mockResolvedValue(jsonResponse(PREFLIGHT_GO)); + await x402DoctorActionProvider({ doctorUrl: "https://doctor.test/" }).preflightX402Endpoint( + makeWallet("base-mainnet"), + { url: "https://api.example.com/data" }, + ); + expect(mockFetchWithPayment.mock.calls[0][0]).toMatch( + /^https:\/\/doctor\.test\/api\/v1\/preflight\?/, + ); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.ts b/typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.ts new file mode 100644 index 000000000..5f0882af4 --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402Doctor/x402DoctorActionProvider.ts @@ -0,0 +1,255 @@ +import { z } from "zod"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { registerExactSvmScheme } from "@x402/svm/exact/client"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { EvmWalletProvider, SvmWalletProvider, WalletProvider } from "../../wallet-providers"; +import { PreflightX402EndpointSchema } from "./schemas"; + +export const X402_DOCTOR_URL = "https://x402-doctor.onrender.com"; + +/** The preflight costs $0.001; never pay more than $0.002 (USDC, 6 decimals). */ +const MAX_PREFLIGHT_ATOMIC = BigInt(2000); + +/** Networks the preflight can be paid on, by AgentKit network id. */ +const PAYMENT_NETWORKS: Record = { + "base-mainnet": "eip155:8453", + "solana-mainnet": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", +}; + +const ADVICE: Record = { + go: "Safe to pay: continue with retry_http_request_with_x402 using the recommended option.", + caution: "Payable, but with a caveat: tell the user the summary and pay only after they confirm.", + no_go: "Do not pay this endpoint: the payment would fail, is over budget, or should not be made.", +}; + +/** + * Configuration for the X402DoctorActionProvider. + */ +export interface X402DoctorConfig { + /** x402 Doctor base URL. Default https://x402-doctor.onrender.com (or X402_DOCTOR_URL). */ + doctorUrl?: string; +} + +/** + * X402DoctorActionProvider checks an x402 endpoint before the agent pays it, using the + * x402 Doctor preflight ($0.001 in USDC, paid via x402 from the agent's wallet on Base or + * Solana). It complements the x402 action provider: call preflight_x402_endpoint between + * make_http_request (which returns the 402) and retry_http_request_with_x402. + */ +export class X402DoctorActionProvider extends ActionProvider { + private readonly doctorUrl: string; + + /** + * Creates a new X402DoctorActionProvider. + * + * @param config - Optional configuration + */ + constructor(config: X402DoctorConfig = {}) { + super("x402Doctor", []); + this.doctorUrl = (config.doctorUrl ?? process.env.X402_DOCTOR_URL ?? X402_DOCTOR_URL).replace( + /\/$/, + "", + ); + } + + /** + * Checks an x402 endpoint before paying it. + * + * @param walletProvider - The wallet that pays the $0.001 preflight + * @param args - The endpoint URL, method and budget + * @returns A JSON string with the verdict (go, caution or no_go), the reasons and advice + */ + @CreateAction({ + name: "preflight_x402_endpoint", + description: ` +Checks an x402 (HTTP 402) endpoint BEFORE paying it, using the x402 Doctor preflight. +Costs $0.001 in USDC, paid automatically from the wallet (Base or Solana mainnet). + +Use it after make_http_request returned a 402 for an endpoint you have not used before, +and before retry_http_request_with_x402. It reads the endpoint's payment requirements and +checks the price against your budget and against what the service advertises, whether the +option is payable on your network (USDC, a valid payout address, a Solana payout account that +exists), HTTPS, the endpoint's payability track record, and whether it is listed in the CDP Bazaar. + +Returns a verdict: +- go: safe to pay the recommended option +- caution: payable, but tell the user the summary and pay only after they confirm +- no_go: do not pay (the payment would fail, is over budget, or should not be made) + +Inputs: +- url: the endpoint you are about to pay +- method: GET or POST (optional, default GET) +- maxUsd: your budget per call in USD (optional)`, + schema: PreflightX402EndpointSchema, + }) + async preflightX402Endpoint( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + const network = PAYMENT_NETWORKS[walletProvider.getNetwork().networkId ?? ""]; + if (!network) { + return JSON.stringify( + { + error: true, + message: "Unsupported network", + details: "The x402 Doctor preflight is paid in USDC on Base mainnet or Solana mainnet.", + }, + null, + 2, + ); + } + if ( + !(walletProvider instanceof EvmWalletProvider || walletProvider instanceof SvmWalletProvider) + ) { + return JSON.stringify( + { + error: true, + message: "Unsupported wallet provider", + details: "Only EvmWalletProvider and SvmWalletProvider are supported", + }, + null, + 2, + ); + } + + const method = args.method ?? "GET"; + const query = new URLSearchParams({ url: args.url, method, network }); + if (args.maxUsd !== null && args.maxUsd !== undefined) + query.set("max_usd", String(args.maxUsd)); + + try { + const client = await this.createClient(walletProvider, network); + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + const response = await fetchWithPayment(`${this.doctorUrl}/api/v1/preflight?${query}`, { + headers: { accept: "application/json" }, + }); + const data = await response.json().catch(() => null); + + if (!response.ok || !data?.verdict) { + return JSON.stringify( + { + error: true, + message: `Preflight failed with status ${response.status}`, + details: data, + advice: "No verdict: do not pay the endpoint without one.", + }, + null, + 2, + ); + } + + return JSON.stringify( + { + success: true, + url: args.url, + method, + verdict: data.verdict, + summary: data.summary, + advice: ADVICE[data.verdict] ?? ADVICE.no_go, + recommendedOption: + data.recommended_option !== null && data.recommended_option !== undefined + ? (data.options?.[data.recommended_option] ?? null) + : null, + reasons: data.reasons ?? [], + signals: data.signals ?? {}, + paymentProof: this.paymentProof(response), + }, + null, + 2, + ); + } catch (error) { + return JSON.stringify( + { + error: true, + message: "Preflight request failed", + details: error instanceof Error ? error.message : String(error), + advice: "No verdict: do not pay the endpoint without one.", + }, + null, + 2, + ); + } + } + + /** + * The preflight can be paid on Base mainnet and Solana mainnet. + * + * @param network - The network to check + * @returns True when the preflight can be paid on this network + */ + supportsNetwork = (network: Network): boolean => + Boolean(network.networkId && PAYMENT_NETWORKS[network.networkId]); + + /** + * An x402 client that pays only on the wallet's network and never more than $0.002. + * + * @param walletProvider - The wallet that signs the payment + * @param network - The CAIP-2 network to pay on + * @returns A configured x402Client + */ + private async createClient( + walletProvider: EvmWalletProvider | SvmWalletProvider, + network: string, + ): Promise { + const client = new x402Client().registerPolicy((_version, requirements) => + requirements.filter(r => { + const amount = (r as { amount?: string }).amount; + return ( + r.network === network && amount !== undefined && BigInt(amount) <= MAX_PREFLIGHT_ATOMIC + ); + }), + ); + + if (walletProvider instanceof EvmWalletProvider) { + const account = walletProvider.toSigner(); + const signer = { + ...account, + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + walletProvider.readContract({ + address: args.address, + abi: args.abi as never, + functionName: args.functionName as never, + args: args.args as never, + }), + }; + registerExactEvmScheme(client, { signer }); + } else { + registerExactSvmScheme(client, { signer: await walletProvider.toSigner() }); + } + return client; + } + + /** + * The settlement receipt of a paid response. + * + * @param response - The paid response + * @returns The decoded PAYMENT-RESPONSE header, or null + */ + private paymentProof(response: Response): Record | null { + const header = + response.headers.get("payment-response") ?? response.headers.get("x-payment-response"); + if (!header) return null; + try { + return JSON.parse(atob(header)); + } catch { + return { raw: header }; + } + } +} + +/** + * Factory function to create a new X402DoctorActionProvider. + * + * @param config - Optional configuration + * @returns A new X402DoctorActionProvider + */ +export const x402DoctorActionProvider = (config?: X402DoctorConfig) => + new X402DoctorActionProvider(config);