diff --git a/scripts/live-probe-arc.ts b/scripts/live-probe-arc.ts new file mode 100644 index 00000000..fb021782 --- /dev/null +++ b/scripts/live-probe-arc.ts @@ -0,0 +1,14 @@ +import { probeEndpoint } from "../src/services/x402.service.ts"; + +async function main() { + const url = "https://arc0btc.com/api/reports/arc-field-guide"; + console.log("PROBE", url, "asset=sBTC"); + try { + const result = await probeEndpoint({ method: "GET", url, asset: "sBTC" }); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error("ERROR", e); + process.exit(1); + } +} +main(); diff --git a/src/services/x402.service.ts b/src/services/x402.service.ts index 77342c55..42a49249 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"; @@ -94,6 +95,12 @@ const X402_MAX_SATS_PER_PAYMENT = parseSatsCap( "X402_MAX_SATS_PER_PAYMENT", 10_000 ); +// Atomic units for SIP-010 assets other than sBTC (e.g. USDCx 6-decimals). +// Default 100 USDC-equivalent at 6 decimals; raise via env for larger invoices. +const X402_MAX_SIP010_ATOMIC_PER_PAYMENT = parseSatsCap( + "X402_MAX_SIP010_ATOMIC_PER_PAYMENT", + 100_000_000 +); // Track payment attempts per client instance (auto-cleanup via WeakMap) const paymentAttempts: WeakMap = new WeakMap(); @@ -317,6 +324,8 @@ export interface CreateApiClientOptions { */ onBeforePayment?: (requirements: PaymentRequirements) => Promise; toolName?: string; + /** Prefer this asset symbol/contract when selecting accepts[] (e.g. "sBTC"). */ + preferredAsset?: string; } /** @@ -667,9 +676,10 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien ); } - // Select first Stacks-compatible payment option - const selectedOption = paymentRequired.accepts.find( - (opt) => opt.network?.startsWith("stacks:") + // Select Stacks-compatible payment option (optional preferred asset) + const selectedOption = selectPaymentOption( + paymentRequired.accepts, + options?.preferredAsset ); if (!selectedOption) { @@ -682,14 +692,28 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien const tokenType = detectTokenType(selectedOption.asset); const amount = BigInt(selectedOption.amount); const isSbtc = tokenType === "sBTC"; - const cap = isSbtc ? X402_MAX_SATS_PER_PAYMENT : X402_MAX_USTX_PER_PAYMENT; - const unit = isSbtc ? "sats" : "uSTX"; + const isStx = tokenType === "STX"; + let cap: number; + let unit: string; + let capEnv: string; + if (isSbtc) { + cap = X402_MAX_SATS_PER_PAYMENT; + unit = "sats"; + capEnv = "X402_MAX_SATS_PER_PAYMENT"; + } else if (isStx) { + cap = X402_MAX_USTX_PER_PAYMENT; + unit = "uSTX"; + capEnv = "X402_MAX_USTX_PER_PAYMENT"; + } else { + cap = X402_MAX_SIP010_ATOMIC_PER_PAYMENT; + unit = "atomic"; + capEnv = "X402_MAX_SIP010_ATOMIC_PER_PAYMENT"; + } if (amount > BigInt(Math.floor(cap))) { return Promise.reject( new Error( `x402 payment of ${amount} ${unit} exceeds the per-payment cap of ${cap} ${unit}. ` + - `If this cost is expected, raise the cap via the ` + - `${isSbtc ? "X402_MAX_SATS_PER_PAYMENT" : "X402_MAX_USTX_PER_PAYMENT"} env var.` + `If this cost is expected, raise the cap via the ${capEnv} env var.` ) ); } @@ -708,10 +732,13 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien ); } - // Cumulative spending limit: the per-payment cap above bounds a single - // 402, but a malicious endpoint can loop sub-cap payments. This blocks - // once the session/day total would be exceeded. - await getSpendLimiter().check(isSbtc ? "sats" : "ustx", amount, acct.address); + // Cumulative spending limit for STX/sBTC micro-payments. + // Other SIP-010 assets are bounded by X402_MAX_SIP010_ATOMIC_PER_PAYMENT + balance checks. + if (isSbtc) { + await getSpendLimiter().check("sats", amount, acct.address); + } else if (isStx) { + await getSpendLimiter().check("ustx", amount, acct.address); + } // Invoke pre-payment callback (e.g. balance check) before signing/broadcasting. // If the callback throws, the payment is aborted and the error propagates to the caller. @@ -733,11 +760,45 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien const networkName = getStacksNetwork(acct.network); let transaction; - if (tokenType === "sBTC") { - const contracts = getContracts(acct.network); - const { address: contractAddress, name: contractName } = parseContractId( - contracts.SBTC_TOKEN - ); + if (tokenType === "STX") { + transaction = await makeSTXTokenTransfer({ + recipient: selectedOption.payTo, + amount, + senderKey: acct.privateKey, + network: networkName, + memo: "", + fee: await resolveDefaultFee(acct.network, "token_transfer"), + }); + } else { + // sBTC and other SIP-010 assets (e.g. USDCx): contract-call transfer + const contractId = + tokenType === "sBTC" + ? getContracts(acct.network).SBTC_TOKEN + : selectedOption.asset; + if (!contractId || !contractId.includes(".")) { + return Promise.reject( + new Error( + `Cannot build SIP-010 payment: asset "${selectedOption.asset}" is not a contract id ` + + `(expected Address.contract-name).` + ) + ); + } + const { address: contractAddress, name: contractName } = parseContractId(contractId); + + let tokenName = contractName; + if (tokenType === "sBTC") { + tokenName = "sbtc-token"; + } else { + try { + const iface = await getHiroApi(acct.network).getContractInterface(contractId); + const fts = iface?.fungible_tokens ?? []; + if (fts.length > 0 && fts[0]?.name) { + tokenName = fts[0].name; + } + } catch { + // Fall back to contract name segment + } + } transaction = await makeContractCall({ contractAddress, @@ -751,28 +812,14 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien ], senderKey: acct.privateKey, network: networkName, - // The payment amount is known at build time, so pin it: a - // compromised token contract must not be able to pull more. postConditionMode: PostConditionMode.Deny, postConditions: [ Pc.principal(acct.address) .willSendEq(amount) - .ft(contracts.SBTC_TOKEN as `${string}.${string}`, "sbtc-token"), + .ft(contractId as `${string}.${string}`, tokenName), ], - // Clamped medium fee — do NOT let @stacks auto-estimate, since - // Hiro's contract_call high_priority tier is polluted by outliers - // (observed >2000 STX). resolveDefaultFee caps it at 0.05 STX. fee: await resolveDefaultFee(acct.network, "contract_call"), }); - } else { - transaction = await makeSTXTokenTransfer({ - recipient: selectedOption.payTo, - amount, - senderKey: acct.privateKey, - network: networkName, - memo: "", - fee: await resolveDefaultFee(acct.network, "token_transfer"), - }); } const txHex = "0x" + transaction.serialize(); @@ -888,7 +935,9 @@ export type ProbeResult = ProbeResultFree | ProbeResultPaymentRequired; * @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 type PaymentTokenType = 'STX' | 'sBTC' | 'USDCx' | 'other'; + +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,9 +945,90 @@ export function detectTokenType(asset: string): 'STX' | 'sBTC' { if (assetLower === 'sbtc' || assetLower.endsWith('::token-sbtc') || assetLower.endsWith('.sbtc-token')) { return 'sBTC'; } + if ( + assetLower === 'usdcx' || + assetLower === 'usdc' || + assetLower.endsWith('.usdcx') || + assetLower.endsWith('::usdcx') || + assetLower.includes('usdcx') + ) { + return 'USDCx'; + } + // Native STX is typically represented as empty, "stx", or a known native marker + if (assetLower === '' || assetLower === 'stx' || assetLower === 'native' || assetLower.endsWith('::stx')) { + return 'STX'; + } + // Unknown SIP-010 assets: do not mislabel as STX in display text + if (assetLower.includes('.')) { + return 'other'; + } return 'STX'; } +/** + * Select an accepts[] payment option for Stacks. + * - If `asset` is provided (symbol or contract id), prefer matching entry. + * - Else prefer sBTC, then STX, then first Stacks-compatible option. + */ +/** + * Map asset contract id / symbol to a stable display name for selection. + * Aligned with detectTokenType() so symbol filters like "sBTC" match contract ids. + */ +export function getAssetDisplayName(asset: string): string { + const t = detectTokenType(asset); + if (t === 'sBTC') return 'sBTC'; + if (t === 'USDCx') return 'USDCx'; + if (t === 'STX') return 'STX'; + const short = asset.includes('.') ? asset.split('.').pop() || asset : asset; + return short || asset; +} + +/** + * Select an accepts[] payment option for Stacks. + * - If `asset` is provided (symbol or contract id), prefer matching entry. + * - Else prefer sBTC, then STX, then first Stacks-compatible option. + */ +export function selectPaymentOption( + accepts: PaymentRequirementsV2[], + asset?: string +): PaymentRequirementsV2 | undefined; +export function selectPaymentOption( + accepts: T[], + asset?: string +): T | undefined; +export function selectPaymentOption( + accepts: T[], + asset?: string +): T | undefined { + const stacksOpts = accepts.filter((opt) => opt.network?.startsWith('stacks:')); + const pool = stacksOpts.length ? stacksOpts : accepts; + if (!pool.length) return undefined; + + if (asset && asset.trim()) { + const want = asset.trim().toLowerCase(); + const match = pool.find((opt) => { + const a = (opt.asset || '').toLowerCase(); + if (a === want) return true; + const display = getAssetDisplayName(opt.asset || '').toLowerCase(); + if (display === want) return true; + if (a.endsWith('.' + want) || a.endsWith('::' + want)) return true; + // symbol aliases via detectTokenType (aligned with display names) + if (want === 'sbtc' && detectTokenType(opt.asset || '') === 'sBTC') return true; + if ((want === 'usdcx' || want === 'usdc') && detectTokenType(opt.asset || '') === 'USDCx') return true; + if (want === 'stx' && detectTokenType(opt.asset || '') === 'STX') return true; + return false; + }); + if (match) return match; + } + + // Default preference when no explicit asset: sBTC > STX > first + const sbtc = pool.find((opt) => detectTokenType(opt.asset || '') === 'sBTC'); + if (sbtc) return sbtc; + const stx = pool.find((opt) => detectTokenType(opt.asset || '') === 'STX'); + if (stx) return stx; + return pool[0]; +} + /** * Format payment amount into human-readable string with token symbol * @param amount - Raw amount string (microSTX or satoshis) @@ -910,6 +1040,16 @@ export function formatPaymentAmount(amount: string, asset: string): string { if (tokenType === 'sBTC') { return formatSbtc(amount); } + if (tokenType === 'USDCx') { + // USDCx uses 6 decimals like USDC + const n = Number(amount) / 1_000_000; + return `${n} USDCx`; + } + if (tokenType === 'other') { + // Avoid claiming "STX" for unknown SIP-010 assets + const short = asset.includes('.') ? asset.split('.').pop() || asset : asset; + return `${amount} ${short}`; + } return formatStx(amount); } @@ -922,6 +1062,8 @@ export async function probeEndpoint(options: { url: string; params?: Record; data?: Record; + /** Optional asset symbol or contract id to select from accepts[] */ + asset?: string; }): Promise { const { method, url, params, data } = options; const axiosInstance = createBaseAxiosInstance(); @@ -945,7 +1087,10 @@ export async function probeEndpoint(options: { // If v2 header is successfully parsed, use it if (paymentRequired?.accepts?.length) { - const acceptedPayment = paymentRequired.accepts[0]; + const acceptedPayment = selectPaymentOption(paymentRequired.accepts, options.asset); + if (!acceptedPayment) { + throw new Error(`No matching payment option for asset filter ${options.asset ?? '(default)'} on ${url}`); + } // Convert CAIP-2 network identifier to human-readable format const network = getNetworkFromStacksChainId(acceptedPayment.network) ?? NETWORK; @@ -1051,7 +1196,27 @@ export async function checkSufficientBalance( const tokenType = detectTokenType(asset); const requiredAmount = BigInt(amount); - if (tokenType === 'sBTC') { + const ensureStxForContractCall = async (label: string): Promise => { + if (sponsored) return; + const hiroApi = getHiroApi(account.network); + const stxInfo = await hiroApi.getStxBalance(account.address); + const stxBalance = BigInt(stxInfo.balance); + const estimatedFee = await resolveDefaultFee(account.network, "contract_call"); + if (stxBalance < estimatedFee) { + const stxShortfall = estimatedFee - stxBalance; + throw new InsufficientBalanceError( + `Insufficient STX balance to cover ${label} transfer fee: need ${formatStx(estimatedFee.toString())} estimated fee, ` + + `have ${formatStx(stxInfo.balance)} (shortfall: ${formatStx(stxShortfall.toString())}). ` + + `Deposit more STX or use a different wallet.`, + "STX", + stxInfo.balance, + estimatedFee.toString(), + stxShortfall.toString() + ); + } + }; + + if (tokenType === "sBTC") { const sbtcService = getSbtcService(account.network); const balanceInfo = await sbtcService.getBalance(account.address); const balance = BigInt(balanceInfo.balance); @@ -1060,65 +1225,71 @@ export async function checkSufficientBalance( const shortfall = requiredAmount - balance; throw new InsufficientBalanceError( `Insufficient sBTC balance: need ${formatSbtc(amount)}, have ${formatSbtc(balanceInfo.balance)} (shortfall: ${formatSbtc(shortfall.toString())}). ` + - `Deposit more sBTC via the bridge at https://bridge.stx.eco or use a different wallet.`, - 'sBTC', + `Deposit more sBTC via the bridge at https://bridge.stx.eco or use a different wallet.`, + "sBTC", balanceInfo.balance, amount, shortfall.toString() ); } - // sBTC transfers are contract calls that also require STX for gas fees, - // unless the transaction is sponsored (relay pays gas; fee: 0n). - if (!sponsored) { - const hiroApiForSbtc = getHiroApi(account.network); - const stxInfoForSbtc = await hiroApiForSbtc.getStxBalance(account.address); - const stxBalanceForSbtc = BigInt(stxInfoForSbtc.balance); - // Same clamped fee the interceptor actually sets on the sBTC contract - // call — so this check is exact, not a high-priority over-estimate. - const estimatedSbtcFee = await resolveDefaultFee(account.network, "contract_call"); - - if (stxBalanceForSbtc < estimatedSbtcFee) { - const stxShortfall = estimatedSbtcFee - stxBalanceForSbtc; - throw new InsufficientBalanceError( - `Insufficient STX balance to cover sBTC transfer fee: need ${formatStx(estimatedSbtcFee.toString())} estimated fee, ` + - `have ${formatStx(stxInfoForSbtc.balance)} (shortfall: ${formatStx(stxShortfall.toString())}). ` + + await ensureStxForContractCall("sBTC"); + return; + } + + if (tokenType === "STX") { + // STX: include estimated fee in the required amount (unless sponsored) + const hiroApi = getHiroApi(account.network); + const balanceInfo = await hiroApi.getStxBalance(account.address); + const balance = BigInt(balanceInfo.balance); + const estimatedFee = sponsored + ? 0n + : await resolveDefaultFee(account.network, "token_transfer"); + const totalRequired = requiredAmount + estimatedFee; + + if (balance < totalRequired) { + const shortfall = totalRequired - balance; + throw new InsufficientBalanceError( + `Insufficient STX balance: need ${formatStx(amount)}` + + (estimatedFee > 0n ? ` + ${formatStx(estimatedFee.toString())} estimated fee` : "") + + `, have ${formatStx(balanceInfo.balance)} (shortfall: ${formatStx(shortfall.toString())}). ` + `Deposit more STX or use a different wallet.`, - 'STX', - stxInfoForSbtc.balance, - estimatedSbtcFee.toString(), - stxShortfall.toString() - ); - } + "STX", + balanceInfo.balance, + totalRequired.toString(), + shortfall.toString() + ); } - return; } - // STX: include estimated fee in the required amount + // SIP-010 (USDCx and other FT): check FT balance + STX gas for contract call + if (!asset.includes(".")) { + throw new InsufficientBalanceError( + `Unsupported payment asset "${asset}" for balance check (expected SIP-010 contract id).`, + asset, + "0", + amount, + amount + ); + } + const hiroApi = getHiroApi(account.network); - const balanceInfo = await hiroApi.getStxBalance(account.address); - const balance = BigInt(balanceInfo.balance); - - let totalRequired = requiredAmount; - if (!sponsored) { - // Same clamped fee the interceptor sets on the STX transfer. - const estimatedFee = await resolveDefaultFee(account.network, "token_transfer"); - totalRequired = requiredAmount + estimatedFee; + const ftBalanceRaw = await hiroApi.getTokenBalance(account.address, asset); + const ftBalance = BigInt(ftBalanceRaw); + if (ftBalance < requiredAmount) { + const shortfall = requiredAmount - ftBalance; + throw new InsufficientBalanceError( + `Insufficient SIP-010 balance for ${asset}: need ${amount}, have ${ftBalanceRaw} (shortfall: ${shortfall.toString()}). ` + + `Acquire more of this token or select a different payment asset.`, + asset, + ftBalanceRaw, + amount, + shortfall.toString() + ); } - if (balance >= totalRequired) return; - - const shortfall = totalRequired - balance; - throw new InsufficientBalanceError( - `Insufficient STX balance: need ${formatStx(totalRequired.toString())} (${formatStx(amount)} payment${!sponsored ? ` + estimated fee` : ''}), ` + - `have ${formatStx(balanceInfo.balance)} (shortfall: ${formatStx(shortfall.toString())}). ` + - `Deposit more STX or use a different wallet.`, - 'STX', - balanceInfo.balance, - totalRequired.toString(), - shortfall.toString() - ); + await ensureStxForContractCall(asset); } export { NETWORK, API_URL }; diff --git a/src/tools/endpoint.tools.ts b/src/tools/endpoint.tools.ts index f34cb5ee..28c7c624 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,7 @@ 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; + if (options.asset) callWith.asset = options.asset; return callWith; } @@ -306,9 +308,13 @@ 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("Preferred payment asset: symbol (sBTC, STX, USDCx) or full contract id. Filters accepts[] on multi-asset endpoints."), }, }, - async ({ method, url, path, apiUrl, params, data, autoApprove }) => { + async ({ method, url, path, apiUrl, params, data, autoApprove, asset }) => { let fullUrl = ""; try { @@ -317,8 +323,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 +343,7 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs const api = await createApiClient(parsed.baseUrl, { toolName: "execute_x402_endpoint", + preferredAsset: 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 +539,13 @@ Supported sources: .record(z.string(), z.unknown()) .optional() .describe("Request body for POST/PUT requests"), + asset: z + .string() + .optional() + .describe("Preferred payment asset: symbol (sBTC, STX, USDCx) or full contract id. Filters accepts[] on multi-asset endpoints."), }, }, - async ({ method, url, path, apiUrl, params, data }) => { + async ({ method, url, path, apiUrl, params, data, asset }) => { let fullUrl = ""; try { @@ -542,8 +553,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/src/utils/errors.ts b/src/utils/errors.ts index ae88ed40..be4abe9e 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -118,7 +118,7 @@ export class InvalidMnemonicError extends WalletError { export class InsufficientBalanceError extends AibtcError { constructor( message: string, - public readonly tokenType: 'STX' | 'sBTC', + public readonly tokenType: string, public readonly balance: string, public readonly required: string, public readonly shortfall: string diff --git a/tests/unit/select-payment-option.test.ts b/tests/unit/select-payment-option.test.ts new file mode 100644 index 00000000..24fa7e6c --- /dev/null +++ b/tests/unit/select-payment-option.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { detectTokenType, formatPaymentAmount, selectPaymentOption } from "../../src/services/x402.service.js"; + +const arcAccepts = [ + { + network: "stacks:1", + asset: "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx", + amount: "29000000", + payTo: "SP2GHQRCRMYY4S8PMBR49BEKX144VR437YT42SF3B", + }, + { + network: "stacks:1", + asset: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token", + amount: "45385", + payTo: "SP2GHQRCRMYY4S8PMBR49BEKX144VR437YT42SF3B", + }, + { + network: "stacks:1", + asset: "stx", + amount: "159911365", + payTo: "SP2GHQRCRMYY4S8PMBR49BEKX144VR437YT42SF3B", + }, +]; + +describe("selectPaymentOption (#613)", () => { + it("selects sBTC when asset=sBTC", () => { + const opt = selectPaymentOption(arcAccepts, "sBTC"); + expect(opt?.asset).toBe("SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token"); + }); + + it("selects USDCx when asset=USDCx", () => { + const opt = selectPaymentOption(arcAccepts, "USDCx"); + expect(opt?.asset).toContain("usdcx"); + }); + + it("defaults to sBTC when available and asset omitted", () => { + const opt = selectPaymentOption(arcAccepts); + expect(opt?.asset).toContain("sbtc-token"); + }); +}); + +describe("formatPaymentAmount (#613 display)", () => { + it("does not call USDCx amount STX", () => { + const msg = formatPaymentAmount("29000000", "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx"); + expect(msg.toLowerCase()).not.toContain("stx"); + expect(msg).toContain("USDCx"); + }); + + it("formats sBTC", () => { + const msg = formatPaymentAmount("45385", "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token"); + expect(msg.toLowerCase()).toContain("sbtc"); + }); +}); + +describe("detectTokenType", () => { + it("detects usdcx", () => { + expect(detectTokenType("SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx")).toBe("USDCx"); + }); +}); + +describe("payment route selection helpers (#613 review)", () => { + it("routes only native STX to STX token type", () => { + expect(detectTokenType("stx")).toBe("STX"); + expect(detectTokenType("STX")).toBe("STX"); + expect(detectTokenType("native")).toBe("STX"); + }); + + it("routes USDCx contract id to USDCx not STX", () => { + expect(detectTokenType("SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx")).toBe("USDCx"); + expect(detectTokenType("SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx")).not.toBe("STX"); + }); + + it("routes sBTC contract id to sBTC", () => { + expect(detectTokenType("SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token")).toBe("sBTC"); + }); + + it("prefers explicit asset over default when both sBTC and USDCx present", () => { + const accepts = [ + { network: "stacks:1", asset: "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx" }, + { network: "stacks:1", asset: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token" }, + { network: "stacks:1", asset: "stx" }, + ]; + expect(selectPaymentOption(accepts, "USDCx")?.asset).toContain("usdcx"); + expect(selectPaymentOption(accepts, "sBTC")?.asset).toContain("sbtc-token"); + expect(selectPaymentOption(accepts, "STX")?.asset?.toLowerCase()).toBe("stx"); + }); +});