From bb9ab3b6c94d00c825e534d6fc22c03c2f23f334 Mon Sep 17 00:00:00 2001 From: gianalarcon Date: Sat, 13 Jun 2026 10:14:46 +0000 Subject: [PATCH] fix(x402): settle Arbitrum One deposits via CDP with checksummed v2 payload Arbitrum One (42161) only settles through Coinbase CDP (x402 v2); PayAI advertises "arbitrum" in /supported but rejects it on verify with invalid_exact_evm_network_mismatch (confirmed on prod). Route Arbitrum One deposits to CDP (Base + everything else stay on PayAI v1) and fix the CDP v2 payload that was being rejected. Root cause of the CDP 400: CDP's v2 validator requires EIP-55 checksummed EVM addresses. We sent lowercase (multisig addresses are stored lowercase; the X-PAYMENT authorization carries lowercase), so the payload failed CDP's oneOf[x402V2PaymentPayload, x402V1PaymentPayload] and surfaced the misleading "x402V1PaymentPayload requires 'scheme'" error. Checksum asset/payTo in the v2 requirements leaf and authorization.from/to in the payment payload via viem getAddress. Casing does not affect the EIP-3009 signature (EIP-712 encodes addresses to the 20-byte value). PayAI v1 path is untouched. Co-Authored-By: Claude Opus 4.8 --- packages/backend/src/x402/x402.controller.ts | 23 ++++-- packages/backend/src/x402/x402.service.ts | 85 +++++++++++++++++++- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/x402/x402.controller.ts b/packages/backend/src/x402/x402.controller.ts index 74737060..dd44239a 100644 --- a/packages/backend/src/x402/x402.controller.ts +++ b/packages/backend/src/x402/x402.controller.ts @@ -40,13 +40,15 @@ export class X402Controller { @Req() req: Request, @Res() res: Response, ): Promise { - // Default deposit path uses PayAI (x402 v1) for all supported chains — - // Base and Arbitrum (One + Sepolia) are all on PayAI's v1 facilitator. The - // CDP/v2 path is reserved for the /bazaar routes below. + // Chain-aware facilitator selection: Arbitrum One (42161) settles only via + // Coinbase CDP (x402 v2); Base (8453/84532) stays on PayAI (x402 v1). The + // /bazaar routes below force CDP regardless, for agentic.market indexing. + const facilitator = + await this.x402Service.resolveFacilitator(multisigAddress); const body = await this.x402Service.buildDiscoveryResponse( multisigAddress, resourceUrlFromRequest(req), - Facilitator.PayAI, + facilitator, ); res.status(HttpStatus.PAYMENT_REQUIRED).json(body); } @@ -59,15 +61,20 @@ export class X402Controller { @Body() body: DepositRequestDto, @Req() req: Request, ): Promise { - // All supported chains (Base, Arbitrum One + Sepolia) settle through PayAI's - // x402 v1 facilitator (network labels "base"/"arbitrum"/"arbitrum-sepolia"). - // The CDP/v2 path is used only by the /bazaar routes. + // Chain-aware: Arbitrum One (42161) → CDP (x402 v2, the only facilitator + // that settles it); everything else (Base 8453/84532, Arbitrum Sepolia + // 421614) → PayAI (x402 v1). Arbitrum Sepolia has no working facilitator + // (CDP unsupported, PayAI returns invalid_exact_evm_network_mismatch); it + // routes to PayAI and fails there cleanly rather than hitting a broken CDP + // path. The /bazaar routes below force CDP regardless. + const facilitator = + await this.x402Service.resolveFacilitator(multisigAddress); return this.x402Service.processDeposit( multisigAddress, paymentHeader, body?.memo, resourceUrlFromRequest(req), - Facilitator.PayAI, + facilitator, ); } diff --git a/packages/backend/src/x402/x402.service.ts b/packages/backend/src/x402/x402.service.ts index edba3a11..de6f825c 100644 --- a/packages/backend/src/x402/x402.service.ts +++ b/packages/backend/src/x402/x402.service.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; -import { createPublicClient, http } from 'viem'; +import { createPublicClient, getAddress, http } from 'viem'; import { USDC_TOKEN, getChainById, @@ -45,6 +45,37 @@ export const Facilitator = { } as const; export type Facilitator = (typeof Facilitator)[keyof typeof Facilitator]; +// CAIP-2 networks the Coinbase CDP facilitator actually settles, per +// https://docs.cdp.coinbase.com/x402/network-support (verified 2026-06-13): +// Base, Base Sepolia, Polygon, Arbitrum One, World, World Sepolia, Solana +// (+ devnet). NOTE: Arbitrum *Sepolia* (eip155:421614) is NOT supported by +// CDP, and PayAI lists "arbitrum"/"arbitrum-sepolia" in /supported but rejects +// them on verify with invalid_exact_evm_network_mismatch — so Arbitrum One is +// the only Arbitrum chain that settles anywhere, and it must go through CDP. +const CDP_SUPPORTED_NETWORKS: ReadonlySet = new Set([ + 'eip155:8453', // Base + 'eip155:84532', // Base Sepolia + 'eip155:137', // Polygon + 'eip155:42161', // Arbitrum One + 'eip155:480', // World + 'eip155:4801', // World Sepolia +]); + +// Chains routed to CDP (x402 v2). Everything else falls back to PayAI (v1). +// Only Arbitrum One settles via CDP today; Base stays on PayAI to keep the +// existing, working v1 path untouched. +const CDP_ROUTED_CHAIN_IDS: ReadonlySet = new Set([42161]); + +/** + * Chain-aware facilitator selection. Arbitrum One (42161) is only settleable + * through Coinbase CDP (x402 v2); all other supported chains keep using PayAI. + */ +export function facilitatorForChain(chainId: number): Facilitator { + return CDP_ROUTED_CHAIN_IDS.has(chainId) + ? Facilitator.CDP + : Facilitator.PayAI; +} + // --- x402 v2 wire types ----------------------------------------------------- // Mirrors @x402/core/types/payments.ts. Inlined so we don't pull the package // just for type names. CDP requires v2 wire format for bazaar indexing today. @@ -115,6 +146,18 @@ export class X402Service { // ---------- public surface ---------- + /** + * Resolve which facilitator a multisig's deposits route through, based on the + * account's chainId. Arbitrum One (42161) → CDP (x402 v2, the only facilitator + * that settles it); every other supported chain → PayAI (x402 v1). Lets the + * default /deposit routes stay chain-aware without the controller knowing + * about chains. Throws (via assertAccount) for unknown/unsupported chains. + */ + async resolveFacilitator(multisigAddress: string): Promise { + const account = await this.assertAccount(multisigAddress); + return facilitatorForChain(account.chainId); + } + async buildDiscoveryResponse( multisigAddress: string, resourceUrl: string, @@ -123,6 +166,7 @@ export class X402Service { if (facilitator === Facilitator.CDP) { this.assertCdpEnabled(); const account = await this.assertAccount(multisigAddress); + this.assertCdpNetworkSupported(account.chainId); return this.buildV2PaymentRequired( account.chainId, account.address, @@ -165,6 +209,9 @@ export class X402Service { ); } const account = await this.assertAccount(multisigAddress); + if (facilitator === Facilitator.CDP) { + this.assertCdpNetworkSupported(account.chainId); + } const payload = decodeXPaymentHeader(paymentHeader); await this.validatePayloadAgainstMultisig( @@ -222,7 +269,14 @@ export class X402Service { resource: this.buildV2Resource(resourceUrl, account.address), accepted: v2Requirements, payload: { - authorization: payload.payload.authorization, + // Checksum the EIP-3009 authorization addresses for CDP's strict + // v2 EIP-55 validation. Safe for the signature: EIP-712 encodes + // addresses to the 20-byte value regardless of display casing. + authorization: { + ...payload.payload.authorization, + from: getAddress(payload.payload.authorization.from), + to: getAddress(payload.payload.authorization.to), + }, signature: payload.payload.signature, }, extensions: { bazaar: this.buildCdpBazaarExtension(resourceUrl) }, @@ -408,6 +462,20 @@ export class X402Service { } } + // Fail fast (and clearly) when a chain is routed to CDP but CDP cannot settle + // it — otherwise the request would die deep inside cdpVerify with an opaque + // facilitator error. Notably guards Arbitrum Sepolia (eip155:421614), which + // CDP does not support and PayAI rejects, so it has no working facilitator. + private assertCdpNetworkSupported(chainId: number): void { + const network = `eip155:${chainId}`; + if (!CDP_SUPPORTED_NETWORKS.has(network)) { + throw new BadRequestException( + `Chain ${chainId} (${network}) is not supported by the Coinbase CDP ` + + `x402 facilitator. CDP settles: ${[...CDP_SUPPORTED_NETWORKS].join(', ')}.`, + ); + } + } + private buildPaymentRequirements( chainId: number, payTo: string, @@ -495,11 +563,20 @@ export class X402Service { return { scheme: 'exact', network: `eip155:${chainId}`, - asset: USDC_TOKEN.addresses[chainId], + // CDP's v2 validator requires EIP-55 checksummed EVM addresses; sending + // lowercase makes the payload fail its oneOf and surface a misleading + // "x402V1PaymentPayload requires 'scheme'" error. Checksum here. + asset: getAddress(USDC_TOKEN.addresses[chainId]), amount, - payTo, + payTo: getAddress(payTo), maxTimeoutSeconds: 120, extra: { + // `assetTransferMethod: "eip3009"` is part of the canonical v2 exact-EVM + // scheme payload (specs/schemes/exact/scheme_exact_evm.md). USDC supports + // transferWithAuthorization natively, so we declare eip3009 explicitly. + assetTransferMethod: 'eip3009', + // EIP-712 domain — required by the facilitator to verify the signature + // against the asset contract for the "exact" scheme on EVM. name: domain.name, version: domain.version, minDeposit: MIN_DEPOSIT.toString(),