Skip to content

Commit 4018cdc

Browse files
authored
Merge pull request #294 from Poly-pay/feat/cdp/arbitrum-one
fix(x402): settle Arbitrum One deposits via CDP with checksummed v2 payload
2 parents 63c9a21 + bb9ab3b commit 4018cdc

2 files changed

Lines changed: 96 additions & 12 deletions

File tree

packages/backend/src/x402/x402.controller.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@ export class X402Controller {
4040
@Req() req: Request,
4141
@Res() res: Response,
4242
): Promise<void> {
43-
// Default deposit path uses PayAI (x402 v1) for all supported chains —
44-
// Base and Arbitrum (One + Sepolia) are all on PayAI's v1 facilitator. The
45-
// CDP/v2 path is reserved for the /bazaar routes below.
43+
// Chain-aware facilitator selection: Arbitrum One (42161) settles only via
44+
// Coinbase CDP (x402 v2); Base (8453/84532) stays on PayAI (x402 v1). The
45+
// /bazaar routes below force CDP regardless, for agentic.market indexing.
46+
const facilitator =
47+
await this.x402Service.resolveFacilitator(multisigAddress);
4648
const body = await this.x402Service.buildDiscoveryResponse(
4749
multisigAddress,
4850
resourceUrlFromRequest(req),
49-
Facilitator.PayAI,
51+
facilitator,
5052
);
5153
res.status(HttpStatus.PAYMENT_REQUIRED).json(body);
5254
}
@@ -59,15 +61,20 @@ export class X402Controller {
5961
@Body() body: DepositRequestDto,
6062
@Req() req: Request,
6163
): Promise<X402DepositResponse> {
62-
// All supported chains (Base, Arbitrum One + Sepolia) settle through PayAI's
63-
// x402 v1 facilitator (network labels "base"/"arbitrum"/"arbitrum-sepolia").
64-
// The CDP/v2 path is used only by the /bazaar routes.
64+
// Chain-aware: Arbitrum One (42161) → CDP (x402 v2, the only facilitator
65+
// that settles it); everything else (Base 8453/84532, Arbitrum Sepolia
66+
// 421614) → PayAI (x402 v1). Arbitrum Sepolia has no working facilitator
67+
// (CDP unsupported, PayAI returns invalid_exact_evm_network_mismatch); it
68+
// routes to PayAI and fails there cleanly rather than hitting a broken CDP
69+
// path. The /bazaar routes below force CDP regardless.
70+
const facilitator =
71+
await this.x402Service.resolveFacilitator(multisigAddress);
6572
return this.x402Service.processDeposit(
6673
multisigAddress,
6774
paymentHeader,
6875
body?.memo,
6976
resourceUrlFromRequest(req),
70-
Facilitator.PayAI,
77+
facilitator,
7178
);
7279
}
7380

packages/backend/src/x402/x402.service.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@nestjs/common';
1010
import { ConfigService } from '@nestjs/config';
1111
import axios, { AxiosError } from 'axios';
12-
import { createPublicClient, http } from 'viem';
12+
import { createPublicClient, getAddress, http } from 'viem';
1313
import {
1414
USDC_TOKEN,
1515
getChainById,
@@ -45,6 +45,37 @@ export const Facilitator = {
4545
} as const;
4646
export type Facilitator = (typeof Facilitator)[keyof typeof Facilitator];
4747

48+
// CAIP-2 networks the Coinbase CDP facilitator actually settles, per
49+
// https://docs.cdp.coinbase.com/x402/network-support (verified 2026-06-13):
50+
// Base, Base Sepolia, Polygon, Arbitrum One, World, World Sepolia, Solana
51+
// (+ devnet). NOTE: Arbitrum *Sepolia* (eip155:421614) is NOT supported by
52+
// CDP, and PayAI lists "arbitrum"/"arbitrum-sepolia" in /supported but rejects
53+
// them on verify with invalid_exact_evm_network_mismatch — so Arbitrum One is
54+
// the only Arbitrum chain that settles anywhere, and it must go through CDP.
55+
const CDP_SUPPORTED_NETWORKS: ReadonlySet<string> = new Set([
56+
'eip155:8453', // Base
57+
'eip155:84532', // Base Sepolia
58+
'eip155:137', // Polygon
59+
'eip155:42161', // Arbitrum One
60+
'eip155:480', // World
61+
'eip155:4801', // World Sepolia
62+
]);
63+
64+
// Chains routed to CDP (x402 v2). Everything else falls back to PayAI (v1).
65+
// Only Arbitrum One settles via CDP today; Base stays on PayAI to keep the
66+
// existing, working v1 path untouched.
67+
const CDP_ROUTED_CHAIN_IDS: ReadonlySet<number> = new Set([42161]);
68+
69+
/**
70+
* Chain-aware facilitator selection. Arbitrum One (42161) is only settleable
71+
* through Coinbase CDP (x402 v2); all other supported chains keep using PayAI.
72+
*/
73+
export function facilitatorForChain(chainId: number): Facilitator {
74+
return CDP_ROUTED_CHAIN_IDS.has(chainId)
75+
? Facilitator.CDP
76+
: Facilitator.PayAI;
77+
}
78+
4879
// --- x402 v2 wire types -----------------------------------------------------
4980
// Mirrors @x402/core/types/payments.ts. Inlined so we don't pull the package
5081
// just for type names. CDP requires v2 wire format for bazaar indexing today.
@@ -115,6 +146,18 @@ export class X402Service {
115146

116147
// ---------- public surface ----------
117148

149+
/**
150+
* Resolve which facilitator a multisig's deposits route through, based on the
151+
* account's chainId. Arbitrum One (42161) → CDP (x402 v2, the only facilitator
152+
* that settles it); every other supported chain → PayAI (x402 v1). Lets the
153+
* default /deposit routes stay chain-aware without the controller knowing
154+
* about chains. Throws (via assertAccount) for unknown/unsupported chains.
155+
*/
156+
async resolveFacilitator(multisigAddress: string): Promise<Facilitator> {
157+
const account = await this.assertAccount(multisigAddress);
158+
return facilitatorForChain(account.chainId);
159+
}
160+
118161
async buildDiscoveryResponse(
119162
multisigAddress: string,
120163
resourceUrl: string,
@@ -123,6 +166,7 @@ export class X402Service {
123166
if (facilitator === Facilitator.CDP) {
124167
this.assertCdpEnabled();
125168
const account = await this.assertAccount(multisigAddress);
169+
this.assertCdpNetworkSupported(account.chainId);
126170
return this.buildV2PaymentRequired(
127171
account.chainId,
128172
account.address,
@@ -165,6 +209,9 @@ export class X402Service {
165209
);
166210
}
167211
const account = await this.assertAccount(multisigAddress);
212+
if (facilitator === Facilitator.CDP) {
213+
this.assertCdpNetworkSupported(account.chainId);
214+
}
168215
const payload = decodeXPaymentHeader(paymentHeader);
169216

170217
await this.validatePayloadAgainstMultisig(
@@ -222,7 +269,14 @@ export class X402Service {
222269
resource: this.buildV2Resource(resourceUrl, account.address),
223270
accepted: v2Requirements,
224271
payload: {
225-
authorization: payload.payload.authorization,
272+
// Checksum the EIP-3009 authorization addresses for CDP's strict
273+
// v2 EIP-55 validation. Safe for the signature: EIP-712 encodes
274+
// addresses to the 20-byte value regardless of display casing.
275+
authorization: {
276+
...payload.payload.authorization,
277+
from: getAddress(payload.payload.authorization.from),
278+
to: getAddress(payload.payload.authorization.to),
279+
},
226280
signature: payload.payload.signature,
227281
},
228282
extensions: { bazaar: this.buildCdpBazaarExtension(resourceUrl) },
@@ -408,6 +462,20 @@ export class X402Service {
408462
}
409463
}
410464

465+
// Fail fast (and clearly) when a chain is routed to CDP but CDP cannot settle
466+
// it — otherwise the request would die deep inside cdpVerify with an opaque
467+
// facilitator error. Notably guards Arbitrum Sepolia (eip155:421614), which
468+
// CDP does not support and PayAI rejects, so it has no working facilitator.
469+
private assertCdpNetworkSupported(chainId: number): void {
470+
const network = `eip155:${chainId}`;
471+
if (!CDP_SUPPORTED_NETWORKS.has(network)) {
472+
throw new BadRequestException(
473+
`Chain ${chainId} (${network}) is not supported by the Coinbase CDP ` +
474+
`x402 facilitator. CDP settles: ${[...CDP_SUPPORTED_NETWORKS].join(', ')}.`,
475+
);
476+
}
477+
}
478+
411479
private buildPaymentRequirements(
412480
chainId: number,
413481
payTo: string,
@@ -495,11 +563,20 @@ export class X402Service {
495563
return {
496564
scheme: 'exact',
497565
network: `eip155:${chainId}`,
498-
asset: USDC_TOKEN.addresses[chainId],
566+
// CDP's v2 validator requires EIP-55 checksummed EVM addresses; sending
567+
// lowercase makes the payload fail its oneOf and surface a misleading
568+
// "x402V1PaymentPayload requires 'scheme'" error. Checksum here.
569+
asset: getAddress(USDC_TOKEN.addresses[chainId]),
499570
amount,
500-
payTo,
571+
payTo: getAddress(payTo),
501572
maxTimeoutSeconds: 120,
502573
extra: {
574+
// `assetTransferMethod: "eip3009"` is part of the canonical v2 exact-EVM
575+
// scheme payload (specs/schemes/exact/scheme_exact_evm.md). USDC supports
576+
// transferWithAuthorization natively, so we declare eip3009 explicitly.
577+
assetTransferMethod: 'eip3009',
578+
// EIP-712 domain — required by the facilitator to verify the signature
579+
// against the asset contract for the "exact" scheme on EVM.
503580
name: domain.name,
504581
version: domain.version,
505582
minDeposit: MIN_DEPOSIT.toString(),

0 commit comments

Comments
 (0)