@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
609 lines (559 loc) • 23.4 kB
text/typescript
/*
* 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;
}
}