UNPKG

@coinbase/cdp-sdk

Version:

SDK for interacting with the Coinbase Developer Platform Wallet API

1,215 lines (1,132 loc) • 45.6 kB
/** * CDP-powered x402 resource server. * * `createX402Server()` is the primary entry point. It accepts a route * configuration (and optional receiver wallet config), provisions EVM and * Solana receiver wallets automatically, and returns a fully initialized * `X402Server`. * * `X402Server` **extends** `x402HTTPResourceServer` — it is a drop-in * replacement and can be passed anywhere an `x402HTTPResourceServer` is * expected (e.g. `paymentMiddlewareFromHTTPServer`, Hono / Next.js adapters). * * Routes may be supplied in either format: * * **Simplified CDP format** (`CdpRouteConfig`) — just `price` and optional * `description` / `networks` / `paymentFlow`. The server fills in `scheme`, * `payTo`, and all x402 internals automatically. `paymentFlow` is exact-scheme * only (`"authorization"` default, or `"upfront"`) and is applied to every * network the route expands to (Base and Solana when `networks` is omitted). * * **Full x402 format** (`RouteConfig`) — the same `accepts` / `description` * shape accepted by `x402HTTPResourceServer`. Vacant `payTo` fields (`""`) * are filled with the provisioned receiver address for that network family. * * Both formats can be mixed within the same `routes` map. * * The `payToConfig` field controls how the receiver wallet address is resolved: * - `{ type: "eoa" }` (default) — provisions a CDP EVM EOA + Solana account * - `{ type: "smart", ownerAccountName }` — provisions a CDP Smart Contract Wallet * - `{ type: "address", evm, solana }` — uses provided addresses directly, no provisioning * * @example Minimal CDP Dev setup: * ```typescript * import { createX402Server } from "@coinbase/cdp-sdk/x402"; * import { paymentMiddlewareFromHTTPServer } from "@x402/express"; * * // Set: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET * const server = await createX402Server({ * routes: { * "GET /report": { price: "$0.01", description: "AI-generated report" }, * }, * }); * app.use(paymentMiddlewareFromHTTPServer(server)); * console.log("EVM receiver:", server.payToEvmAddress); * console.log("Solana receiver:", server.payToSvmAddress); * ``` * * @example Bring your own addresses (skip wallet provisioning): * ```typescript * const server = await createX402Server({ * routes: { "GET /report": { price: "$0.01" } }, * payToConfig: { type: "address", evm: "0x...", solana: "..." }, * }); * ``` * * @example Full x402 format (interop with x402HTTPResourceServer): * ```typescript * const server = await createX402Server({ * routes: { * "GET /report": { * accepts: [ * { scheme: "exact", price: "$0.01", network: "eip155:8453", payTo: "" }, * { scheme: "exact", price: "$0.01", network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", payTo: "" }, * ], * }, * }, * }); * ``` */ import { readFile } from "node:fs/promises"; import { x402ResourceServer, x402HTTPResourceServer } from "@x402/core/server"; import { assertBuilderCode, declareServerBuilderCodeExtension } from "./builder-code.js"; import { baseMainnetCaip2, baseSepoliaCaip2, solanaMainnetCaip2, solanaDevnetCaip2, } from "./constants.js"; import { createCdpFacilitatorClient } from "./facilitator.js"; import { getCdpDefaultSchemes, getCdpExtensionRegistrations, CDP_SUPPORTED_EXTENSIONS, CDP_EXTENSION_BAZAAR, CDP_EXTENSION_BUILDER_CODE, buildBazaarDeclaration, } from "./server-extensions.js"; import { findSmartAccountByOwner, isOwnerAlreadyHasSmartWalletError } from "./smart-account.js"; import { CdpClient } from "../client/cdp.js"; import type { RoutesConfig, RouteConfig } from "@x402/core/server"; import type { Network } from "@x402/core/types"; import type { BuilderCodeRequiredExtension } from "@x402/extensions/builder-code"; import type { Address } from "viem"; /* * --------------------------------------------------------------------------- * Default networks * --------------------------------------------------------------------------- */ const DEFAULT_SERVER_ACCOUNT_NAME = "x402-receiver-wallet-1"; /** * Default EVM networks (production) — Base mainnet (`eip155:8453`). */ export const CDP_SERVER_DEFAULT_EVM_NETWORKS = [baseMainnetCaip2] as const; /** * Default Solana networks (production) — Solana mainnet. */ export const CDP_SERVER_DEFAULT_SVM_NETWORKS = [solanaMainnetCaip2] as const; /** * Default networks for production: Base mainnet + Solana mainnet. */ export const CDP_SERVER_DEFAULT_NETWORKS: readonly string[] = [ ...CDP_SERVER_DEFAULT_EVM_NETWORKS, ...CDP_SERVER_DEFAULT_SVM_NETWORKS, ]; /** * Default EVM networks for development — Base Sepolia (`eip155:84532`). */ export const CDP_SERVER_DEVELOPMENT_EVM_NETWORKS = [baseSepoliaCaip2] as const; /** * Default Solana networks for development — Solana Devnet. */ export const CDP_SERVER_DEVELOPMENT_SVM_NETWORKS = [solanaDevnetCaip2] as const; /** * Default networks for development: Base Sepolia + Solana Devnet. */ export const CDP_SERVER_DEVELOPMENT_NETWORKS: readonly string[] = [ ...CDP_SERVER_DEVELOPMENT_EVM_NETWORKS, ...CDP_SERVER_DEVELOPMENT_SVM_NETWORKS, ]; /* * --------------------------------------------------------------------------- * Types * --------------------------------------------------------------------------- */ /** * Payment scheme identifiers supported by the simplified CDP route format. * * - `"exact"` — (default) Transfer a fixed amount, locked at signing time. * - `"upto"` — Usage-based billing: the client authorizes a maximum amount * and the server settles the actual amount charged (≤ max), via Permit2 on * EVM and a payment channel on Solana. * * Both support EVM and Solana networks. Solana `upto` delegates voucher signing * to the facilitator. */ export type CdpPaymentScheme = "exact" | "upto"; /** * Payment flows supported on the simplified CDP route format. * * The shorthand is exact-scheme only: * - `"authorization"` — (default) verify, run the handler, then settle. * - `"upfront"` — settle on-chain before the handler runs. */ export type CdpPaymentFlow = "authorization" | "upfront"; const CDP_EXACT_PAYMENT_FLOWS: ReadonlySet<string> = new Set(["authorization", "upfront"]); /** * Simplified CDP-owned route configuration. * * Specifying just `price` (and optionally `description` / `networks` / * `paymentFlow`) is enough for most routes. `createX402Server` automatically * expands this into the full x402 `RouteConfig` format with `scheme`, `payTo`, * and `maxTimeoutSeconds` filled in. * * For routes that need fine-grained control (custom scheme, `extra`, explicit * `payTo`, etc.) pass a full x402 `RouteConfig` instead — both formats are * accepted in the same `routes` map. */ export interface CdpRouteConfig { /** * Payment amount required to access this route, e.g. `"$0.01"`. * Accepts any amount string supported by the x402 protocol. */ price: string; /** Human-readable description of what this route provides. */ description?: string; /** * Payment scheme to use for this route. * * Defaults to `"exact"`. Both `"exact"` and `"upto"` support Base and * Solana out of the box — see {@link CdpPaymentScheme}. */ scheme?: CdpPaymentScheme; /** * Payment flow for this route. Exact-scheme only. * * Defaults to `"authorization"` (the x402 protocol default) when omitted. * `"upfront"` settles on-chain before the route handler runs, and is * stamped onto every payment option this route expands to — including the * default Base and Solana networks when `networks` is omitted. * * Throws if set on a non-`"exact"` scheme. For other schemes, pass a * full x402 route with `accepts[].extra.paymentFlow`. */ paymentFlow?: CdpPaymentFlow; /** * CAIP-2 network identifiers for which the route accepts payments. * Defaults to `CDP_SERVER_DEFAULT_NETWORKS` (Base mainnet + Solana mainnet), * or `CDP_SERVER_DEVELOPMENT_NETWORKS` (Base Sepolia + Solana Devnet) when * `environment` is `"development"`. */ networks?: string[]; /** * Maximum seconds a payment token is valid before expiry. * Defaults to `300` (5 minutes). */ maxTimeoutSeconds?: number; /** * Extension overrides for this route. * * Gas-sponsoring, `bazaar`, and `builder-code` are injected automatically on * EVM routes (`builder-code` always carries the SDK's own service code, plus * the server config's `builderCode` app code when set). Use this field to * override the auto-generated Bazaar or builder-code declaration. */ extensions?: Record<string, unknown>; } /** * Receiver wallet configuration for `createX402Server`. * * Controls how receiver wallet addresses are resolved: * - `"eoa"` — provision a CDP EVM EOA + Solana account (default) * - `"smart"` — provision a CDP Smart Contract Wallet + Solana account * - `"address"` — use provided addresses directly, no provisioning needed */ export type PayToConfig = | { /** CDP Server Wallet (EOA). Default when `type` is omitted. */ type: "eoa"; /** * Named CDP account to use as the receiver wallet. * Defaults to `"x402-receiver-wallet-1"`. */ accountName?: string; } | { /** CDP Smart Contract Wallet. */ type: "smart"; /** * Named CDP smart account. Defaults to `"x402-receiver-wallet-1"`. */ accountName?: string; /** * Owner EOA account name. Required for `"smart"` type. */ ownerAccountName: string; } | { /** * Use provided addresses directly. * No wallet provisioning is performed — `CDP_WALLET_SECRET` is not required. * API key credentials (`CDP_API_KEY_ID` / `CDP_API_KEY_SECRET`) are still * needed for the CDP facilitator. * At least one of `evm` or `solana` should be provided, or all routes * must supply explicit `payTo` values in the full x402 `RouteConfig` format. */ type: "address"; /** EVM address to receive payments. */ evm?: Address; /** Solana address to receive payments. */ solana?: string; }; /** * Configuration for `createX402Server()`. * * All credential fields fall back to environment variables, so an empty * object `{}` with a `routes` map is sufficient in most environments. * * Pass `configPath` to load routes (and optionally credentials) from a JSON * file instead of specifying them inline. */ export interface CdpX402ServerConfig { /** * CDP API key ID. * Falls back to `CDP_API_KEY_ID` env var. */ apiKeyId?: string; /** * CDP API key secret. * Falls back to `CDP_API_KEY_SECRET` env var. */ apiKeySecret?: string; /** * CDP wallet secret used to provision the receiver wallet. * Falls back to `CDP_WALLET_SECRET` env var. * Not required when `payToConfig.type` is `"address"`. */ walletSecret?: string; /** * Deployment environment. Controls which networks are used by default. * * - `"production"` (default) — Base mainnet + Solana mainnet. * - `"development"` — Base Sepolia + Solana Devnet. * * Falls back to `CDP_X402_SERVER_ENVIRONMENT` env var. */ environment?: "production" | "development"; /** * Receiver wallet / payTo configuration. * * Defaults to `{ type: "eoa" }` which provisions a CDP Server Wallet (EOA) * named `"x402-receiver-wallet-1"`. * * Use `{ type: "address", evm: "0x...", solana: "..." }` to provide your * own addresses without provisioning a CDP wallet. */ payToConfig?: PayToConfig; /** * Optional [builder code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) * for on-chain attribution (`a` / app code). * * Every route with an EVM (`eip155:*`) payment option always advertises the * `builder-code` extension with this SDK's own service code (`s`); Solana-only * routes are skipped because the attribution suffix is ERC-8021 EVM calldata. * Setting `builderCode` additionally declares your own app code (`a`) on * those routes. Must match `^[a-z0-9_]{1,32}$`. Override per-route via * `extensions["builder-code"]`. */ builderCode?: string; /** * Payment-protected routes served by this server. * * Map of HTTP method + path pattern → route config. * Keys use the `"METHOD /path"` convention, e.g. `"GET /report"`. * * Each value is either: * - A `CdpRouteConfig` — simplified format, just `price` + optional fields. * - A `RouteConfig` — full x402 format with an `accepts` array/object. * * Both formats can be mixed within the same map. * May be omitted when `configPath` supplies the routes. */ routes?: Record<string, CdpRouteConfig | RouteConfig>; /** * Path to a JSON file whose fields are merged with this inline config. * Inline config takes precedence over file config when both specify the * same field. The file mirrors {@link CdpX402ServerConfig} (minus `configPath`). * * @example * ```json * { * "routes": { * "GET /report": { "price": "$0.01", "description": "AI-generated report" } * } * } * ``` * * A full JSON Schema for the file lives at * `examples/typescript/x402/servers/express/x402.config.schema.json`. * * Security: this file may carry credentials (`apiKeySecret` / `walletSecret`). * Prefer environment variables for credentials and use the file for `routes`; * if secrets are stored here, keep the file out of version control. */ configPath?: string; } /* * --------------------------------------------------------------------------- * Internal wallet provisioning * --------------------------------------------------------------------------- */ interface ProvisionedAddresses { evmAddress: Address | ""; svmAddress: string; ownerWallet?: string; } /** Network families referenced by a route set, controlling which wallets to provision. */ interface NetworkFamilies { evm: boolean; svm: boolean; } /** * Resolves server-scoped CDP credentials and environment, falling back from * explicit config to `CDP_*` env vars * * @param config - Optional explicit credential overrides. * @returns Resolved credential strings and environment (any credential may be `undefined` if not found). */ function resolveServerCredentials( config: Pick<CdpX402ServerConfig, "apiKeyId" | "apiKeySecret" | "walletSecret" | "environment">, ): { apiKeyId: string | undefined; apiKeySecret: string | undefined; walletSecret: string | undefined; environment: "production" | "development"; } { const rawEnv = config.environment ?? (process.env.CDP_X402_SERVER_ENVIRONMENT as "production" | "development" | undefined); const environment: "production" | "development" = rawEnv === "development" ? "development" : "production"; return { apiKeyId: config.apiKeyId ?? process.env.CDP_API_KEY_ID, apiKeySecret: config.apiKeySecret ?? process.env.CDP_API_KEY_SECRET, walletSecret: config.walletSecret ?? process.env.CDP_WALLET_SECRET, environment, }; } /** * Provisions CDP EVM and Solana receiver accounts for use as `payTo` addresses. * * @param credentials - CDP API credentials for wallet operations. * @param credentials.apiKeyId - CDP API key ID. * @param credentials.apiKeySecret - CDP API key secret. * @param credentials.walletSecret - CDP wallet secret for signing. * @param payToConfig - Resolved wallet type (`"eoa"` or `"smart"`) and account names. * @param need - Which network families the routes actually reference. Only the * referenced families are provisioned, so an EVM-only server never creates a * Solana account (and vice versa). * @returns Provisioned EVM address, Solana address, and optional owner wallet name. */ async function provisionServerAccounts( credentials: { apiKeyId: string; apiKeySecret: string; walletSecret: string }, payToConfig: Exclude<PayToConfig, { type: "address" }>, need: NetworkFamilies, ): Promise<ProvisionedAddresses> { const cdpClient = new CdpClient({ apiKeyId: credentials.apiKeyId, apiKeySecret: credentials.apiKeySecret, walletSecret: credentials.walletSecret, }); const accountName = payToConfig.accountName ?? DEFAULT_SERVER_ACCOUNT_NAME; const svmAddress = need.svm ? (await cdpClient.solana.getOrCreateAccount({ name: accountName })).address : ""; if (!need.evm) { return { evmAddress: "", svmAddress }; } if (payToConfig.type === "smart") { const ownerAccount = await cdpClient.evm.getOrCreateAccount({ name: payToConfig.ownerAccountName, }); let smartAccount; try { smartAccount = await cdpClient.evm.getOrCreateSmartAccount({ name: accountName, owner: ownerAccount, }); } catch (error) { if (isOwnerAlreadyHasSmartWalletError(error)) { const existingAddress = await findSmartAccountByOwner(cdpClient, ownerAccount.address); if (!existingAddress) throw error; smartAccount = await cdpClient.evm.getSmartAccount({ address: existingAddress as Address, owner: ownerAccount, }); } else { throw error; } } return { evmAddress: smartAccount.address as Address, svmAddress, ownerWallet: payToConfig.ownerAccountName, }; } const evmAccount = await cdpClient.evm.getOrCreateAccount({ name: accountName }); return { evmAddress: evmAccount.address as Address, svmAddress, }; } /* * --------------------------------------------------------------------------- * Route resolution helpers * --------------------------------------------------------------------------- */ /** * Classifies a CAIP-2 network identifier into a receiver-wallet family. * * @param network - CAIP-2 network identifier, e.g. `"eip155:8453"`. * @returns `"evm"` for `eip155:*`, `"svm"` for `solana:*`, otherwise `"other"`. */ function networkFamily(network: string): "evm" | "svm" | "other" { if (network.startsWith("eip155:")) return "evm"; if (network.startsWith("solana:")) return "svm"; return "other"; } /** * Returns the default networks for a simplified route that omits `networks`. * Both supported schemes (`"exact"` and `"upto"`) cover Base + Solana. * * @param environment - Deployment environment controlling mainnet vs testnet defaults. * @returns The default CAIP-2 networks. */ function getDefaultNetworks(environment: "production" | "development"): readonly string[] { return environment === "development" ? CDP_SERVER_DEVELOPMENT_NETWORKS : CDP_SERVER_DEFAULT_NETWORKS; } /** * Determines which network families a route references. * * @param route - Simplified or full x402 route config. * @param environment - Deployment environment controlling default network selection. * @returns The network families referenced by the route. */ function routeNetworkFamilies( route: CdpRouteConfig | RouteConfig, environment: "production" | "development", ): NetworkFamilies { let networks: string[]; if ("accepts" in route) { const accepts = Array.isArray(route.accepts) ? route.accepts : [route.accepts]; networks = accepts.map(option => option.network as string); } else { networks = [...(route.networks ?? getDefaultNetworks(environment))]; } return { evm: networks.some(network => networkFamily(network) === "evm"), svm: networks.some(network => networkFamily(network) === "svm"), }; } /** * Aggregates the network families referenced across all routes. * * @param routes - Map of route patterns to simplified or full x402 route configs. * @param environment - Deployment environment controlling default network selection. * @returns The union of network families referenced by the route set. */ function requiredNetworkFamilies( routes: Record<string, CdpRouteConfig | RouteConfig>, environment: "production" | "development", ): NetworkFamilies { const result: NetworkFamilies = { evm: false, svm: false }; for (const route of Object.values(routes)) { const families = routeNetworkFamilies(route, environment); result.evm ||= families.evm; result.svm ||= families.svm; } return result; } /** * Throws when `paymentFlow` is set on a simplified route that cannot use it: * unknown flow names, or any scheme other than `"exact"`. * * @param route - Simplified CDP route config being converted. * @param scheme - Resolved scheme for the route (`"exact"` or `"upto"`). */ function assertPaymentFlowCompatible(route: CdpRouteConfig, scheme: CdpPaymentScheme): void { if (route.paymentFlow === undefined) return; if (!CDP_EXACT_PAYMENT_FLOWS.has(route.paymentFlow)) { throw new Error( `Unsupported paymentFlow "${String(route.paymentFlow)}". ` + `The simplified route format only supports "authorization" (default) and "upfront".`, ); } if (scheme !== "exact") { throw new Error( `paymentFlow is only supported on the "exact" scheme. ` + `Route uses scheme "${scheme}". Omit paymentFlow, or pass a full x402 route ` + `with accepts[].extra.paymentFlow to configure other schemes.`, ); } } /** * Simplified-route scheme identifiers accepted by {@link validateRouteSemantics}. * Kept separate from the `CdpPaymentScheme` type check since config files are * untyped JSON cast to `CdpX402ServerConfig` — a typo or unsupported scheme * (e.g. `"auth-capture"`) has no compile-time guard and must be rejected here. */ const CDP_SIMPLIFIED_ROUTE_SCHEMES: ReadonlySet<string> = new Set<CdpPaymentScheme>([ "exact", "upto", ]); /** * Throws when a simplified route's `scheme` isn't one of the values the * simplified route format actually supports. * * @param scheme - The raw `scheme` value from a simplified route config. * @returns The validated scheme, narrowed to {@link CdpPaymentScheme}. */ function assertValidSimplifiedScheme(scheme: unknown): CdpPaymentScheme { if (typeof scheme !== "string" || !CDP_SIMPLIFIED_ROUTE_SCHEMES.has(scheme)) { throw new Error( `Unsupported simplified route scheme "${String(scheme)}". ` + `Supported values: ${[...CDP_SIMPLIFIED_ROUTE_SCHEMES].map(s => `"${s}"`).join(", ")}.`, ); } return scheme as CdpPaymentScheme; } /** * Validates every route's scheme/network and `paymentFlow` compatibility up * front, before any CDP or facilitator I/O. Without this, an invalid route * (e.g. `{ scheme: "upto", paymentFlow: "upfront" }`, an unsupported/typo'd * scheme such as `"auth-capture"`, an empty `networks` list, or a non-EVM/ * non-Solana network) would only be caught during `resolveRoutes` — or not * at all until facilitator init — by which point `provisionServerAccounts` * has already created real receiver wallets for the (wrongly) inferred * network families. * * @param routes - Map of route patterns to simplified or full x402 route configs. * @param environment - Deployment environment controlling default network selection. */ function validateRouteSemantics( routes: Record<string, CdpRouteConfig | RouteConfig>, environment: "production" | "development", ): void { for (const route of Object.values(routes)) { if ("accepts" in route) continue; const scheme = assertValidSimplifiedScheme(route.scheme ?? "exact"); assertPaymentFlowCompatible(route, scheme); const networks = route.networks ?? getDefaultNetworks(environment); if (networks.length === 0) { throw new Error("Simplified route must include at least one network."); } for (const network of networks) { if (networkFamily(network) === "other") { throw new Error( `Unsupported network family for simplified route: "${network}". ` + `Simplified routes only support "eip155:*" (EVM) and "solana:*" networks.`, ); } } } } /** * Throws when the resolved `payTo` address for a network is empty. This can * happen when `payToConfig: { type: "address" }` is used without providing * an address for the given network family. * * @param payTo - The resolved address (may be empty string). * @param network - The CAIP-2 network identifier for the route option. */ function assertNonEmptyPayTo(payTo: string, network: string): void { if (payTo === "") { const family = network.startsWith("eip155:") ? "EVM (`evm`)" : network.startsWith("solana:") ? "Solana (`solana`)" : `"${network}"`; throw new Error( `No receiver address for ${family} network "${network}". ` + `Provide a receiver address via payToConfig (e.g. { type: "address", evm: "0x...", solana: "..." }) ` + `or use payToConfig: { type: "eoa" } to auto-provision a CDP wallet.`, ); } } /** * Fills vacant `payTo` fields (`""`) in a full x402 `RouteConfig` with the * provisioned receiver addresses for each network family. Returns a new object — * the original is not mutated. Non-vacant `payTo` values are left untouched. * * @param route - The x402 `RouteConfig` whose vacant `payTo` fields will be filled. * @param evmAddress - EVM receiver address for `eip155:*` payment options. * @param svmAddress - Solana receiver address for `solana:*` payment options. * @returns A new `RouteConfig` with all blank `payTo` fields resolved. */ function fillX402RoutePayTo( route: RouteConfig, evmAddress: Address | "", svmAddress: string, ): RouteConfig { const accepts = Array.isArray(route.accepts) ? route.accepts : [route.accepts]; const filled = accepts.map(option => { const network = option.network as string; if (typeof option.payTo !== "string" || option.payTo.trim() !== "") { return option; } if (network.startsWith("eip155:")) { assertNonEmptyPayTo(evmAddress, network); return { ...option, payTo: evmAddress }; } if (network.startsWith("solana:")) { assertNonEmptyPayTo(svmAddress, network); return { ...option, payTo: svmAddress }; } throw new Error( `Cannot fill vacant payTo for network "${network}": unrecognised network family. ` + `Provide an explicit payTo address for this route option.`, ); }); return { ...route, accepts: Array.isArray(route.accepts) ? filled : filled[0]!, }; } /** * Converts a {@link CdpRouteConfig} into a full x402 `RouteConfig`, wiring * the provisioned receiver addresses into each payment option's `payTo`. * * @param route - Simplified CDP route config with `price` and optional fields. * @param evmAddress - EVM receiver address for `eip155:*` networks (`""` when none). * @param svmAddress - Solana receiver address for `solana:*` networks (`""` when none). * @param environment - Deployment environment controlling default network selection. * @param available - Which receiver families have an address. When the route * relies on default networks, families without a receiver address are dropped * so e.g. an EVM-only `payToConfig` works without listing `networks` explicitly. * @returns A full x402 `RouteConfig` with `accepts`, `payTo`, and `scheme` resolved. */ function convertCdpRoute( route: CdpRouteConfig, evmAddress: Address | "", svmAddress: string, environment: "production" | "development", available: NetworkFamilies, ): RouteConfig { const scheme = route.scheme ?? "exact"; assertPaymentFlowCompatible(route, scheme); const usingDefaultNetworks = route.networks === undefined; const defaultNetworks = getDefaultNetworks(environment); /* * For default networks, drop families that have no receiver address so a * partial payToConfig (e.g. EVM-only) doesn't fail on an unused default * network. Explicit `networks` are always honored (and validated) as-is. */ const networks = usingDefaultNetworks ? defaultNetworks.filter(network => { const family = networkFamily(network); if (family === "evm") return available.evm; if (family === "svm") return available.svm; return true; }) : route.networks!; if (networks.length === 0) { throw new Error( `No receiver address available for any default network of scheme "${scheme}". ` + `Provide a receiver address via payToConfig (e.g. { type: "address", evm: "0x...", solana: "..." }), ` + `set explicit \`networks\` on the route, or use payToConfig: { type: "eoa" } to auto-provision a CDP wallet.`, ); } const maxTimeoutSeconds = route.maxTimeoutSeconds ?? 300; const accepts = networks.map(network => { const payTo = network.startsWith("eip155:") ? (evmAddress as string) : network.startsWith("solana:") ? (svmAddress as string) : (() => { throw new Error( `Cannot resolve payTo for network "${network}": unrecognised network family.`, ); })(); assertNonEmptyPayTo(payTo, network); return { scheme, price: route.price, network: network as `${string}:${string}`, payTo, maxTimeoutSeconds, ...(route.paymentFlow === "upfront" ? { extra: { paymentFlow: "upfront" as const } } : {}), }; }); return { accepts: accepts.length === 1 ? accepts[0]! : accepts, ...(route.description !== undefined && { description: route.description }), ...(route.extensions !== undefined && { extensions: route.extensions }), }; } /** * Parses an x402 route pattern key (`"GET /report"`) into its HTTP method and * path components for building a Bazaar discovery declaration. Returns `undefined` * when the pattern lacks a method prefix or uses a wildcard verb. * * @param pattern - Route pattern key, e.g. `"GET /report"` or `"POST /orders/:id"`. * @returns Parsed `{ method, path }` pair, or `undefined` when not parseable. */ function parseRouteKeyForBazaar(pattern: string): { method: string; path: string } | undefined { const spaceIdx = pattern.indexOf(" "); if (spaceIdx === -1) return undefined; const method = pattern.slice(0, spaceIdx).toUpperCase(); const path = pattern.slice(spaceIdx + 1); if (method === "*" || !path.startsWith("/")) return undefined; return { method, path }; } /** * Returns `true` when any payment option on a resolved route targets an EVM * (`eip155:*`) network. Used to gate EVM-specific extensions so a Solana-only * route doesn't advertise EVM gas-sponsoring keys. * * @param route - Resolved x402 `RouteConfig` to inspect. * @returns `true` if at least one accept option is on an EVM network. */ function routeHasEvmAccept(route: RouteConfig): boolean { const accepts = Array.isArray(route.accepts) ? route.accepts : [route.accepts]; return accepts.some(option => (option.network as string).startsWith("eip155:")); } /** * Merges CDP auto-injected extensions into a resolved route config. Gas-sponsoring * extensions and builder-code are added only to routes with an EVM (`eip155:*`) * payment option — gas sponsoring is meaningless for Solana-only routes, and * builder-code attribution is an ERC-8021 EVM calldata suffix. The Bazaar * declaration is built from the route pattern for all routes. User-provided * `route.extensions` always win. * * @param pattern - Route key (e.g. `"GET /report"`) used to derive Bazaar metadata. * @param route - Resolved x402 `RouteConfig` to augment with CDP extensions. * @param builderCode - Pre-validated builder-code declaration to advertise. `create()` * always supplies one (at minimum the SDK's own service code); `undefined` here only * occurs in tests that call this function directly. * @returns A new `RouteConfig` with CDP extensions merged in. */ function withAutoInjectedExtensions( pattern: string, route: RouteConfig, builderCode?: BuilderCodeRequiredExtension, ): RouteConfig { const bazaar = parseRouteKeyForBazaar(pattern); const hasEvmAccept = routeHasEvmAccept(route); return { ...route, extensions: { ...(hasEvmAccept && CDP_SUPPORTED_EXTENSIONS), ...(bazaar && { [CDP_EXTENSION_BAZAAR]: buildBazaarDeclaration(bazaar.method, bazaar.path) }), ...(builderCode && hasEvmAccept && { [CDP_EXTENSION_BUILDER_CODE]: builderCode }), ...route.extensions, }, }; } /** * Resolves a mixed `Record<string, CdpRouteConfig | RouteConfig>` into the * x402 `RoutesConfig` format. Simplified routes are expanded; full x402 * routes have vacant `payTo` fields filled. All CDP extensions are injected * into every route. * * @param routes - Map of route patterns to simplified or full x402 route configs. * @param evmAddress - EVM receiver address for `eip155:*` payment options (`""` when none). * @param svmAddress - Solana receiver address for `solana:*` payment options (`""` when none). * @param environment - Deployment environment controlling default network selection. * @param builderCode - Builder-code declaration injected on every EVM route. * @returns A fully resolved `RoutesConfig` ready to pass to an HTTP resource server. */ function resolveRoutes( routes: Record<string, CdpRouteConfig | RouteConfig>, evmAddress: Address | "", svmAddress: string, environment: "production" | "development", builderCode?: BuilderCodeRequiredExtension, ): RoutesConfig { const result: Record<string, RouteConfig> = {}; const available: NetworkFamilies = { evm: evmAddress !== "", svm: svmAddress !== "" }; for (const [pattern, route] of Object.entries(routes)) { const resolved = "accepts" in route ? fillX402RoutePayTo(route, evmAddress, svmAddress) : convertCdpRoute(route, evmAddress, svmAddress, environment, available); result[pattern] = withAutoInjectedExtensions(pattern, resolved, builderCode); } return result; } /** * Loads and parses a JSON config file, stripping `configPath` from the result * to prevent circular references when the field is present in the file itself. * * Security: this file may carry credentials (`apiKeySecret` / `walletSecret`). * Prefer supplying credentials via environment variables and reserving the * config file for `routes`; if secrets are stored here, keep the file out of * version control (e.g. add it to `.gitignore`). * * @param filePath - Absolute or relative path to the JSON config file. * @returns Parsed config with `configPath` removed. */ async function loadConfigFile(filePath: string): Promise<Omit<CdpX402ServerConfig, "configPath">> { const content = await readFile(filePath, "utf-8"); const parsed = JSON.parse(content) as CdpX402ServerConfig; const result: Omit<CdpX402ServerConfig, "configPath"> = { ...parsed }; delete (result as CdpX402ServerConfig).configPath; return result; } /* * --------------------------------------------------------------------------- * X402Server class * --------------------------------------------------------------------------- */ /** * A CDP-powered x402 resource server that **extends** `x402HTTPResourceServer`. * * It is a drop-in replacement anywhere an `x402HTTPResourceServer` is * expected — pass it directly to `paymentMiddlewareFromHTTPServer`, Hono / * Next.js adapters, or any other framework integration. * * Use `createX402Server()` (or `X402Server.create()`) to obtain an initialized * instance. The constructor is intentionally private; call the factory instead. * * In addition to the full `x402HTTPResourceServer` surface (`initialize()`, * `processHTTPRequest()`, `processSettlement()`, `requiresPayment()`, etc.) * this class exposes `payToEvmAddress` and `payToSvmAddress` for the * provisioned receiver wallets. */ export class X402Server extends x402HTTPResourceServer { /** * EVM address of the receiver wallet. `undefined` when no EVM wallet was * provisioned (no `eip155:*` route, and `payToConfig` did not supply an * `evm` address). */ readonly payToEvmAddress: Address | undefined; /** * Solana address of the provisioned receiver wallet. `undefined` when no * Solana wallet was provisioned (no `solana:*` route, and `payToConfig` did * not supply a `solana` address). */ readonly payToSvmAddress: string | undefined; /** * Owner account name for `"smart"` receiver wallets, otherwise `undefined`. * Only set when `payToConfig.type` is `"smart"`. */ readonly ownerWallet: string | undefined; private _initPromise: Promise<void> | null = null; /** * Private constructor — use `createX402Server()` or `X402Server.create()` instead. * * @param resourceServer - Initialized `x402ResourceServer` with schemes registered. * @param routes - Resolved `RoutesConfig` with `payTo` and extensions filled in. * @param payToEvmAddress - EVM address of the provisioned receiver wallet, * or `undefined` if none was provisioned. * @param payToSvmAddress - Solana address of the provisioned receiver wallet, * or `undefined` if none was provisioned. * @param ownerWallet - Owner account name for smart wallets, otherwise `undefined`. * @internal */ private constructor( resourceServer: x402ResourceServer, routes: RoutesConfig, payToEvmAddress: Address | undefined, payToSvmAddress: string | undefined, ownerWallet?: string, ) { super(resourceServer, routes); this.payToEvmAddress = payToEvmAddress; this.payToSvmAddress = payToSvmAddress; this.ownerWallet = ownerWallet; } /** * The underlying `x402ResourceServer` with EVM and Solana schemes registered. * * @returns The `x402ResourceServer` instance used by this server. */ get resourceServer(): x402ResourceServer { return this.server; } /** * Provisions CDP receiver wallets (unless `payToConfig.type` is `"address"`), * resolves routes, constructs the HTTP resource server, and syncs supported * schemes with the CDP facilitator. * * This is the async entry point for `X402Server`. Prefer the module-level * `createX402Server()` wrapper for convenience. * * @param config - Credential, wallet, and route configuration. * @returns A fully initialized `X402Server` instance ready to be passed to * any framework middleware. */ static async create(config: CdpX402ServerConfig): Promise<X402Server> { // Routes are deep-merged so file and inline routes both survive; inline wins on conflicts. let merged = config; if (config.configPath) { const fileConfig = await loadConfigFile(config.configPath); merged = { ...fileConfig, ...config, routes: { ...fileConfig.routes, ...config.routes }, }; } // Validate before any I/O so a bad config fails before wallet provisioning. const routes = merged.routes; if (!routes || Object.keys(routes).length === 0) { throw new Error("createX402Server requires at least one payment route."); } if (merged.builderCode !== undefined) { assertBuilderCode(merged.builderCode); } /* * Always declared (not gated on `builderCode`) so every EVM route attaches * this SDK's own service code to `s`, in addition to the developer's own * app code (`a`) when configured. */ const builderCodeDeclaration: BuilderCodeRequiredExtension = declareServerBuilderCodeExtension( merged.builderCode, ); const credentials = resolveServerCredentials(merged); const { environment } = credentials; validateRouteSemantics(routes, environment); const facilitatorClient = createCdpFacilitatorClient({ apiKeyId: credentials.apiKeyId, apiKeySecret: credentials.apiKeySecret, }); const resourceServer = new x402ResourceServer(facilitatorClient); for (const ext of getCdpExtensionRegistrations()) { resourceServer.registerExtension(ext); } const payToConfig = merged.payToConfig; let evmAddress: Address | ""; let svmAddress: string; let ownerWallet: string | undefined; if (payToConfig?.type === "address") { evmAddress = payToConfig.evm ?? ""; svmAddress = payToConfig.solana ?? ""; } else { const missing: string[] = []; if (!credentials.apiKeyId) missing.push("CDP_API_KEY_ID"); if (!credentials.apiKeySecret) missing.push("CDP_API_KEY_SECRET"); if (!credentials.walletSecret) missing.push("CDP_WALLET_SECRET"); if (missing.length > 0) { throw new Error( `Missing required CDP credentials: ${missing.join(", ")}. ` + "Provide them via config options or set the corresponding environment variables " + "(CDP_API_KEY_ID / CDP_API_KEY_SECRET / CDP_WALLET_SECRET). " + "Alternatively, pass payToConfig: { type: 'address', evm: '0x...', solana: '...' } to skip wallet provisioning.", ); } /* * Only provision the wallet families the routes actually reference, so an * EVM-only server never creates a Solana account (and vice versa). */ const need = requiredNetworkFamilies(routes, environment); const provisioned = await provisionServerAccounts( { apiKeyId: credentials.apiKeyId!, apiKeySecret: credentials.apiKeySecret!, walletSecret: credentials.walletSecret!, }, (payToConfig ?? { type: "eoa" }) as Exclude<PayToConfig, { type: "address" }>, need, ); evmAddress = provisioned.evmAddress; svmAddress = provisioned.svmAddress; ownerWallet = provisioned.ownerWallet; } for (const scheme of getCdpDefaultSchemes()) { resourceServer.register(scheme.network as Network, scheme.server); } const resolvedRoutes = resolveRoutes( routes, evmAddress, svmAddress, environment, builderCodeDeclaration, ); const instance = new X402Server( resourceServer, resolvedRoutes, evmAddress === "" ? undefined : evmAddress, svmAddress === "" ? undefined : svmAddress, ownerWallet, ); await instance.initialize(); return instance; } /** * Initializes the underlying resource server (facilitator support sync + * route validation) exactly once. * * `createX402Server()` already calls this before returning, and framework * adapters such as `paymentMiddlewareFromHTTPServer` call `initialize()` * again on startup. The base implementation re-fetches facilitator support * on every call, so this override memoizes the first run to avoid a redundant * facilitator round-trip. The promise is cleared on failure so a later call * can retry. * * @returns A promise that resolves once initialization has completed. */ override async initialize(): Promise<void> { if (!this._initPromise) { this._initPromise = super.initialize().catch(error => { this._initPromise = null; throw error; }); } return this._initPromise; } } /* * --------------------------------------------------------------------------- * Public factory * --------------------------------------------------------------------------- */ /** * Creates and initializes a CDP-powered x402 resource server. * * Returns an `X402Server` which **extends** `x402HTTPResourceServer` and can * be passed directly to `paymentMiddlewareFromHTTPServer` or any other * framework adapter. * * All credential fields fall back to environment variables; an empty `{}` * with a `routes` map is sufficient in most environments. * * Pass `payToConfig: { type: "address", evm: "0x...", solana: "..." }` to * provide your own receiver addresses without provisioning a CDP wallet. * * @param config - Credential, wallet, and route configuration. All credential fields * fall back to environment variables; `routes` is the only required field (or * `configPath` pointing to a JSON file that supplies routes). * @returns A fully initialized `X402Server` ready to be passed to any framework middleware. * @example Minimal setup (auto-provisions receiver wallet): * ```typescript * // Set: CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET * const server = await createX402Server({ * routes: { * "GET /report": { price: "$0.01", description: "AI-generated report" }, * }, * }); * app.use(paymentMiddlewareFromHTTPServer(server)); * console.log("EVM receiver:", server.payToEvmAddress); * console.log("Solana receiver:", server.payToSvmAddress); * ``` * * @example Bring your own addresses: * ```typescript * const server = await createX402Server({ * routes: { "GET /report": { price: "$0.01" } }, * payToConfig: { type: "address", evm: "0x1234...", solana: "ABC..." }, * }); * ``` * * @example Exact + upfront payment flow (shorthand; applies to Base and Solana): * ```typescript * const server = await createX402Server({ * routes: { * "GET /report": { price: "$0.01", paymentFlow: "upfront" }, * }, * }); * ``` * * @example Full x402 RouteConfig format with vacant payTo: * ```typescript * const server = await createX402Server({ * routes: { * "GET /report": { * accepts: { scheme: "exact", price: "$0.01", network: "eip155:8453", payTo: "" }, * }, * }, * }); * ``` * * @example Config-file variant: * ```typescript * // x402.config.json: { "routes": { "GET /report": { "price": "$0.01" } } } * const server = await createX402Server({ configPath: "./x402.config.json" }); * ``` */ export async function createX402Server(config: CdpX402ServerConfig): Promise<X402Server> { return X402Server.create(config); } export type { RoutesConfig, RouteConfig } from "@x402/core/server";