@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
385 lines (356 loc) • 14.4 kB
text/typescript
/*
* Account-level x402 payment signing.
*
* `@x402/*` packages are optional peer dependencies of the SDK — most
* consumers of `@coinbase/cdp-sdk` never touch x402, so every module reached
* from an account's `signX402Payment()` method (this file, and the
* `../../x402/account-signers.js` adapters it uses) loads them lazily via
* dynamic `import()` instead of a static top-level import. That keeps a bare
* `import { CdpClient } from "@coinbase/cdp-sdk"` from requiring `@x402/*` to
* be installed at all; they're only resolved the first time a caller
* actually signs an x402 payment.
*/
import { getDefaultEvmRpcUrls } from "../../x402/constants.js";
import type { CdpSmartAccount, CdpSolanaAccount } from "../../x402/account-signers.js";
import type { Network, PaymentPayload, PaymentRequired } from "@x402/core/types";
import type { ClientEvmSigner } from "@x402/evm";
/**
* Dynamically imports an optional `@x402/*` peer dependency (or an internal
* module that itself imports one), wrapping module-not-found failures with
* an actionable install hint instead of a raw resolution error.
*
* @param specifier - The module specifier to import.
* @param loader - Thunk performing the dynamic `import()`, so callers keep accurate typing.
* @returns The imported module's exports.
*/
async function importX402Dependency<T>(specifier: string, loader: () => Promise<T>): Promise<T> {
try {
return await loader();
} catch (error) {
throw new Error(
`Failed to load "${specifier}", which is required to sign x402 payments. ` +
"Install the CDP SDK's x402 peer dependencies: `@x402/core`, `@x402/evm`, `@x402/svm`.",
{ cause: error },
);
}
}
/**
* Options for signing an x402 payment payload.
*/
export interface SignX402PaymentOptions {
/** The x402 payment requirements returned by a resource server. */
paymentRequired: PaymentRequired;
/**
* Index into `paymentRequired.accepts` selecting which requirement to sign.
*/
acceptedIndex: number;
}
type RpcUrlsByCaip2 = Record<string, { rpcUrl: string }>;
/**
* Describes which networks and schemes a given CDP account type can sign for.
* Used to produce actionable errors when an `acceptedIndex` selects a payment
* requirement the account cannot fulfill.
*/
interface SigningCapability {
/** CAIP-2 namespace this account type can sign for (e.g. `"eip155"`). */
networkNamespace: string;
/** x402 schemes this account type can produce payloads for. */
supportedSchemes: readonly string[];
/** Human-readable account type label used in error messages. */
label: string;
}
const EVM_SIGNING_CAPABILITY: SigningCapability = {
networkNamespace: "eip155",
supportedSchemes: ["exact", "upto"],
label: "EVM",
};
/*
* Smart accounts sign with an ERC-1271/ERC-6492 contract signature, which only
* settles via the EIP-3009 `exact` flow. The `upto` scheme and the Permit2
* transfer method both require an on-chain Permit2 allowance owned by an EOA,
* so they are intentionally unsupported for smart accounts.
*/
const EVM_SMART_ACCOUNT_SIGNING_CAPABILITY: SigningCapability = {
networkNamespace: "eip155",
supportedSchemes: ["exact"],
label: "EVM smart",
};
const SVM_SIGNING_CAPABILITY: SigningCapability = {
networkNamespace: "solana",
supportedSchemes: ["exact"],
label: "Solana",
};
/**
* Parses JSON from CDP_X402_RPC_URLS and converts it into CAIP-2 keyed RPC config.
*
* Supports this value format:
* - { "eip155:8453": "https://..." }
*
* @returns Parsed CAIP-2 keyed EVM/Solana RPC URL overrides.
*/
function parseRpcUrlsFromEnv(): RpcUrlsByCaip2 {
const raw = process.env.CDP_X402_RPC_URLS;
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(
"Invalid CDP_X402_RPC_URLS: expected JSON object mapping CAIP-2 network IDs to RPC URL strings.",
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(
"Invalid CDP_X402_RPC_URLS: expected JSON object mapping CAIP-2 network IDs to RPC URL strings.",
);
}
const rpcUrlsByCaip2: RpcUrlsByCaip2 = {};
for (const [network, value] of Object.entries(parsed)) {
if (typeof value === "string") {
rpcUrlsByCaip2[network] = { rpcUrl: value };
continue;
}
throw new Error(`Invalid CDP_X402_RPC_URLS entry for "${network}": expected string URL.`);
}
return rpcUrlsByCaip2;
}
/**
* Resolves the final CAIP-2 keyed EVM RPC configuration used for x402 signing.
*
* Defaults come from {@link getDefaultEvmRpcUrls} (Base networks only), with
* CDP_X402_RPC_URLS env values taking precedence.
*
* @returns CAIP-2 keyed RPC configuration map.
*/
async function resolveEvmRpcUrlsByCaip2(): Promise<RpcUrlsByCaip2> {
return {
...(await getDefaultEvmRpcUrls()),
...parseRpcUrlsFromEnv(),
};
}
/**
* Converts CAIP-2 keyed RPC config to the numeric-chain-ID map expected by
* Exact/Upto EVM schemes.
*
* @param rpcUrlsByCaip2 - CAIP-2 keyed RPC configuration map.
* @returns Record of numeric EIP-155 chain IDs to RPC URL objects.
*/
function buildEvmRpcUrlsByChainId(
rpcUrlsByCaip2: RpcUrlsByCaip2,
): 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;
}
/**
* Selects a payment requirement by original `paymentRequired.accepts` index and
* returns a normalized PaymentRequired containing only that entry.
*
* This preserves the documented semantics of `acceptedIndex` even though
* `x402Client` internally filters accepts by registered scheme/network before
* invoking selectors.
*
* @param paymentRequired - Original payment requirements from a resource server.
* @param acceptedIndex - Index into the original `paymentRequired.accepts`.
* @param capability - The signing networks/schemes the account type supports.
* @returns A PaymentRequired with exactly one accepted requirement.
*/
function selectAcceptedPaymentRequired(
paymentRequired: PaymentRequired,
acceptedIndex: number,
capability: SigningCapability,
): PaymentRequired {
const selected = paymentRequired.accepts[acceptedIndex];
if (!selected) {
throw new Error(
`acceptedIndex ${acceptedIndex} is out of range — paymentRequired.accepts has ` +
`${paymentRequired.accepts.length} entr${paymentRequired.accepts.length === 1 ? "y" : "ies"}.`,
);
}
if (!selected.network.startsWith(`${capability.networkNamespace}:`)) {
throw new Error(
`acceptedIndex ${acceptedIndex} targets network "${selected.network}", which a ` +
`${capability.label} account cannot sign. Choose an index whose network uses the ` +
`"${capability.networkNamespace}:" namespace.`,
);
}
if (!capability.supportedSchemes.includes(selected.scheme)) {
throw new Error(
`acceptedIndex ${acceptedIndex} uses the "${selected.scheme}" scheme, which is not ` +
`supported for ${capability.label} accounts. Supported schemes: ` +
`${capability.supportedSchemes.join(", ")}.`,
);
}
return {
...paymentRequired,
accepts: [selected],
};
}
/**
* Throws with an actionable message when an EVM payment requirement targets a
* chain that has no configured RPC URL in the resolved map.
*
* Without an RPC, the x402 EVM scheme cannot sign or submit the payment
* transaction. Base and Base Sepolia resolve an RPC automatically via the
* CDP-authenticated node endpoint; every other chain requires an explicit
* RPC via `CDP_X402_RPC_URLS` or `rpcUrls`.
*
* @param network - The CAIP-2 network string from the selected requirement.
* @param rpcUrlsByChainId - The resolved chain-ID-to-RPC-URL map.
*/
function assertEvmRpcConfigured(
network: string,
rpcUrlsByChainId: Record<number, { rpcUrl: string }>,
): void {
const chainId = Number(network.split(":")[1]);
if (!Number.isNaN(chainId) && rpcUrlsByChainId[chainId] === undefined) {
throw new Error(
`No RPC URL configured for ${network}. ` +
`Provide one via CDP_X402_RPC_URLS='{"${network}":"https://your-rpc-url"}' ` +
`or rpcUrls: { "${network}": { rpcUrl: "https://your-rpc-url" } } in the SDK configuration.`,
);
}
}
/**
* Resolves a per-network RPC URL override for SVM signing from
* `CDP_X402_RPC_URLS`, keyed by the selected requirement's CAIP-2 network.
*
* The SVM exact scheme provides its own public RPC defaults, so only an
* explicit override needs to be wired through.
*
* @param network - CAIP-2 network identifier of the selected requirement.
* @returns The override RPC URL, or undefined when none is configured.
*/
function resolveSvmRpcUrlOverride(network: string): string | undefined {
return parseRpcUrlsFromEnv()[network]?.rpcUrl;
}
/**
* Signs an x402 payment payload with an EVM server account (EOA).
*
* Registers the exact and upto EVM schemes locally using the x402 SDK, then
* calls `x402Client.createPaymentPayload`. No CDP signing endpoint is used.
*
* @param account - The CDP EVM server account.
* @param options - The x402 payment requirements and selected accepted index.
* @returns The signed x402 payment payload.
*/
export async function signEvmX402Payment(
account: ClientEvmSigner,
options: SignX402PaymentOptions,
): Promise<PaymentPayload> {
const rpcUrlsByChainId = buildEvmRpcUrlsByChainId(await resolveEvmRpcUrlsByCaip2());
const selectedPaymentRequired = selectAcceptedPaymentRequired(
options.paymentRequired,
options.acceptedIndex,
EVM_SIGNING_CAPABILITY,
);
const [selected] = selectedPaymentRequired.accepts;
assertEvmRpcConfigured(selected.network, rpcUrlsByChainId);
const [{ x402Client }, { registerExactEvmScheme }, { UptoEvmScheme }] = await Promise.all([
importX402Dependency("@x402/core/client", () => import("@x402/core/client")),
importX402Dependency("@x402/evm/exact/client", () => import("@x402/evm/exact/client")),
importX402Dependency("@x402/evm/upto/client", () => import("@x402/evm/upto/client")),
]);
const client = new x402Client();
registerExactEvmScheme(client, { signer: account, schemeOptions: rpcUrlsByChainId });
client.register("eip155:*" as Network, new UptoEvmScheme(account, rpcUrlsByChainId));
return client.createPaymentPayload(selectedPaymentRequired);
}
/**
* Signs an x402 payment payload with an EVM smart account.
*
* The smart-account adapter derives the CDP network from the EIP-712 domain's
* `chainId`, so no explicit network parameter is needed on the call site.
*
* Smart accounts produce an ERC-1271/ERC-6492 contract signature, which only
* settles via the EIP-3009 `exact` flow. The `upto` scheme and the Permit2
* transfer method are rejected because they require an on-chain Permit2
* allowance owned by an EOA.
*
* @param account - The CDP EVM smart account.
* @param options - The x402 payment requirements and selected accepted index.
* @returns The signed x402 payment payload.
*/
export async function signEvmSmartAccountX402Payment(
account: CdpSmartAccount,
options: SignX402PaymentOptions,
): Promise<PaymentPayload> {
const rpcUrlsByChainId = buildEvmRpcUrlsByChainId(await resolveEvmRpcUrlsByCaip2());
const selectedPaymentRequired = selectAcceptedPaymentRequired(
options.paymentRequired,
options.acceptedIndex,
EVM_SMART_ACCOUNT_SIGNING_CAPABILITY,
);
const [selected] = selectedPaymentRequired.accepts;
assertEvmRpcConfigured(selected.network, rpcUrlsByChainId);
if (selected.extra?.assetTransferMethod === "permit2") {
throw new Error(
`acceptedIndex ${options.acceptedIndex} requires the Permit2 transfer method, which is ` +
`not supported for EVM smart accounts. Choose a requirement that uses EIP-3009 ` +
`(transferWithAuthorization).`,
);
}
const [{ x402Client }, { registerExactEvmScheme }, { fromCdpSmartWallet }] = await Promise.all([
importX402Dependency("@x402/core/client", () => import("@x402/core/client")),
importX402Dependency("@x402/evm/exact/client", () => import("@x402/evm/exact/client")),
importX402Dependency(
"../../x402/account-signers.js",
() => import("../../x402/account-signers.js"),
),
]);
const signer = fromCdpSmartWallet(account);
const client = new x402Client();
registerExactEvmScheme(client, { signer, schemeOptions: rpcUrlsByChainId });
return client.createPaymentPayload(selectedPaymentRequired);
}
/**
* Signs an x402 payment payload with a Solana account.
*
* @param account - The CDP Solana account.
* @param options - The x402 payment requirements and selected accepted index.
* @returns The signed x402 payment payload.
*/
export async function signSolanaX402Payment(
account: CdpSolanaAccount,
options: SignX402PaymentOptions,
): Promise<PaymentPayload> {
const selectedPaymentRequired = selectAcceptedPaymentRequired(
options.paymentRequired,
options.acceptedIndex,
SVM_SIGNING_CAPABILITY,
);
const [selected] = selectedPaymentRequired.accepts;
const [
{ x402Client },
{ ExactSvmScheme, registerExactSvmScheme },
{ cdpSolanaAccountToSvmSigner },
] = await Promise.all([
importX402Dependency("@x402/core/client", () => import("@x402/core/client")),
importX402Dependency("@x402/svm/exact/client", () => import("@x402/svm/exact/client")),
importX402Dependency(
"../../x402/account-signers.js",
() => import("../../x402/account-signers.js"),
),
]);
const signer = cdpSolanaAccountToSvmSigner(account);
const client = new x402Client();
const rpcUrl = resolveSvmRpcUrlOverride(selected.network);
if (rpcUrl) {
/*
* registerExactSvmScheme ignores RPC overrides, so register the selected
* network directly with the override. An exact-network registration takes
* precedence over the "solana:*" wildcard during scheme selection.
*/
client.register(selected.network as Network, new ExactSvmScheme(signer, { rpcUrl }));
} else {
registerExactSvmScheme(client, { signer });
}
return client.createPaymentPayload(selectedPaymentRequired);
}