From 0467af598340170a1f4764540bf1ace731e5f067 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 20 Jul 2026 15:57:06 +0545 Subject: [PATCH] fix(x402): select payment asset instead of hardcoding accepts[0] (#613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectTokenType returned 'STX' for any unrecognized asset. Combined with the interceptor taking the first accepts[] entry, a USDCx-denominated 402 (asset SP120...usdcx, amount 29000000) selected USDCx, classified it as STX, and fell into the makeSTXTokenTransfer branch — signing a real 29 STX native transfer for an invoice the endpoint would never credit. The same fallback made the probe render "29 STX" while payment.asset said USDCx. - detectTokenType identifies STX positively and returns 'unsupported' for anything that is not STX or sBTC, so unknown assets are refused not guessed - selectPaymentOption picks the requested asset, else the first payable entry in the server's order; errors list the accepted assets rather than substituting a different one - Add an `asset` param to probe_x402_endpoint and execute_x402_endpoint, and echo it through callWith so executing a quote pays what was quoted - Probe returns accepts[] with a payable flag per asset, and the message text is derived from the same asset the payment block reports - formatPaymentAmount renders unknown tokens in base units with their symbol instead of scaling by 1e6 and labelling them STX - checkSufficientBalance refuses unsupported assets instead of validating STX Selection is deliberately not balance-aware: picking by wallet contents would let a manifest reorder change which asset leaves the wallet. Two existing tests asserted the unknown-asset -> STX fallback as intended behavior and are updated; the substring-guard test keeps its intent. --- src/services/x402.service.ts | 221 ++++++++++++++++++++-- src/tools/endpoint.tools.ts | 42 +++- tests/services/x402-prevalidation.test.ts | 138 +++++++++++++- 3 files changed, 369 insertions(+), 32 deletions(-) diff --git a/src/services/x402.service.ts b/src/services/x402.service.ts index 77342c55..4d7ffd83 100644 --- a/src/services/x402.service.ts +++ b/src/services/x402.service.ts @@ -14,6 +14,7 @@ import { generatePaymentId, buildPaymentIdentifierExtension, X402_HEADERS, + type PaymentRequirementsV2, } from "../utils/x402-protocol.js"; import { generateWallet, getStxAddress } from "@stacks/wallet-sdk"; import { NETWORK, API_URL, getStacksNetwork, type Network } from "../config/networks.js"; @@ -317,6 +318,12 @@ export interface CreateApiClientOptions { */ onBeforePayment?: (requirements: PaymentRequirements) => Promise; toolName?: string; + /** + * Which asset to pay with when the endpoint offers several — an asset + * identifier ("SM3V....sbtc-token") or a symbol ("sBTC", "STX", "USDCx"). + * Unset means the first entry this client can pay for. + */ + asset?: string; } /** @@ -667,19 +674,19 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien ); } - // Select first Stacks-compatible payment option - const selectedOption = paymentRequired.accepts.find( - (opt) => opt.network?.startsWith("stacks:") - ); - - if (!selectedOption) { - const networks = paymentRequired.accepts.map((a) => a.network).join(", "); - return Promise.reject( - new Error(`No compatible Stacks payment option found. Available networks: ${networks}`) - ); + // Select the payment option: the caller's requested asset, else the + // first entry this client can actually sign for. Throws with the list + // of accepted assets rather than falling back to a different one. + let selectedOption: PaymentRequirementsV2; + try { + selectedOption = selectPaymentOption(paymentRequired.accepts, options?.asset); + } catch (selectionError) { + return Promise.reject(selectionError); } - const tokenType = detectTokenType(selectedOption.asset); + // Guaranteed non-'unsupported' by selectPaymentOption, so the branch + // below can never route a third-party token into the native-STX path. + const tokenType = detectTokenType(selectedOption.asset) as 'STX' | 'sBTC'; const amount = BigInt(selectedOption.amount); const isSbtc = tokenType === "sBTC"; const cap = isSbtc ? X402_MAX_SATS_PER_PAYMENT : X402_MAX_USTX_PER_PAYMENT; @@ -870,9 +877,13 @@ export type ProbeResultPaymentRequired = { type: 'payment_required'; amount: string; asset: string; + /** Display symbol for `asset` (e.g. "USDCx"), when derivable. */ + symbol?: string; recipient: string; network: string; endpoint: string; + /** Every asset the endpoint accepts, so the caller can pick another. */ + accepts?: PaymentOptionSummary[]; resource?: { url: string; description?: string; @@ -884,11 +895,24 @@ export type ProbeResultPaymentRequired = { export type ProbeResult = ProbeResultFree | ProbeResultPaymentRequired; /** - * Detect token type from asset identifier + * Token rails this client can actually build and sign a payment for. + * Anything else is `unsupported` — see detectTokenType. + */ +export type PaymentTokenType = 'STX' | 'sBTC' | 'unsupported'; + +/** + * Detect token type from asset identifier. + * + * STX must be identified POSITIVELY, never as a fallback. This function used + * to return 'STX' for any unrecognized asset, which meant a USDCx-denominated + * 402 (asset `SP120...usdcx`, amount `29000000`) was routed to the interceptor's + * native-STX branch and signed as a 29 STX transfer — real funds, wrong asset, + * and the endpoint never credits it. Unknown assets are now `unsupported` so + * the payment is refused instead of silently mis-sent (#613). + * * @param asset - Full contract identifier or token name - * @returns 'STX' for native STX, 'sBTC' for sBTC token */ -export function detectTokenType(asset: string): 'STX' | 'sBTC' { +export function detectTokenType(asset: string): PaymentTokenType { const assetLower = asset.trim().toLowerCase(); // Treat as sBTC if the asset is exactly "sbtc" (token name), // a full contract identifier ending with "::token-sbtc", @@ -896,23 +920,164 @@ export function detectTokenType(asset: string): 'STX' | 'sBTC' { if (assetLower === 'sbtc' || assetLower.endsWith('::token-sbtc') || assetLower.endsWith('.sbtc-token')) { return 'sBTC'; } - return 'STX'; + // Native STX: the bare symbol, or a CAIP-19-style native reference + // ("stacks:1/native" / ".../slip44:5773") used by some manifests. + if (assetLower === 'stx' || /(^|\/)(native|slip44:5773)$/.test(assetLower)) { + return 'STX'; + } + return 'unsupported'; } /** - * Format payment amount into human-readable string with token symbol - * @param amount - Raw amount string (microSTX or satoshis) + * Best-effort display symbol for an asset. Servers advertise a friendly name in + * `extra.tokenType` (arc0btc sends "USDCx"); otherwise fall back to the contract + * name, then the raw identifier. + */ +export function resolveAssetSymbol(asset: string, extra?: Record): string { + const advertised = extra?.tokenType; + if (typeof advertised === 'string' && advertised.trim()) { + return advertised.trim(); + } + const tokenType = detectTokenType(asset); + if (tokenType !== 'unsupported') { + return tokenType; + } + // "SP120....usdcx" -> "usdcx"; strip any "::asset-name" suffix first. + const withoutAssetName = asset.split('::')[0]; + const contractName = withoutAssetName.split('.')[1]; + return contractName || asset; +} + +/** + * Format payment amount into human-readable string with token symbol. + * + * Only STX and sBTC have known decimal scales here. For any other asset the + * amount is rendered in raw base units with an explicit marker rather than + * being run through the STX formatter — dividing a USDCx amount by 1e6 and + * labelling it "STX" is how `29000000` USDCx base units got reported as + * "29 STX" (#613). + * + * @param amount - Raw amount string (microSTX, satoshis, or token base units) * @param asset - Token asset identifier - * @returns Formatted string like "0.000001 sBTC" or "0.001 STX" + * @param extra - Scheme-specific `extra` block from the accepts[] entry + * @returns Formatted string like "0.000001 sBTC", "0.001 STX", or "29000000 USDCx (base units)" */ -export function formatPaymentAmount(amount: string, asset: string): string { +export function formatPaymentAmount( + amount: string, + asset: string, + extra?: Record +): string { const tokenType = detectTokenType(asset); if (tokenType === 'sBTC') { return formatSbtc(amount); } + if (tokenType === 'unsupported') { + // Decimals are unknown, so do not invent a scaled figure. + return `${amount} ${resolveAssetSymbol(asset, extra)} (base units)`; + } return formatStx(amount); } +/** + * Does `requested` name this accepts[] entry? Matches on the full asset + * identifier or on the display symbol, both case-insensitively, so callers can + * say "sBTC", "usdcx", or the full "SM3V....sbtc-token". + */ +function assetMatches(option: PaymentRequirementsV2, requested: string): boolean { + const want = requested.trim().toLowerCase(); + if (!want) return false; + if (option.asset.trim().toLowerCase() === want) return true; + if (resolveAssetSymbol(option.asset, option.extra).toLowerCase() === want) return true; + // "sbtc" should also match the full sbtc-token contract id, and "stx" the + // native entry, without the caller needing the exact string. + const tokenType = detectTokenType(option.asset); + return tokenType !== 'unsupported' && tokenType.toLowerCase() === want; +} + +/** One payment option, summarised for callers choosing between them. */ +export interface PaymentOptionSummary { + asset: string; + symbol: string; + amount: string; + formatted: string; + payTo: string; + /** False when this client cannot build a transaction for the asset. */ + payable: boolean; +} + +export function summarizePaymentOptions( + accepts: PaymentRequirementsV2[] +): PaymentOptionSummary[] { + return accepts.map((opt) => ({ + asset: opt.asset, + symbol: resolveAssetSymbol(opt.asset, opt.extra), + amount: opt.amount, + formatted: formatPaymentAmount(opt.amount, opt.asset, opt.extra), + payTo: opt.payTo, + payable: detectTokenType(opt.asset) !== 'unsupported', + })); +} + +/** + * Choose which entry of `accepts[]` to pay. + * + * Previously this was hardcoded to the first Stacks-network entry, so on a + * multi-asset endpoint the caller got whatever the server happened to list + * first — USDCx on arc0btc — with no way to ask for anything else (#613). + * + * - `preferredAsset` set: that asset or an explicit error naming what IS on + * offer. Never silently substitutes a different asset than the one asked for. + * - unset: the first entry this client can actually pay for, in the server's + * own preference order. Deliberately NOT balance-aware — auto-selecting by + * wallet contents would let a manifest reorder change which asset leaves the + * wallet without the caller ever naming it. + */ +export function selectPaymentOption( + accepts: PaymentRequirementsV2[], + preferredAsset?: string +): PaymentRequirementsV2 { + const stacksOptions = accepts.filter((opt) => opt.network?.startsWith('stacks:')); + if (stacksOptions.length === 0) { + const networks = accepts.map((a) => a.network).join(', ') || 'none'; + throw new Error( + `No compatible Stacks payment option found. Available networks: ${networks}` + ); + } + + const describe = (opts: PaymentRequirementsV2[]) => + opts + .map((o) => `${resolveAssetSymbol(o.asset, o.extra)} (${o.asset})`) + .join(', '); + + if (preferredAsset) { + const match = stacksOptions.find((opt) => assetMatches(opt, preferredAsset)); + if (!match) { + throw new Error( + `Endpoint does not accept "${preferredAsset}". Accepted assets: ${describe(stacksOptions)}.` + ); + } + if (detectTokenType(match.asset) === 'unsupported') { + throw new Error( + `Payment in ${resolveAssetSymbol(match.asset, match.extra)} (${match.asset}) is not supported — ` + + `this client can only sign STX and sBTC payments. ` + + `Accepted assets: ${describe(stacksOptions)}.` + ); + } + return match; + } + + const payable = stacksOptions.find( + (opt) => detectTokenType(opt.asset) !== 'unsupported' + ); + if (!payable) { + throw new Error( + `No payable asset offered: this client can only sign STX and sBTC payments, ` + + `but the endpoint accepts ${describe(stacksOptions)}.` + ); + } + return payable; +} + /** * Probe an endpoint without payment interceptor * Returns either free response data or payment requirements @@ -922,8 +1087,10 @@ export async function probeEndpoint(options: { url: string; params?: Record; data?: Record; + /** Asset to quote (identifier or symbol). Defaults to the first payable entry. */ + asset?: string; }): Promise { - const { method, url, params, data } = options; + const { method, url, params, data, asset: preferredAsset } = options; const axiosInstance = createBaseAxiosInstance(); try { @@ -945,7 +1112,9 @@ export async function probeEndpoint(options: { // If v2 header is successfully parsed, use it if (paymentRequired?.accepts?.length) { - const acceptedPayment = paymentRequired.accepts[0]; + // Pick the requested asset, or the first one we can actually pay — + // NOT blindly accepts[0], which quoted USDCx to sBTC-only wallets (#613). + const acceptedPayment = selectPaymentOption(paymentRequired.accepts, preferredAsset); // Convert CAIP-2 network identifier to human-readable format const network = getNetworkFromStacksChainId(acceptedPayment.network) ?? NETWORK; @@ -954,11 +1123,13 @@ export async function probeEndpoint(options: { type: 'payment_required', amount: acceptedPayment.amount, asset: acceptedPayment.asset, + symbol: resolveAssetSymbol(acceptedPayment.asset, acceptedPayment.extra), recipient: acceptedPayment.payTo, network, endpoint: url, resource: paymentRequired.resource, maxTimeoutSeconds: acceptedPayment.maxTimeoutSeconds, + accepts: summarizePaymentOptions(paymentRequired.accepts), }; } @@ -1051,6 +1222,14 @@ export async function checkSufficientBalance( const tokenType = detectTokenType(asset); const requiredAmount = BigInt(amount); + if (tokenType === 'unsupported') { + // No balance rail for third-party tokens. Refuse rather than falling + // through to the STX check, which would validate the wrong asset (#613). + throw new Error( + `Cannot validate balance for asset ${asset}: this client only supports STX and sBTC payments.` + ); + } + if (tokenType === 'sBTC') { const sbtcService = getSbtcService(account.network); const balanceInfo = await sbtcService.getBalance(account.address); diff --git a/src/tools/endpoint.tools.ts b/src/tools/endpoint.tools.ts index f34cb5ee..aacccc4a 100644 --- a/src/tools/endpoint.tools.ts +++ b/src/tools/endpoint.tools.ts @@ -85,6 +85,7 @@ function buildCallWith(options: { apiUrl?: string; params?: Record; data?: Record; + asset?: string; }): Record { const callWith: Record = { method: options.method, autoApprove: true }; if (options.url) callWith.url = options.url; @@ -92,6 +93,8 @@ function buildCallWith(options: { if (options.apiUrl) callWith.apiUrl = options.apiUrl; if (options.params && Object.keys(options.params).length > 0) callWith.params = options.params; if (options.data && Object.keys(options.data).length > 0) callWith.data = options.data; + // Echo the asset so executing the quote pays in the asset that was quoted. + if (options.asset) callWith.asset = options.asset; return callWith; } @@ -140,18 +143,30 @@ function formatProbeResponse( }); } + // Derive the message from the same asset the machine-readable block reports. + // These used to disagree — the text said "29 STX" while payment.asset was + // USDCx — because the formatter treated every unknown token as STX (#613). const formattedCost = formatPaymentAmount(result.amount, result.asset); const prefix = messagePrefix ?? 'No payment made. '; + const alternatives = (result.accepts ?? []).filter((o) => o.asset !== result.asset); + const altNote = + alternatives.length > 0 + ? ` This endpoint also accepts ${alternatives + .map((o) => `${o.formatted}${o.payable ? '' : ' [not payable by this client]'}`) + .join(', ')} — pass the \`asset\` parameter to pay with one of those instead.` + : ''; return createJsonResponse({ type: 'payment_required', endpoint: `${method} ${fullUrl}`, - message: `${prefix}This endpoint costs ${formattedCost}. To execute and pay, call execute_x402_endpoint with autoApprove: true and the parameters shown in callWith below.`, + message: `${prefix}This endpoint costs ${formattedCost}. To execute and pay, call execute_x402_endpoint with autoApprove: true and the parameters shown in callWith below.${altNote}`, payment: { amount: result.amount, asset: result.asset, + ...(result.symbol && { symbol: result.symbol }), recipient: result.recipient, network: result.network, }, + ...(result.accepts && result.accepts.length > 0 && { accepts: result.accepts }), callWith: buildCallWith(callWithOptions), }); } @@ -306,9 +321,15 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs .optional() .default(false) .describe("Skip cost probe and execute immediately. When false (default), probes first and returns cost info for paid endpoints. When true, executes atomically like before. Free endpoints always execute transparently."), + asset: z + .string() + .optional() + .describe( + "Which asset to pay with when the endpoint accepts several. Accepts a symbol (\"sBTC\", \"STX\") or a full contract identifier. Defaults to the first asset this client can pay. Only STX and sBTC can be signed; requesting any other asset returns an error listing what is accepted." + ), }, }, - async ({ method, url, path, apiUrl, params, data, autoApprove }) => { + async ({ method, url, path, apiUrl, params, data, autoApprove, asset }) => { let fullUrl = ""; try { @@ -317,8 +338,8 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs params = parsed.params; if (!autoApprove) { - const probeResult = await probeEndpoint({ method, url: fullUrl, params, data }); - return formatProbeResponse(probeResult, method, fullUrl, { method, url, path, apiUrl, params, data }); + const probeResult = await probeEndpoint({ method, url: fullUrl, params, data, asset }); + return formatProbeResponse(probeResult, method, fullUrl, { method, url, path, apiUrl, params, data, asset }); } // autoApprove=true: check dedup cache before any network request, then execute @@ -337,6 +358,7 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs const api = await createApiClient(parsed.baseUrl, { toolName: "execute_x402_endpoint", + asset, onBeforePayment: async (requirements) => { // Non-sponsored: sender pays its own gas, so validate STX for the // fee too (sponsored=false is the default, passed explicitly here). @@ -532,9 +554,15 @@ Supported sources: .record(z.string(), z.unknown()) .optional() .describe("Request body for POST/PUT requests"), + asset: z + .string() + .optional() + .describe( + "Quote the cost in this asset when the endpoint accepts several. Accepts a symbol (\"sBTC\", \"STX\") or a full contract identifier. Defaults to the first asset this client can pay; the full accepts[] list is always returned." + ), }, }, - async ({ method, url, path, apiUrl, params, data }) => { + async ({ method, url, path, apiUrl, params, data, asset }) => { let fullUrl = ""; try { @@ -542,8 +570,8 @@ Supported sources: fullUrl = parsed.fullUrl; params = parsed.params; - const result = await probeEndpoint({ method, url: fullUrl, params, data }); - return formatProbeResponse(result, method, fullUrl, { method, url, path, apiUrl, params, data }); + const result = await probeEndpoint({ method, url: fullUrl, params, data, asset }); + return formatProbeResponse(result, method, fullUrl, { method, url, path, apiUrl, params, data, asset }); } catch (error) { return formatEndpointError(error, fullUrl || "unknown"); } diff --git a/tests/services/x402-prevalidation.test.ts b/tests/services/x402-prevalidation.test.ts index 41c3894c..5b63fa10 100644 --- a/tests/services/x402-prevalidation.test.ts +++ b/tests/services/x402-prevalidation.test.ts @@ -36,6 +36,9 @@ const { formatPaymentAmount, createApiClient, getAccount, + selectPaymentOption, + summarizePaymentOptions, + resolveAssetSymbol, } = await import("../../src/services/x402.service.js"); const { InsufficientBalanceError } = await import("../../src/utils/errors.js"); @@ -81,13 +84,24 @@ describe("detectTokenType", () => { expect(detectTokenType("SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token")).toBe("sBTC"); }); - it("returns STX for unknown asset identifiers", () => { - expect(detectTokenType("some-random-token")).toBe("STX"); - expect(detectTokenType("")).toBe("STX"); + // Previously every unrecognized asset fell back to "STX", which routed a + // USDCx-denominated 402 into the interceptor's native-STX branch and signed a + // real STX transfer for it. Unknown assets must be refused, not guessed (#613). + it("returns unsupported for unknown asset identifiers", () => { + expect(detectTokenType("some-random-token")).toBe("unsupported"); + expect(detectTokenType("")).toBe("unsupported"); + expect(detectTokenType("SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx")).toBe( + "unsupported" + ); + }); + + it("recognizes CAIP-19-style native STX references", () => { + expect(detectTokenType("stacks:1/native")).toBe("STX"); + expect(detectTokenType("stacks:1/slip44:5773")).toBe("STX"); }); it("does not false-match contract names containing sbtc-token as substring", () => { - expect(detectTokenType("SP123.not-sbtc-token-wrapper")).toBe("STX"); + expect(detectTokenType("SP123.not-sbtc-token-wrapper")).toBe("unsupported"); }); }); @@ -101,6 +115,122 @@ describe("formatPaymentAmount", () => { expect(formatPaymentAmount("100", "sbtc")).toBe("0.000001 sBTC"); expect(formatPaymentAmount("100000000", "sbtc")).toBe("1 sBTC"); }); + + // The probe reported "This endpoint costs 29 STX" while payment.asset said + // USDCx, because the unknown asset was scaled by 1e6 and labelled STX (#613). + it("never labels a third-party token as STX", () => { + const formatted = formatPaymentAmount( + "29000000", + "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx", + { tokenType: "USDCx" } + ); + expect(formatted).not.toMatch(/STX/); + expect(formatted).toContain("USDCx"); + // Decimals are unknown, so the raw base units must survive unscaled. + expect(formatted).toContain("29000000"); + }); + + it("falls back to the contract name when the server advertises no symbol", () => { + expect(resolveAssetSymbol("SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx")).toBe( + "usdcx" + ); + }); +}); + +// The real payment-required header served by arc0btc.com, which is the endpoint +// in #613. Order matters: USDCx is listed first, so accepts[0] selection sent +// sBTC-only wallets down the USDCx path. +const ARC_ACCEPTS = [ + { + scheme: "exact", + network: "stacks:1", + amount: "29000000", + asset: "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx", + payTo: "SP2GHQRCRMYY4S8PMBR49BEKX144VR437YT42SF3B", + maxTimeoutSeconds: 3600, + extra: { tokenType: "USDCx" }, + }, + { + scheme: "exact", + network: "stacks:1", + amount: "45385", + asset: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token", + payTo: "SP2GHQRCRMYY4S8PMBR49BEKX144VR437YT42SF3B", + maxTimeoutSeconds: 3600, + extra: { tokenType: "sBTC" }, + }, + { + scheme: "exact", + network: "stacks:1", + amount: "159911365", + asset: "STX", + payTo: "SP2GHQRCRMYY4S8PMBR49BEKX144VR437YT42SF3B", + maxTimeoutSeconds: 3600, + extra: { tokenType: "STX" }, + }, +] as never as Parameters[0]; + +describe("selectPaymentOption (#613)", () => { + it("skips the unpayable first entry instead of picking accepts[0]", () => { + // The old code took accepts[0] (USDCx) and, because detectTokenType fell + // back to STX, would have signed a 29 STX native transfer for it. + const selected = selectPaymentOption(ARC_ACCEPTS); + expect(selected.asset).toBe( + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token" + ); + expect(selected.amount).toBe("45385"); + }); + + it("honors an explicit asset symbol", () => { + expect(selectPaymentOption(ARC_ACCEPTS, "STX").amount).toBe("159911365"); + expect(selectPaymentOption(ARC_ACCEPTS, "sBTC").amount).toBe("45385"); + // Case-insensitive, and full contract identifiers work too. + expect(selectPaymentOption(ARC_ACCEPTS, "sbtc").amount).toBe("45385"); + expect( + selectPaymentOption( + ARC_ACCEPTS, + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token" + ).amount + ).toBe("45385"); + }); + + it("refuses an accepted-but-unsignable asset rather than substituting another", () => { + // USDCx IS accepted by the endpoint, but this client cannot sign it. The + // caller must get an error, never a silent swap to STX. + expect(() => selectPaymentOption(ARC_ACCEPTS, "USDCx")).toThrow( + /not supported/i + ); + }); + + it("errors with the accepted list when the asset is not offered at all", () => { + expect(() => selectPaymentOption(ARC_ACCEPTS, "DOGE")).toThrow( + /does not accept "DOGE"/ + ); + expect(() => selectPaymentOption(ARC_ACCEPTS, "DOGE")).toThrow(/sBTC/); + }); + + it("errors when no offered asset is payable", () => { + const usdcxOnly = [ARC_ACCEPTS[0]] as typeof ARC_ACCEPTS; + expect(() => selectPaymentOption(usdcxOnly)).toThrow(/No payable asset/i); + }); + + it("rejects non-Stacks networks", () => { + const evmOnly = [ + { ...ARC_ACCEPTS[0], network: "eip155:1" }, + ] as typeof ARC_ACCEPTS; + expect(() => selectPaymentOption(evmOnly)).toThrow(/No compatible Stacks/i); + }); +}); + +describe("summarizePaymentOptions (#613)", () => { + it("reports every asset with a payability flag so callers can choose", () => { + const summary = summarizePaymentOptions(ARC_ACCEPTS); + expect(summary).toHaveLength(3); + expect(summary.map((o) => o.symbol)).toEqual(["USDCx", "sBTC", "STX"]); + expect(summary.map((o) => o.payable)).toEqual([false, true, true]); + expect(summary[1].formatted).toBe("0.00045385 sBTC"); + expect(summary[2].formatted).toBe("159.911365 STX"); + }); }); describe("generateDedupKey", () => {