diff --git a/api/package.json b/api/package.json index de294d2d..1e8d04ff 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", -"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", +"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/x402-discovery-rails.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", "test:verify-resend": "node tests/verify-resend.test.mjs", diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts index 67778ad4..bd49e24e 100644 --- a/api/src/middleware/x402.ts +++ b/api/src/middleware/x402.ts @@ -88,6 +88,44 @@ const USDT_CONTRACTS: Record = { bsc: "0x55d398326f99059fF775485246999027B3197955", // USDT on BSC (BEP-20) — $40B+ liquidity }; +// CDP Facilitator supported networks ONLY. +// CDP supports: Base, Polygon, Solana — for ERC-20 tokens (USDC via EIP-3009, any ERC-20 via Permit2). +// CDP does NOT support: Avalanche, Ethereum mainnet, Arbitrum, Optimism, native ETH, native SOL, or any non-ERC-20. +// Source: https://docs.cdp.coinbase.com/x402/network-support +const CDP_SUPPORTED_NETWORKS = new Set([ + "base", + "base-sepolia", + "eip155:8453", + "eip155:84532", + "polygon", + "polygon-amoy", + "eip155:137", + "eip155:80002", + "solana", + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "solana-devnet", + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", +]); + +export function filterCdpSupportedAccepts>(accepts: T[]): T[] { + return accepts + .map((a) => { + // Normalize Solana to full CAIP-2 — @x402/svm registerV1 uses this exact string. + if (a.network === "solana:mainnet" || a.network === "solana") { + return { ...a, network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" }; + } + return a; + }) + .filter((a) => { + if (!CDP_SUPPORTED_NETWORKS.has(a.network)) return false; + if (a.asset === "native") return false; // native SOL — CDP can't handle + if (a.asset === "0x0000000000000000000000000000000000000000") return false; // native ETH + if (a.extra?.version === "native" || a.extra?.version === "native-base") return false; // no native ETH/SOL + if (a.extra?.version === "usol-erc20" || a.asset === "0x311935cd80b76769bf2ecc9d8ab7635b2139cf82") return false; + return true; + }); +} + // Aptos native USDC token address (Circle native, launched Jan 2025) const APTOS_USDC_ADDRESS = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"; @@ -718,49 +756,7 @@ export function buildPaymentRequired(toolName: string, price: string): object { } } - // CDP Facilitator supported networks ONLY. - // CDP supports: Base, Polygon, Solana — for ERC-20 tokens (USDC via EIP-3009, any ERC-20 via Permit2). - // CDP does NOT support: Avalanche, Ethereum mainnet, Arbitrum, Optimism, native ETH, native SOL, or any non-ERC-20. - // Source: https://docs.cdp.coinbase.com/x402/network-support - // CDP supports ONLY these exact network identifiers. Named aliases (e.g. "base", "polygon") are NOT - // used here intentionally — they would also match native ETH/SOL options that CDP cannot handle. - // All CDP-supported options in buildPaymentRequired use CAIP-2 format (eip155:*) or "solana". - // CDP supports these networks. Use x402 named format (base/polygon/solana) in accepts[]. - // CAIP-2 (eip155:*) variants included for internal verify/settle calls that may use them. - const CDP_SUPPORTED_NETWORKS = new Set([ - "base", // Base mainnet ✅ (named — used in accepts[]) - "base-sepolia", // Base Sepolia ✅ - "eip155:8453", // Base mainnet ✅ (CAIP-2 — used in verify/settle) - "eip155:84532", // Base Sepolia ✅ - "polygon", // Polygon mainnet ✅ (named — used in accepts[]) - "polygon-amoy", // Polygon Amoy ✅ - "eip155:137", // Polygon mainnet ✅ (CAIP-2 — used in verify/settle) - "eip155:80002", // Polygon Amoy ✅ - "solana", // Solana mainnet (short alias) ✅ - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", // Solana mainnet (full CAIP-2) ✅ - "solana-devnet", // Solana devnet ✅ - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", // Solana devnet (full CAIP-2) ✅ - ]); - - // Filter to only CDP-supported networks. - // Normalize Solana network to full CAIP-2 format required by @x402/svm client SDK. - const filteredAccepts = accepts - .map((a: any) => { - // Normalize Solana to full CAIP-2 — @x402/svm registerV1 uses this exact string - if (a.network === "solana:mainnet" || a.network === "solana") { - a.network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; - } - return a; - }) - .filter((a: any) => { - if (!CDP_SUPPORTED_NETWORKS.has(a.network)) return false; - if (a.asset === "native") return false; // native SOL — CDP can't handle - if (a.asset === "0x0000000000000000000000000000000000000000") return false; // native ETH - if (a.extra?.version === "native" || a.extra?.version === "native-base") return false; // no native ETH/SOL - if (a.extra?.version === "usol-erc20") return false; // no wrapped SOL on Base — CDP can't verify this - if (a.extra?.version === "usol-erc20" || a.asset === "0x311935cd80b76769bf2ecc9d8ab7635b2139cf82") return false; - return true; - }); + const filteredAccepts = filterCdpSupportedAccepts(accepts as Array>); return { x402Version: 1, diff --git a/api/src/routes/discovery.ts b/api/src/routes/discovery.ts index 022ea070..0a83b922 100644 --- a/api/src/routes/discovery.ts +++ b/api/src/routes/discovery.ts @@ -1,7 +1,7 @@ import { Router, Request, Response } from "express"; import { prisma } from "../lib/prisma.js"; import { redis } from "../lib/redis.js"; -import { X402_PRICES } from "../middleware/x402.js"; +import { X402_PRICES, filterCdpSupportedAccepts } from "../middleware/x402.js"; import { getStatusPageData } from "../middleware/analytics.js"; import { config } from "../config.js"; import { isValidAdminKey } from "../middleware/auth.js"; @@ -144,7 +144,7 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { const suiWallet = process.env.SUI_WALLET_ADDRESS ?? ""; const polkadotWallet = process.env.POLKADOT_WALLET_ADDRESS ?? ""; const aptosWallet = process.env.APTOS_WALLET_ADDRESS ?? ""; - const usdtWallet = process.env.USDT_ETH_WALLET_ADDRESS ?? ""; + const usdtWallet = evmWallet; const ethWallet = process.env.ETH_WALLET_ADDRESS ?? ""; const bnbWallet = process.env.BNB_WALLET_ADDRESS ?? ""; const nearWallet = process.env.NEAR_WALLET_ADDRESS ?? ""; @@ -172,8 +172,7 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { }; // Build accepts array from live wallet config - const accepts: object[] = []; - const activeNetworks: string[] = []; + const accepts: Array> = []; // USDC on EVM chains (Base, Ethereum, Arbitrum, Polygon, Optimism, Avalanche, Unichain, Monad) if (evmWallet) { @@ -189,7 +188,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { ]; for (const { network, chain, description } of evmUsdcChains) { accepts.push({ scheme: "exact", network, asset: USDC_CONTRACTS[chain], payTo: evmWallet, token: "USDC", description }); - activeNetworks.push(network); } } @@ -204,7 +202,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { token: "USDC", description: "USDC on Solana (fast, cheap)", }); - activeNetworks.push("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"); } // USDC on Noble/Cosmos @@ -217,7 +214,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { token: "USDC", description: "USDC on Noble/Cosmos (IBC to 50+ Cosmos chains)", }); - activeNetworks.push("cosmos:noble-1"); } // USDC on Algorand @@ -230,7 +226,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { token: "USDC", description: "USDC on Algorand (ASA #31566704)", }); - activeNetworks.push("algorand:mainnet"); } // USDC on Stellar @@ -244,7 +239,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { description: "USDC on Stellar (17-sec settlement)", memo: stellarMemo || undefined, }); - activeNetworks.push("stellar:pubnet"); } // USDC on Sui @@ -257,7 +251,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { token: "USDC", description: "USDC on Sui (Move L1, Circle CCTP)", }); - activeNetworks.push("sui:mainnet"); } // USDC on Polkadot Asset Hub @@ -270,7 +263,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { token: "USDC", description: "USDC on Polkadot Asset Hub (XCM to all parachains)", }); - activeNetworks.push("polkadot:asset-hub"); } // USDC on Aptos @@ -283,7 +275,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { token: "USDC", description: "USDC on Aptos (Move L1, native Circle)", }); - activeNetworks.push("aptos:mainnet"); } // USDT on EVM chains @@ -321,13 +312,11 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { // Native BNB if (bnbWallet) { accepts.push({ scheme: "exact", network: "eip155:56", asset: "0x0000000000000000000000000000000000000000", payTo: bnbWallet, token: "BNB", description: "Native BNB on BNB Chain" }); - activeNetworks.push("eip155:56"); } // Native NEAR if (nearWallet) { accepts.push({ scheme: "exact", network: "near:mainnet", asset: "near", payTo: nearWallet, token: "NEAR", description: "Native NEAR on NEAR Protocol" }); - activeNetworks.push("near:mainnet"); } // Native SOL @@ -338,7 +327,6 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { // TAO (Bittensor) if (taoWallet) { accepts.push({ scheme: "exact", network: "bittensor:finney", asset: "TAO", payTo: taoWallet, token: "TAO", description: "TAO on Bittensor (AI-native blockchain)" }); - activeNetworks.push("bittensor:finney"); } // UNI (Uniswap) @@ -352,6 +340,9 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { price: `$${price}`, description: TOOL_DESCRIPTIONS[tool] ?? tool, })); + const supportedRails = filterCdpSupportedAccepts(accepts); + const supportedNetworks = [...new Set(supportedRails.map((rail) => String(rail.network)))]; + const supportedTokens = [...new Set(supportedRails.map((rail) => String(rail.token)).filter(Boolean))]; res.json({ name: "Arch Tools", @@ -371,13 +362,13 @@ router.get("/.well-known/x402", (_req: Request, res: Response): void => { // mention in coinbase/x402 specs/) and x402scan's discovery parser dropped it // (@agentcash/discovery 1.7.5 SPECIFICATION.md: "Legacy /.well-known/x402 ... // no longer parsed" — it discovers via /openapi.json). - supportedRails: accepts, + supportedRails, rails_note: "supportedRails is a chain/asset/wallet summary for humans and crawlers. Spec-shaped x402 v2 PaymentRequirements (CAIP-2 network, amount, maxTimeoutSeconds) are served in the accepts[] of each tool's 402 challenge.", endpoints, payment: { stripe: { url: `${BASE_URL}/pricing` }, - x402: { status: "active", networks: [...new Set(activeNetworks)], token: "USDC/USDT/ETH/BNB/NEAR/SOL/TAO/UNI" }, + x402: { status: "active", networks: supportedNetworks, token: supportedTokens.join("/") || "USDC" }, }, mcp: { server: "arch-tools-mcp", diff --git a/api/tests/x402-discovery-rails.test.mjs b/api/tests/x402-discovery-rails.test.mjs new file mode 100644 index 00000000..9fc7f395 --- /dev/null +++ b/api/tests/x402-discovery-rails.test.mjs @@ -0,0 +1,79 @@ +/** + * x402 discovery rails — regression test for /.well-known/x402. + * + * The discovery endpoint must not advertise rails that the real x402 402 + * challenge filters out before verify/settle. This mounts the compiled router + * with deliberately over-broad wallet envs and asserts the public metadata only + * exposes CDP-supported rails. + * + * Run: cd api && npm run build && node tests/x402-discovery-rails.test.mjs + */ +import assert from "assert"; +import express from "express"; + +process.env.PUBLIC_SITE_URL = "https://archtools.dev"; +process.env.WALLET_ADDRESS = "0x2583aAc89f58a63D9CCbeDaa5e3BaF2196Aa967e"; +process.env.SOLANA_WALLET_ADDRESS = "D6ZhtNQ5nT9ZnTHUbqXZsTx5MH2rPFiBBggX4hY1WePM"; +process.env.NOBLE_WALLET_ADDRESS = "noble1unsupported"; +process.env.ALGORAND_WALLET_ADDRESS = "ALGO_UNSUPPORTED"; +process.env.STELLAR_WALLET_ADDRESS = "STELLAR_UNSUPPORTED"; +process.env.SUI_WALLET_ADDRESS = "0xsuiunsupported"; +process.env.POLKADOT_WALLET_ADDRESS = "polkadotUnsupported"; +process.env.APTOS_WALLET_ADDRESS = "0xaptosunsupported"; +process.env.ETH_WALLET_ADDRESS = "0x2583aAc89f58a63D9CCbeDaa5e3BaF2196Aa967e"; +process.env.BNB_WALLET_ADDRESS = "0x2583aAc89f58a63D9CCbeDaa5e3BaF2196Aa967e"; +process.env.NEAR_WALLET_ADDRESS = "nearunsupported"; +process.env.SOL_NATIVE_WALLET_ADDRESS = "SOL_UNSUPPORTED"; +process.env.TAO_WALLET_ADDRESS = "taounsupported"; +process.env.UNI_WALLET_ADDRESS = "0x2583aAc89f58a63D9CCbeDaa5e3BaF2196Aa967e"; + +const { default: discoveryRouter } = await import("../dist/routes/discovery.js"); + +const app = express(); +app.use(discoveryRouter); +const server = app.listen(0); + +try { + const { port } = server.address(); + const res = await fetch(`http://127.0.0.1:${port}/.well-known/x402`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + const rails = body.supportedRails; + assert.ok(Array.isArray(rails), "supportedRails must be an array"); + + const railNetworks = rails.map((r) => r.network); + const networkSet = new Set(railNetworks); + assert.ok(networkSet.has("eip155:8453"), "Base USDC/USDT rail missing"); + assert.ok(networkSet.has("eip155:137"), "Polygon USDC/USDT rail missing"); + assert.ok(networkSet.has("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"), "Solana USDC rail missing"); + + const forbiddenNetworks = new Set([ + "eip155:1", + "eip155:42161", + "eip155:10", + "eip155:43114", + "eip155:130", + "eip155:143", + "cosmos:noble-1", + "algorand:mainnet", + "stellar:pubnet", + "sui:mainnet", + "polkadot:asset-hub", + "aptos:mainnet", + "eip155:56", + "near:mainnet", + "bittensor:finney", + ]); + for (const rail of rails) { + assert.ok(!forbiddenNetworks.has(rail.network), `unsupported network leaked: ${rail.network}`); + assert.notStrictEqual(rail.asset, "native", `native asset leaked on ${rail.network}`); + assert.notStrictEqual(rail.asset, "0x0000000000000000000000000000000000000000", `native EVM asset leaked on ${rail.network}`); + } + + const summaryNetworks = body.payment?.x402?.networks ?? []; + assert.deepStrictEqual(new Set(summaryNetworks), networkSet, "payment.x402.networks must mirror filtered rails"); + assert.strictEqual(body.payment?.x402?.token, "USDC/USDT"); + console.log("x402-discovery-rails: ALL PASS"); +} finally { + await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())); +}