UNPKG

@coinbase/cdp-sdk

Version:

SDK for interacting with the Coinbase Developer Platform Wallet API

609 lines (559 loc) • 23.4 kB
/* * CDP-powered x402 payment client. * * CdpX402Client is a drop-in extension of x402Client that auto-provisions * CDP-managed wallets (EVM EOA or Smart Contract Wallet + Solana), registers * payment schemes and wires spend controls. * Credentials fall back to environment variables; wallet configuration and * RPC URLs are supplied explicitly via config. */ import { x402Client } from "@x402/core/client"; import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; import { BuilderCodeClientExtension } from "@x402/extensions/builder-code"; import { ExactSvmScheme } from "@x402/svm/exact/client"; import { ExactSvmSchemeV1 } from "@x402/svm/exact/v1/client"; import { UptoSvmScheme } from "@x402/svm/upto/client"; import { cdpSolanaAccountToSvmSigner, fromCdpEvmAccount, fromCdpSmartWallet, } from "./account-signers.js"; import { CDP_SDK_CLIENT_BUILDER_CODE, toServiceBuilderCodes } from "./builder-code.js"; import { baseMainnetCaip2, baseSepoliaCaip2, getDefaultEvmRpcUrls } from "./constants.js"; import { CdpClient } from "../client/cdp.js"; import { applySpendControls } from "./guardrails/apply.js"; import { normalizeNetwork } from "./guardrails/normalize.js"; import { findSmartAccountByOwner, isOwnerAlreadyHasSmartWalletError } from "./smart-account.js"; import type { SpendControls } from "./guardrails/types.js"; import type { Network, PaymentPayload, PaymentRequired } from "@x402/core/types"; import type { Address } from "viem"; /** Wallet type for CDP Server Wallet (EOA) or Smart Contract Wallet. */ type WalletType = "eoa" | "smart"; type SupportedNetwork = | "base" | "base-sepolia" | "polygon" | "arbitrum" | "world" | "world-sepolia" | "solana" | "solana-devnet"; /** * Wallet configuration for the CDP x402 client. */ export type WalletConfig = | { /** CDP Server Wallet (EOA). Default when `type` is omitted. */ type: "eoa"; /** * Named CDP account. Defaults to `"x402-client-wallet-1"`. */ accountName?: string; } | { /** CDP Smart Contract Wallet. */ type: "smart"; /** * Named CDP smart account. Defaults to `"x402-client-wallet-1"`. */ accountName?: string; /** * Owner EOA account name. Required for `"smart"` type. */ ownerAccountName: string; }; export type SchemesConfig = { /** Registers the `exact` scheme: transfers a fixed amount. On by default. */ exact?: boolean; /** * Registers the `upto` scheme: authorizes a maximum amount and lets the * resource server settle the actual amount used. On by default — except on * EVM for a `"smart"` {@link WalletConfig}, since `upto`'s Permit2 approval * transaction can't be sponsored for Smart Contract Wallets. */ upto?: boolean; /** * Registers the `batch-settlement` scheme (EVM only): reuses an off-chain * voucher channel across multiple payments. Opt-in. */ batchSettlement?: boolean; /** * Registers the `auth-capture` scheme (EVM only): the client signs a * payer-agnostic authorization that the resource server's captureAuthorizer * can later capture, void, or let expire. On by default — except on EVM * for a `"smart"` {@link WalletConfig}, since the ERC-1271/ERC-6492-wrapped * signature a Smart Contract Wallet produces isn't supported by the * `auth-capture` settlement path yet. */ authCapture?: boolean; }; export type NetworkConfig = { network: SupportedNetwork; rpcUrl?: string; scheme: SchemesConfig; }; /** * Configuration for {@link CdpX402Client}. */ export interface CdpX402ClientConfig { /** 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. Falls back to `CDP_WALLET_SECRET` env var. */ walletSecret?: string; /** * Wallet configuration. Defaults to `{ type: "eoa" }`. */ walletConfig?: WalletConfig; /** * Optional SDK-managed spend controls. Unset by default — unlike upstream * `x402Client` (which defaults to a $1 per-payment cap), `CdpX402Client` * applies no cap unless you set one here. The constructor emits a * `console.warn` when this is unset. */ spendControls?: SpendControls; /** * Optional [builder code](https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md) * for on-chain attribution (`s` / service codes). Pass an array to attribute * several participants, e.g. a client layered behind middleware. * * Every `CdpX402Client` always attaches its own `cdp_sdk_client` service * code to `s` alongside any codes configured here, regardless of whether * this option is set. Up to four codes may be configured, because the x402 * extension allows five service codes total. * * Each code must match `^[a-z0-9_]{1,32}$`; invalid codes and an empty array * are rejected by the constructor. A `builder-code` extension registered * manually via `registerExtension` replaces the one built from this option. */ builderCode?: string | string[]; /** * Deployment environment. Controls which Base network is prescribed by default. * * - `"production"` (default) — Base mainnet. * - `"development"` — Base Sepolia. * * Falls back to the `CDP_X402_CLIENT_ENVIRONMENT` env var. */ environment?: "production" | "development"; /** * Optional SDK-managed additional network schemes configuration. * * The Base network matching `environment` is always prescribed as a * baseline, regardless of this option, since CDP hosts a default RPC for it: * * Defaults to [ * { network: "base", scheme: { exact: true, upto: true, authCapture: true } }, * ] * (or `"base-sepolia"` instead of `"base"` when `environment` is `"development"`) * * Use `networkSchemes` to add other networks (e.g. `"solana"`, `"polygon"`, * or the Base network for the *other* environment), or to override the * scheme for the prescribed Base network (e.g. to disable a scheme). An * override for `"base"` or `"base-sepolia"` that omits `rpcUrl` still gets * the CDP-hosted default RPC injected for it. * * Solana has no CDP-hosted default RPC and no override is required for * `exact` or `upto` — both fall back to a public default RPC. `authCapture` * isn't supported for Solana (skipped with a warning), regardless of * `rpcUrl` — it's an EVM-only scheme upstream. It's also skipped (with a * warning) on EVM for a `"smart"` {@link WalletConfig}, since a Smart * Contract Wallet's ERC-1271/ERC-6492-wrapped signature isn't supported by * the `auth-capture` settlement path yet. `batchSettlement` is opt-in on * top of the default baseline (EVM-only as well). */ networkSchemes?: NetworkConfig[]; } const DEFAULT_ACCOUNT_NAME = "x402-client-wallet-1"; const resolveWalletType = (type: string | undefined): WalletType => { if (!type) return "eoa"; if (type === "eoa" || type === "smart") return type; throw new Error(`Unsupported wallet type "${type}". Supported values: "eoa", "smart".`); }; const resolveAccountName = (config?: WalletConfig): string => config?.accountName ?? DEFAULT_ACCOUNT_NAME; /** * Resolves the deployment environment, falling back from explicit config to * the `CDP_X402_CLIENT_ENVIRONMENT` env var. Mirrors the server-side * `CDP_X402_SERVER_ENVIRONMENT` resolution in `server.ts`. * * @param config - Optional explicit environment override. * @returns `"development"` only when explicitly requested; `"production"` otherwise. */ const resolveEnvironment = ( config: Pick<CdpX402ClientConfig, "environment"> | undefined, ): "production" | "development" => { const rawEnv = config?.environment ?? (process.env.CDP_X402_CLIENT_ENVIRONMENT as "production" | "development" | undefined); return rawEnv === "development" ? "development" : "production"; }; const buildEvmRpcUrlsByChainId = ( rpcUrlsByCaip2: Record<string, { rpcUrl: string }>, ): Record<number, { rpcUrl: string }> => { const result: Record<number, { rpcUrl: string }> = {}; for (const [caip2, cfg] of Object.entries(rpcUrlsByCaip2)) { const [namespace, chainId] = caip2.split(":"); if (namespace !== "eip155" || !chainId) continue; const numericChainId = Number(chainId); if (!Number.isNaN(numericChainId)) { result[numericChainId] = cfg; } } return result; }; type SignerSetup = { cdpClient: CdpClient; evmAddress: Address; svmAddress: string; ownerWallet?: string; }; const setupCdpSigners = async ( client: x402Client, config: CdpX402ClientConfig | undefined, ): Promise<SignerSetup> => { const apiKeyId = config?.apiKeyId ?? process.env.CDP_API_KEY_ID; const apiKeySecret = config?.apiKeySecret ?? process.env.CDP_API_KEY_SECRET; const walletSecret = config?.walletSecret ?? process.env.CDP_WALLET_SECRET; const missing: string[] = []; if (!apiKeyId) missing.push("CDP_API_KEY_ID"); if (!apiKeySecret) missing.push("CDP_API_KEY_SECRET"); if (!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.", ); } const cdpClient = new CdpClient({ apiKeyId: apiKeyId!, apiKeySecret: apiKeySecret!, walletSecret: walletSecret!, }); const walletConfigInput = config?.walletConfig; const walletType = resolveWalletType(walletConfigInput?.type); const accountName = resolveAccountName(walletConfigInput); const svmAccount = await cdpClient.solana.getOrCreateAccount({ name: accountName }); let evmAddress: Address; let ownerWallet: string | undefined; let evmSigner; if (walletType === "smart") { const ownerAccountName = walletConfigInput?.type === "smart" ? walletConfigInput.ownerAccountName : undefined; if (!ownerAccountName) { throw new Error( 'Missing required owner account name for wallet type "smart". ' + "Provide it via walletConfig.ownerAccountName.", ); } const ownerAccount = await cdpClient.evm.getOrCreateAccount({ name: 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; } } evmAddress = smartAccount.address as Address; ownerWallet = ownerAccountName; evmSigner = fromCdpSmartWallet(smartAccount); } else { const evmAccount = await cdpClient.evm.getOrCreateAccount({ name: accountName }); evmAddress = evmAccount.address as Address; evmSigner = fromCdpEvmAccount(evmAccount); } const svmSigner = cdpSolanaAccountToSvmSigner(svmAccount); // Keyed by CAIP-2 identifier (e.g. "eip155:8453"), not by network name. const defaultEvmRpcUrls = await getDefaultEvmRpcUrls(); const defaultBaseRpcUrl = defaultEvmRpcUrls[baseMainnetCaip2]?.rpcUrl; const defaultBaseSepoliaRpcUrl = defaultEvmRpcUrls[baseSepoliaCaip2]?.rpcUrl; // `environment` "development" prescribes Base Sepolia; "production" (default) prescribes Base mainnet. const environment = resolveEnvironment(config); const defaultNetworkSchemes: NetworkConfig[] = environment === "development" ? [ { network: "base-sepolia", rpcUrl: defaultBaseSepoliaRpcUrl, scheme: { exact: true, upto: true, authCapture: true }, }, ] : [ { network: "base", rpcUrl: defaultBaseRpcUrl, scheme: { exact: true, upto: true, authCapture: true }, }, ]; /* * `networkSchemes` merges into the default baseline per network: a partial `scheme` override * (e.g. `{ authCapture: false }`) only touches the fields it sets — it doesn't disable the * other schemes the network defaulted to (or a prior override already set). */ const networksByName = new Map(defaultNetworkSchemes.map(config => [config.network, config])); for (const override of config?.networkSchemes ?? []) { const existing = networksByName.get(override.network); /* * `existing` (and thus `existing?.rpcUrl`) is only populated for the Base * network matching `environment` — the one already in the default * baseline. An override for the *other* Base network (e.g. `"base"` in a * `"development"` environment) has no `existing` entry, so fall back to * the pre-fetched CDP-hosted default RPC by name to honor the documented * contract that Base and Base Sepolia always get a default RPC injected. */ const defaultRpcUrl = override.network === "base" ? defaultBaseRpcUrl : override.network === "base-sepolia" ? defaultBaseSepoliaRpcUrl : undefined; const rpcUrl = override.rpcUrl ?? existing?.rpcUrl ?? defaultRpcUrl; const scheme = { ...existing?.scheme, ...override.scheme }; networksByName.set(override.network, { ...override, rpcUrl, scheme }); } // `normalizeNetwork` converts the v1-style plain names used here (e.g. "base") into the CAIP-2 form v2's `register` needs (e.g. "eip155:8453"); `registerV1` keeps using the plain name. const evmRpcUrlsByCaip2: Record<string, { rpcUrl: string }> = {}; for (const [network, netConfig] of networksByName) { if (network === "solana" || network === "solana-devnet") continue; if (netConfig.rpcUrl) evmRpcUrlsByCaip2[normalizeNetwork(network)] = { rpcUrl: netConfig.rpcUrl }; } const evmRpcUrlsByChainId = buildEvmRpcUrlsByChainId(evmRpcUrlsByCaip2); for (const [network, netConfig] of networksByName) { const caip2Network = normalizeNetwork(network) as Network; const isSolana = network === "solana" || network === "solana-devnet"; if (netConfig.scheme.exact) { if (isSolana) { client.register(caip2Network, new ExactSvmScheme(svmSigner, { rpcUrl: netConfig.rpcUrl })); client.registerV1( network as Network, new ExactSvmSchemeV1(svmSigner, { rpcUrl: netConfig.rpcUrl }), ); } else { client.register(caip2Network, new ExactEvmScheme(evmSigner, evmRpcUrlsByChainId)); client.registerV1(network as Network, new ExactEvmSchemeV1(evmSigner)); } } if (netConfig.scheme.upto) { if (isSolana) { client.register(caip2Network, new UptoSvmScheme(svmSigner, { rpcUrl: netConfig.rpcUrl })); /* * `upto`'s EVM Permit2 transfer method requires an initial approval * transaction that cannot be sponsored; disabled for smart accounts at * this time. Solana `upto` has no such restriction — CDP Solana * accounts are never smart-wallet-typed, so it's unaffected either way. */ } else if (walletType === "smart") { // eslint-disable-next-line no-console console.warn( `CdpX402Client: skipping network "${network}": upto scheme is not supported for Smart Contract Wallets (Permit2 approval cannot be sponsored).`, ); } else { client.register(caip2Network, new UptoEvmScheme(evmSigner, evmRpcUrlsByChainId)); } } if (netConfig.scheme.batchSettlement) { if (isSolana) { // eslint-disable-next-line no-console console.warn( `CdpX402Client: skipping network "${network}": Solana Batch Settlement scheme is not yet supported.`, ); } else { client.register( caip2Network, new BatchSettlementEvmScheme(evmSigner, { rpcUrl: netConfig.rpcUrl }), ); } } if (netConfig.scheme.authCapture) { if (isSolana) { // eslint-disable-next-line no-console console.warn( `CdpX402Client: skipping network "${network}": Solana Auth Capture scheme is not supported (EVM-only upstream).`, ); } else if (walletType === "smart") { /* * `fromCdpSmartWallet` produces an ERC-1271/ERC-6492-wrapped signature. * `AuthCaptureEvmScheme`'s settlement path (and this SDK's * `signEvmSmartAccountX402Payment` capability, which only supports * `exact`) has no verified support for that signature shape yet, so * registering it here would let a smart-wallet client select an * auth-capture requirement and produce an unsettleable payload. */ // eslint-disable-next-line no-console console.warn( `CdpX402Client: skipping network "${network}": auth-capture is not supported for Smart Contract Wallets.`, ); } else { client.register(caip2Network, new AuthCaptureEvmScheme(evmSigner)); } } } if (config?.spendControls) { applySpendControls(client, config.spendControls); } return { cdpClient, evmAddress, svmAddress: svmAccount.address, ownerWallet }; }; /** * Wallet addresses provisioned by a {@link CdpX402Client}. */ export interface CdpX402WalletAddresses { /** EVM address (EOA or Smart Contract Wallet) used for payment signing. */ evmAddress: Address; /** Solana address used for payment signing. */ svmAddress: string; /** Name of the owner EOA account backing a `"smart"` wallet, if configured. */ ownerWallet?: string; } /** * A Coinbase CDP-powered x402 client that initializes lazily on first payment. * * Extends `x402Client` with automatic wallet provisioning and scheme registration. * Credentials fall back to environment variables; wallet configuration and * RPC URLs are supplied explicitly via config. * * The account name/address used for payments is resolved internally. Use * {@link CdpX402Client.getAddresses} to retrieve it — for example, to fund * the wallet before making a payment. * * @example * ```typescript * import { CdpX402Client } from "@coinbase/cdp-sdk/x402"; * import { wrapFetchWithPayment } from "@x402/fetch"; * * const client = new CdpX402Client(); * const { evmAddress, svmAddress } = await client.getAddresses(); * console.log("Fund this address before paying:", evmAddress, svmAddress); * * const fetchWithPayment = wrapFetchWithPayment(fetch, client); * const response = await fetchWithPayment("https://api.example.com/report"); * ``` * * @example * ```typescript * const USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; * const client = new CdpX402Client({ * spendControls: { * maxAmountPerPayment: { atomic: 10_000n, asset: USDC_BASE }, * maxCumulativeSpend: { atomic: 50_000n, asset: USDC_BASE }, * maxCumulativeSpendWindow: "24h", * allowedNetworks: ["eip155:8453"], * }, * }); * ``` * * @example * ```typescript * // Attribute payments to this client via the builder-code extension. * const client = new CdpX402Client({ builderCode: "my_client" }); * ``` */ export class CdpX402Client extends x402Client { private readonly _config: CdpX402ClientConfig | undefined; private readonly _serviceBuilderCodes: string[]; private _initPromise: Promise<void> | null = null; private _addresses: CdpX402WalletAddresses | null = null; /** * Creates a CdpX402Client that initializes lazily on first payment. * * @param config - Optional configuration. Credentials fall back to environment variables. * @throws If `config.builderCode` is empty, has more than four codes, or * contains a code that is not 1-32 lowercase alphanumeric / underscore characters. */ constructor(config?: CdpX402ClientConfig) { super(); /* * Disable x402Client's own default $1-per-payment cap so the only active * spend policy is this SDK's own, set via `config.spendControls`. That * leaves no cap at all when `config.spendControls` is unset, so warn * instead of failing silently. */ this.setSpendControls(false); if (!config?.spendControls) { // eslint-disable-next-line no-console console.warn( "CdpX402Client: spendControls not configured. No default spend cap is applied. " + "Set `spendControls` to bound payment size.", ); } this._serviceBuilderCodes = [ ...(config?.builderCode !== undefined ? toServiceBuilderCodes(config.builderCode) : []), CDP_SDK_CLIENT_BUILDER_CODE, ]; /* * Registered here rather than during lazy initialization so that a malformed * code throws before any CDP I/O, and so a `registerExtension("builder-code")` * call by the caller takes precedence over the config. */ this.registerExtension(new BuilderCodeClientExtension(this._serviceBuilderCodes)); this._config = config; } /** * The CDP account name that this client provisions (or has already * provisioned) for payment signing. Resolved synchronously from config * and defaults — does not perform any CDP account lookups. * * @returns The resolved account name, e.g. `"x402-client-wallet-1"`. */ get accountName(): string { return resolveAccountName(this._config?.walletConfig); } /** * Provisions the CDP wallet (if not already provisioned) and returns its * addresses. Call this eagerly — for example, to display or fund the * wallet — instead of waiting for the lazy initialization on first payment. * * @returns The EVM and Solana addresses backing this client's payments. */ async getAddresses(): Promise<CdpX402WalletAddresses> { await this._ensureInitialized(); return this._addresses!; } /** * Creates the payment payload, initializing the CDP wallet lazily on first call. * * @param paymentRequired - The x402 payment requirements from the resource server. * @returns The signed payment payload. */ override async createPaymentPayload(paymentRequired: PaymentRequired): Promise<PaymentPayload> { await this._ensureInitialized(); return super.createPaymentPayload(paymentRequired); } /** * Provisions CDP accounts and registers payment schemes. */ private async _initialize(): Promise<void> { const { evmAddress, svmAddress, ownerWallet } = await setupCdpSigners(this, this._config); this._addresses = { evmAddress, svmAddress, ownerWallet }; } /** * Returns a shared initialization promise, starting initialization if needed. * * @returns Promise that resolves when the client is ready to make payments. */ private _ensureInitialized(): Promise<void> { if (!this._initPromise) { this._initPromise = this._initialize().catch(error => { this._initPromise = null; throw error; }); } return this._initPromise; } }