From c8fa39327539bef086f0038df4b8616862ebfd3b Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Fri, 31 Oct 2025 17:59:29 -0700 Subject: [PATCH] [X402] Refactor payment processing to use facilitator.accepts --- packages/thirdweb/src/x402/common.ts | 242 +----------------- packages/thirdweb/src/x402/facilitator.ts | 53 +++- .../thirdweb/src/x402/fetchWithPayment.ts | 16 +- packages/thirdweb/src/x402/schemas.ts | 108 ++++++-- packages/thirdweb/src/x402/sign.ts | 22 +- packages/thirdweb/src/x402/types.ts | 3 - 6 files changed, 181 insertions(+), 263 deletions(-) diff --git a/packages/thirdweb/src/x402/common.ts b/packages/thirdweb/src/x402/common.ts index 8e2464aee1e..5b6a9a271da 100644 --- a/packages/thirdweb/src/x402/common.ts +++ b/packages/thirdweb/src/x402/common.ts @@ -1,23 +1,18 @@ import type { Abi } from "abitype"; import { toFunctionSelector } from "viem/utils"; -import { ChainIdToNetwork, type Money, moneySchema } from "x402/types"; import { getCachedChain } from "../chains/utils.js"; import type { ThirdwebClient } from "../client/client.js"; import { resolveContractAbi } from "../contract/actions/resolve-abi.js"; import { getContract } from "../contract/contract.js"; import { isPermitSupported } from "../extensions/erc20/__generated__/IERC20Permit/write/permit.js"; import { isTransferWithAuthorizationSupported } from "../extensions/erc20/__generated__/USDC/write/transferWithAuthorization.js"; -import { getAddress } from "../utils/address.js"; -import { toUnits } from "../utils/units.js"; import { decodePayment } from "./encode.js"; -import type { ThirdwebX402Facilitator } from "./facilitator.js"; import { - networkToChainId, + networkToCaip2ChainId, type RequestedPaymentPayload, type RequestedPaymentRequirements, } from "./schemas.js"; import { - type DefaultAsset, type ERC20TokenAmount, type PaymentArgs, type PaymentRequiredResult, @@ -50,95 +45,24 @@ export async function decodePaymentRequest( method, paymentData, } = args; - const { - description, - mimeType, - maxTimeoutSeconds, - inputSchema, - outputSchema, - errorMessages, - discoverable, - } = routeConfig; + const { errorMessages } = routeConfig; - let chainId: number; - try { - chainId = networkToChainId(network); - } catch (error) { - return { - status: 402, - responseHeaders: { "Content-Type": "application/json" }, - responseBody: { - x402Version, - error: - error instanceof Error - ? error.message - : `Invalid network: ${network}`, - accepts: [], - }, - }; - } - - const atomicAmountForAsset = await processPriceToAtomicAmount( + const paymentRequirementsResult = await facilitator.accepts({ + resourceUrl, + method, + network, price, - chainId, - facilitator, - ); - if ("error" in atomicAmountForAsset) { - return { - status: 402, - responseHeaders: { "Content-Type": "application/json" }, - responseBody: { - x402Version, - error: atomicAmountForAsset.error, - accepts: [], - }, - }; - } - const { maxAmountRequired, asset } = atomicAmountForAsset; - - const paymentRequirements: RequestedPaymentRequirements[] = []; - - const mappedNetwork = ChainIdToNetwork[chainId]; - paymentRequirements.push({ - scheme: "exact", - network: mappedNetwork ? mappedNetwork : `eip155:${chainId}`, - maxAmountRequired, - resource: resourceUrl, - description: description ?? "", - mimeType: mimeType ?? "application/json", - payTo: getAddress(facilitator.address), // always pay to the facilitator address first - maxTimeoutSeconds: maxTimeoutSeconds ?? 86400, - asset: getAddress(asset.address), - outputSchema: { - input: { - type: "http", - method, - discoverable: discoverable ?? true, - ...inputSchema, - }, - output: outputSchema, - }, - extra: { - recipientAddress: payTo, // input payTo is the final recipient address - ...((asset as ERC20TokenAmount["asset"]).eip712 ?? {}), - }, + routeConfig, + payTo, }); - // Check for payment header + // Check for payment header, if none, return the payment requirements if (!paymentData) { - return { - status: 402, - responseHeaders: { - "Content-Type": "application/json", - }, - responseBody: { - x402Version, - error: errorMessages?.paymentRequired || "X-PAYMENT header is required", - accepts: paymentRequirements, - }, - }; + return paymentRequirementsResult; } + const paymentRequirements = paymentRequirementsResult.responseBody.accepts; + // decode b64 payment let decodedPayment: RequestedPaymentPayload; try { @@ -163,8 +87,8 @@ export async function decodePaymentRequest( const selectedPaymentRequirements = paymentRequirements.find( (value) => value.scheme === decodedPayment.scheme && - networkToChainId(value.network) === - networkToChainId(decodedPayment.network), + networkToCaip2ChainId(value.network) === + networkToCaip2ChainId(decodedPayment.network), ); if (!selectedPaymentRequirements) { return { @@ -190,86 +114,6 @@ export async function decodePaymentRequest( }; } -/** - * Parses the amount from the given price - * - * @param price - The price to parse - * @param network - The network to get the default asset for - * @returns The parsed amount or an error message - */ -async function processPriceToAtomicAmount( - price: Money | ERC20TokenAmount, - chainId: number, - facilitator: ThirdwebX402Facilitator, -): Promise< - { maxAmountRequired: string; asset: DefaultAsset } | { error: string } -> { - // Handle USDC amount (string) or token amount (ERC20TokenAmount) - let maxAmountRequired: string; - let asset: DefaultAsset; - - if (typeof price === "string" || typeof price === "number") { - // USDC amount in dollars - const parsedAmount = moneySchema.safeParse(price); - if (!parsedAmount.success) { - return { - error: `Invalid price (price: ${price}). Must be in the form "$3.10", 0.10, "0.001", ${parsedAmount.error}`, - }; - } - const parsedUsdAmount = parsedAmount.data; - const defaultAsset = await getDefaultAsset(chainId, facilitator); - if (!defaultAsset) { - return { - error: `Unable to get default asset on chain ${chainId}. Please specify an asset in the payment requirements.`, - }; - } - asset = defaultAsset; - maxAmountRequired = toUnits( - parsedUsdAmount.toString(), - defaultAsset.decimals, - ).toString(); - } else { - // Token amount in atomic units - maxAmountRequired = price.amount; - const tokenExtras = await getOrDetectTokenExtras({ - facilitator, - partialAsset: price.asset, - chainId, - }); - if (!tokenExtras) { - return { - error: `Unable to find token information for ${price.asset.address} on chain ${chainId}. Please specify the asset decimals and eip712 information in the asset options.`, - }; - } - asset = { - address: price.asset.address, - decimals: tokenExtras.decimals, - eip712: { - name: tokenExtras.name, - version: tokenExtras.version, - primaryType: tokenExtras.primaryType, - }, - }; - } - - return { - maxAmountRequired, - asset, - }; -} - -async function getDefaultAsset( - chainId: number, - facilitator: ThirdwebX402Facilitator, -): Promise { - const supportedAssets = await facilitator.supported(); - const matchingAsset = supportedAssets.kinds.find( - (supported) => networkToChainId(supported.network) === chainId, - ); - const assetConfig = matchingAsset?.extra?.defaultAsset as DefaultAsset; - return assetConfig; -} - export async function getSupportedSignatureType(args: { client: ThirdwebClient; asset: string; @@ -309,61 +153,3 @@ export async function getSupportedSignatureType(args: { } return undefined; } - -async function getOrDetectTokenExtras(args: { - facilitator: ThirdwebX402Facilitator; - partialAsset: ERC20TokenAmount["asset"]; - chainId: number; -}): Promise< - | { - name: string; - version: string; - decimals: number; - primaryType: SupportedSignatureType; - } - | undefined -> { - const { facilitator, partialAsset, chainId } = args; - if ( - partialAsset.eip712?.name && - partialAsset.eip712?.version && - partialAsset.decimals !== undefined - ) { - return { - name: partialAsset.eip712.name, - version: partialAsset.eip712.version, - decimals: partialAsset.decimals, - primaryType: partialAsset.eip712.primaryType, - }; - } - // read from facilitator - const response = await facilitator - .supported({ - chainId, - tokenAddress: partialAsset.address, - }) - .catch(() => { - return { - kinds: [], - }; - }); - - const exactScheme = response.kinds?.find((kind) => kind.scheme === "exact"); - if (!exactScheme) { - return undefined; - } - const supportedAsset = exactScheme.extra?.supportedAssets?.find( - (asset) => - asset.address.toLowerCase() === partialAsset.address.toLowerCase(), - ); - if (!supportedAsset) { - return undefined; - } - - return { - name: supportedAsset.eip712.name, - version: supportedAsset.eip712.version, - decimals: supportedAsset.decimals, - primaryType: supportedAsset.eip712.primaryType as SupportedSignatureType, - }; -} diff --git a/packages/thirdweb/src/x402/facilitator.ts b/packages/thirdweb/src/x402/facilitator.ts index 0d95070e4a9..3ad103ea02f 100644 --- a/packages/thirdweb/src/x402/facilitator.ts +++ b/packages/thirdweb/src/x402/facilitator.ts @@ -2,13 +2,15 @@ import type { VerifyResponse } from "x402/types"; import type { ThirdwebClient } from "../client/client.js"; import { stringify } from "../utils/json.js"; import { withCache } from "../utils/promise/withCache.js"; -import type { - FacilitatorSettleResponse, - FacilitatorSupportedResponse, - FacilitatorVerifyResponse, - RequestedPaymentPayload, - RequestedPaymentRequirements, +import { + type FacilitatorSettleResponse, + type FacilitatorSupportedResponse, + type FacilitatorVerifyResponse, + networkToCaip2ChainId, + type RequestedPaymentPayload, + type RequestedPaymentRequirements, } from "./schemas.js"; +import type { PaymentArgs, PaymentRequiredResult } from "./types.js"; export type WaitUntil = "simulated" | "submitted" | "confirmed"; @@ -46,6 +48,9 @@ export type ThirdwebX402Facilitator = { chainId: number; tokenAddress?: string; }) => Promise; + accepts: ( + args: Omit, + ) => Promise; }; const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402"; @@ -256,6 +261,42 @@ export function facilitator( }, ); }, + + async accepts( + args: Omit, + ): Promise { + const url = config.baseUrl ?? DEFAULT_BASE_URL; + let headers = { "Content-Type": "application/json" }; + const authHeaders = await facilitator.createAuthHeaders(); + headers = { ...headers, ...authHeaders.verify }; // same as verify + const caip2ChainId = networkToCaip2ChainId(args.network); + const res = await fetch(`${url}/accepts`, { + method: "POST", + headers, + body: stringify({ + resourceUrl: args.resourceUrl, + method: args.method, + network: caip2ChainId, + price: args.price, + routeConfig: args.routeConfig, + serverWalletAddress: facilitator.address, + recipientAddress: args.payTo, + }), + }); + if (res.status !== 402) { + throw new Error( + `Failed to construct payment requirements: ${res.statusText} - ${await res.text()}`, + ); + } + return { + status: res.status as 402, + responseBody: + (await res.json()) as PaymentRequiredResult["responseBody"], + responseHeaders: { + "Content-Type": "application/json", + }, + }; + }, }; return facilitator; diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index ffad27c61a2..d9adeef3b59 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -2,7 +2,8 @@ import { getCachedChain } from "../chains/utils.js"; import type { ThirdwebClient } from "../client/client.js"; import type { Wallet } from "../wallets/interfaces/wallet.js"; import { - networkToChainId, + extractEvmChainId, + networkToCaip2ChainId, type RequestedPaymentRequirements, RequestedPaymentRequirementsSchema, } from "./schemas.js"; @@ -96,9 +97,16 @@ export function wrapFetchWithPayment( ); } - const paymentChainId = networkToChainId( + const caip2ChainId = networkToCaip2ChainId( selectedPaymentRequirements.network, ); + const paymentChainId = extractEvmChainId(caip2ChainId); + // TODO (402): support solana + if (paymentChainId === null) { + throw new Error( + `Unsupported chain ID: ${selectedPaymentRequirements.network}`, + ); + } // switch to the payment chain if it's not the current chain if (paymentChainId !== chain.id) { @@ -150,7 +158,9 @@ function defaultPaymentRequirementsSelector( } // find the payment requirements matching the connected wallet chain const matchingPaymentRequirements = paymentRequirements.find( - (x) => networkToChainId(x.network) === chainId && x.scheme === scheme, + (x) => + extractEvmChainId(networkToCaip2ChainId(x.network)) === chainId && + x.scheme === scheme, ); if (matchingPaymentRequirements) { diff --git a/packages/thirdweb/src/x402/schemas.ts b/packages/thirdweb/src/x402/schemas.ts index 22ac1d9be62..4953f1f890f 100644 --- a/packages/thirdweb/src/x402/schemas.ts +++ b/packages/thirdweb/src/x402/schemas.ts @@ -59,7 +59,7 @@ export const SupportedSignatureTypeSchema = z.enum([ "Permit", ]); -export const FacilitatorSupportedAssetSchema = z.object({ +const FacilitatorSupportedAssetSchema = z.object({ address: z.string(), decimals: z.number(), eip712: z.object({ @@ -92,25 +92,95 @@ export type FacilitatorSupportedResponse = z.infer< typeof FacilitatorSupportedResponseSchema >; -export function networkToChainId(network: string | Chain): number { - if (typeof network === "object") { - return network.id; +function isEvmChain(caip2ChainId: Caip2ChainId): boolean { + return caip2ChainId.startsWith("eip155:"); +} + +/** + * Extract numeric chain ID from CAIP-2 EVM chain (e.g., "eip155:1" -> 1) + */ +export function extractEvmChainId(caip2ChainId: Caip2ChainId): number | null { + if (!isEvmChain(caip2ChainId)) { + return null; } - if (network.startsWith("eip155:")) { - const chainId = parseInt(network.split(":")[1] ?? "0"); - if (!Number.isNaN(chainId) && chainId > 0) { - return chainId; - } else { - throw new Error(`Invalid network: ${network}`); + const parts = caip2ChainId.split(":"); + const chainId = Number(parts[1]); + return Number.isNaN(chainId) ? null : chainId; +} + +/** + * CAIP-2 compliant blockchain identifier + * @see https://chainagnostic.org/CAIPs/caip-2 + */ +const Caip2ChainIdSchema = z + .union([z.string(), z.number().int().positive()]) + .transform((value, ctx) => { + // Handle proper CAIP-2 format (already valid) + if (typeof value === "string" && value.includes(":")) { + const [namespace, reference] = value.split(":"); + + // Solana mainnet/devnet aliases + if (namespace === "solana" && reference === "mainnet") { + return "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ" as const; + } + if (namespace === "solana" && reference === "devnet") { + return "solana:8E9rvCKLFQia2Y35HXjjpWzj8weVo44K" as const; + } + + // Validate CAIP-2 format + const namespaceRegex = /^[-a-z0-9]{3,8}$/; + const referenceRegex = /^[-_a-zA-Z0-9]{1,32}$/; + + if (!namespaceRegex.test(namespace ?? "")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid CAIP-2 namespace: ${namespace}. Must match [-a-z0-9]{3,8}`, + }); + return z.NEVER; + } + + if (!referenceRegex.test(reference ?? "")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid CAIP-2 reference: ${reference}. Must match [-_a-zA-Z0-9]{1,32}`, + }); + return z.NEVER; + } + + return value as `${string}:${string}`; } + + // Handle number (EVM chain ID fallback) + if (typeof value === "number") { + return `eip155:${value}` as const; + } + + // Handle string number (EVM chain ID fallback) + const numValue = Number(value); + if (!Number.isNaN(numValue) && Number.isInteger(numValue) && numValue > 0) { + return `eip155:${numValue}` as const; + } + + const mappedChainId = EvmNetworkToChainId.get(value as Network); + if (mappedChainId) { + return `eip155:${mappedChainId}` as const; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid chain ID: ${value}. Must be a CAIP-2 identifier (e.g., "eip155:1", "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ"), a numeric chain ID for EVM, or "solana:mainnet"/"solana:devnet"`, + }); + return z.NEVER; + }) + .describe( + "CAIP-2 blockchain identifier (e.g., 'eip155:1' for Ethereum, 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ' for Solana mainnet). Also accepts numeric EVM chain IDs (e.g., 1, 137) or aliases ('solana:mainnet', 'solana:devnet') for backward compatibility.", + ); + +type Caip2ChainId = z.output; + +export function networkToCaip2ChainId(network: string | Chain): Caip2ChainId { + if (typeof network === "object") { + return `eip155:${network.id}` as const; } - const mappedChainId = EvmNetworkToChainId.get(network as Network); - if (!mappedChainId) { - throw new Error(`Invalid network: ${network}`); - } - // TODO (402): support solana networks - if (mappedChainId === 101 || mappedChainId === 103) { - throw new Error("Solana networks not supported yet."); - } - return mappedChainId; + return Caip2ChainIdSchema.parse(network); } diff --git a/packages/thirdweb/src/x402/sign.ts b/packages/thirdweb/src/x402/sign.ts index 102fc962959..d9d9f294c8c 100644 --- a/packages/thirdweb/src/x402/sign.ts +++ b/packages/thirdweb/src/x402/sign.ts @@ -10,7 +10,8 @@ import type { Account } from "../wallets/interfaces/wallet.js"; import { getSupportedSignatureType } from "./common.js"; import { encodePayment } from "./encode.js"; import { - networkToChainId, + extractEvmChainId, + networkToCaip2ChainId, type RequestedPaymentPayload, type RequestedPaymentRequirements, type UnsignedPaymentPayload, @@ -71,7 +72,14 @@ async function signPaymentHeader( x402Version: number, ): Promise { const from = getAddress(account.address); - const chainId = networkToChainId(paymentRequirements.network); + const caip2ChainId = networkToCaip2ChainId(paymentRequirements.network); + const chainId = extractEvmChainId(caip2ChainId); + + // TODO (402): support solana + if (chainId === null) { + throw new Error(`Unsupported chain ID: ${paymentRequirements.network}`); + } + const supportedSignatureType = await getSupportedSignatureType({ client, asset: paymentRequirements.asset, @@ -191,7 +199,10 @@ async function signERC3009Authorization( }: ExactEvmPayloadAuthorization, { asset, network, extra }: RequestedPaymentRequirements, ): Promise<{ signature: Hex }> { - const chainId = networkToChainId(network); + const chainId = extractEvmChainId(networkToCaip2ChainId(network)); + if (chainId === null) { + throw new Error(`Unsupported chain ID: ${network}`); + } const name = extra?.name; const version = extra?.version; @@ -233,7 +244,10 @@ async function signERC2612Permit( { from, to, value, validBefore, nonce }: ExactEvmPayloadAuthorization, { asset, network, extra }: RequestedPaymentRequirements, ): Promise<{ signature: Hex }> { - const chainId = networkToChainId(network); + const chainId = extractEvmChainId(networkToCaip2ChainId(network)); + if (chainId === null) { + throw new Error(`Unsupported chain ID: ${network}`); + } const name = extra?.name; const version = extra?.version; diff --git a/packages/thirdweb/src/x402/types.ts b/packages/thirdweb/src/x402/types.ts index f9143a485f3..db235c1c5e4 100644 --- a/packages/thirdweb/src/x402/types.ts +++ b/packages/thirdweb/src/x402/types.ts @@ -6,7 +6,6 @@ import type { ThirdwebX402Facilitator, WaitUntil } from "./facilitator.js"; import type { FacilitatorNetwork, FacilitatorSettleResponse, - FacilitatorSupportedAssetSchema, RequestedPaymentPayload, RequestedPaymentRequirements, SupportedSignatureTypeSchema, @@ -110,5 +109,3 @@ export type ERC20TokenAmount = { }; }; }; - -export type DefaultAsset = z.infer;