Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 148 additions & 12 deletions src/services/x402.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,13 @@ export interface CreateApiClientOptions {
*/
onBeforePayment?: (requirements: PaymentRequirements) => Promise<void>;
toolName?: string;
/**
* Optional payment-asset selector (contract id or symbol like "sBTC"/"STX").
* Filters the endpoint's accepts[] to the matching entry. When omitted, the
* caller's held asset with the highest balance is preferred, falling back to
* first-in-accepts (see #613).
*/
asset?: string;
}

/**
Expand Down Expand Up @@ -667,16 +674,18 @@ 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 payment option: caller's asset selector first, then the
// caller's held asset (highest balance), then first-in-accepts (#613).
let selectedOption;
try {
const preferredAssets = options?.asset ? undefined : await getHeldAssetPreference();
selectedOption = selectPaymentOption(
paymentRequired.accepts,
options?.asset,
preferredAssets
);
} catch (selectionError) {
return Promise.reject(selectionError);
}

const tokenType = detectTokenType(selectedOption.asset);
Expand Down Expand Up @@ -879,6 +888,8 @@ export type ProbeResultPaymentRequired = {
mimeType?: string;
};
maxTimeoutSeconds?: number;
/** All payment assets advertised in the endpoint's accepts[] (v2 manifests). */
acceptedAssets?: string[];
};

export type ProbeResult = ProbeResultFree | ProbeResultPaymentRequired;
Expand All @@ -899,6 +910,112 @@ export function detectTokenType(asset: string): 'STX' | 'sBTC' {
return 'STX';
}

/**
* Check whether an accepts[] entry matches a caller-supplied asset selector.
* The selector may be a full contract identifier ("SP...address.contract-name"),
* or a symbol ("STX", "sBTC"). Matching is case-insensitive.
*/
export function assetMatchesSelector(entryAsset: string, selector: string): boolean {
const sel = selector.trim().toLowerCase();
const entry = entryAsset.trim().toLowerCase();

// Exact contract-id (or exact token-name) match
if (entry === sel) return true;

// Symbol matching
if (sel === 'sbtc') {
return detectTokenType(entryAsset) === 'sBTC';
}
if (sel === 'stx') {
// Native STX is advertised as "STX" (not a contract id)
return entry === 'stx';
}

// Match on contract name suffix: selector "usdcx" matches "SP....usdcx"
const dotIdx = entry.lastIndexOf('.');
if (dotIdx !== -1) {
const contractName = entry.slice(dotIdx + 1).split('::')[0];
if (contractName === sel) return true;
}
return false;
}

/**
* Select a payment option from a v2 accepts[] array.
*
* Selection order:
* 1. If `assetSelector` is given, the first Stacks entry matching it. Throws
* if no entry matches (lists what IS accepted so the caller can retry).
* 2. Otherwise, if `heldAssetProbe` is provided, the Stacks entry for the
* caller's held asset with the highest balance (sBTC checked first).
* 3. Otherwise, the first Stacks-compatible entry (documented default).
*/
export function selectPaymentOption<T extends { network?: string; asset: string }>(
accepts: T[],
assetSelector?: string,
preferredAssets?: string[]
): T {
const stacksOptions = accepts.filter((opt) => opt.network?.startsWith('stacks:'));
if (stacksOptions.length === 0) {
const networks = accepts.map((a) => a.network).join(', ');
throw new Error(`No compatible Stacks payment option found. Available networks: ${networks}`);
}

if (assetSelector) {
const match = stacksOptions.find((opt) => assetMatchesSelector(opt.asset, assetSelector));
if (!match) {
const available = stacksOptions.map((o) => o.asset).join(', ');
throw new Error(
`Requested payment asset "${assetSelector}" is not accepted by this endpoint. ` +
`Accepted assets: ${available}`
);
}
return match;
}

if (preferredAssets && preferredAssets.length > 0) {
for (const pref of preferredAssets) {
const match = stacksOptions.find((opt) => assetMatchesSelector(opt.asset, pref));
if (match) return match;
}
}

// Documented default: first Stacks-compatible entry in accepts[]
return stacksOptions[0];
}

/**
* Rank the caller's held assets for payment preference. Returns asset symbols
* ordered by preference (held, non-zero balances first; sBTC checked before
* STX since sBTC-only wallets are the blocked case — see #613). Returns
* undefined when no wallet is configured/unlockable so probe keeps working
* without a wallet (falls back to first-in-accepts).
*/
export async function getHeldAssetPreference(): Promise<string[] | undefined> {
try {
const acct = await getAccount();
const held: Array<{ symbol: string; balance: bigint }> = [];

try {
const sbtcService = getSbtcService(acct.network);
const sbtcBalance = BigInt((await sbtcService.getBalance(acct.address)).balance);
if (sbtcBalance > 0n) held.push({ symbol: 'sBTC', balance: sbtcBalance });
} catch { /* sBTC balance unavailable — skip */ }

try {
const hiroApi = getHiroApi(acct.network);
const stxBalance = BigInt((await hiroApi.getStxBalance(acct.address)).balance);
if (stxBalance > 0n) held.push({ symbol: 'STX', balance: stxBalance });
} catch { /* STX balance unavailable — skip */ }

if (held.length === 0) return undefined;
return held.map((h) => h.symbol);
} catch {
// No wallet configured — probe must still work
return undefined;
}
}

/**
* Format payment amount into human-readable string with token symbol
* @param amount - Raw amount string (microSTX or satoshis)
Expand All @@ -910,7 +1027,16 @@ export function formatPaymentAmount(amount: string, asset: string): string {
if (tokenType === 'sBTC') {
return formatSbtc(amount);
}
return formatStx(amount);
// Only claim STX when the asset actually IS native STX. Other SIP-10
// tokens (e.g. USDCx) must never be displayed as STX — see #613 bug 2:
// message said "29 STX" while payment.asset was the USDCx contract.
if (asset.trim().toLowerCase() === 'stx') {
return formatStx(amount);
}
// Unknown SIP-10 token: derive display name from the contract identifier
// and show atomic units (decimals are not knowable without a contract read).
const contractName = asset.split('.').pop()?.split('::')[0] ?? asset;
return `${amount} ${contractName} (atomic units, contract: ${asset})`;
}

/**
Expand All @@ -922,8 +1048,10 @@ export async function probeEndpoint(options: {
url: string;
params?: Record<string, string>;
data?: Record<string, unknown>;
/** Optional payment-asset selector (contract id or symbol like "sBTC"/"STX"). */
asset?: string;
}): Promise<ProbeResult> {
const { method, url, params, data } = options;
const { method, url, params, data, asset } = options;
const axiosInstance = createBaseAxiosInstance();

try {
Expand All @@ -945,7 +1073,14 @@ export async function probeEndpoint(options: {

// If v2 header is successfully parsed, use it
if (paymentRequired?.accepts?.length) {
const acceptedPayment = paymentRequired.accepts[0];
// Select by caller's asset preference, falling back to the caller's
// held asset (highest balance first), then first-in-accepts.
const preferredAssets = asset ? undefined : await getHeldAssetPreference();
const acceptedPayment = selectPaymentOption(
paymentRequired.accepts,
asset,
preferredAssets
);

// Convert CAIP-2 network identifier to human-readable format
const network = getNetworkFromStacksChainId(acceptedPayment.network) ?? NETWORK;
Expand All @@ -959,6 +1094,7 @@ export async function probeEndpoint(options: {
endpoint: url,
resource: paymentRequired.resource,
maxTimeoutSeconds: acceptedPayment.maxTimeoutSeconds,
acceptedAssets: paymentRequired.accepts.map((a) => a.asset),
};
}

Expand Down
29 changes: 23 additions & 6 deletions src/tools/endpoint.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,15 @@ function buildCallWith(options: {
apiUrl?: string;
params?: Record<string, string>;
data?: Record<string, unknown>;
asset?: string;
}): Record<string, unknown> {
const callWith: Record<string, unknown> = { method: options.method, autoApprove: true };
if (options.url) callWith.url = options.url;
if (options.path) callWith.path = options.path;
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;
}

Expand Down Expand Up @@ -152,6 +154,12 @@ function formatProbeResponse(
recipient: result.recipient,
network: result.network,
},
...(result.acceptedAssets && result.acceptedAssets.length > 1 && {
acceptedAssets: result.acceptedAssets,
assetNote:
"This endpoint accepts multiple payment assets. Pass asset: '<contract-id or symbol>' " +
"(e.g. asset: 'sBTC') to probe/execute with a specific one.",
}),
callWith: buildCallWith(callWithOptions),
});
}
Expand Down Expand Up @@ -306,9 +314,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("Payment asset to use when the endpoint accepts multiple (contract id or symbol, e.g. 'sBTC', 'STX', or 'SP....token::name'). When omitted, prefers an asset you hold, falling back to the endpoint's first accepted asset."),
},
},
async ({ method, url, path, apiUrl, params, data, autoApprove }) => {
async ({ method, url, path, apiUrl, params, data, autoApprove, asset }) => {
let fullUrl = "";

try {
Expand All @@ -317,8 +329,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
Expand All @@ -337,6 +349,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).
Expand Down Expand Up @@ -532,18 +545,22 @@ Supported sources:
.record(z.string(), z.unknown())
.optional()
.describe("Request body for POST/PUT requests"),
asset: z
.string()
.optional()
.describe("Payment asset to report pricing for when the endpoint accepts multiple (contract id or symbol, e.g. 'sBTC', 'STX'). When omitted, prefers an asset you hold, falling back to the endpoint's first accepted asset."),
},
},
async ({ method, url, path, apiUrl, params, data }) => {
async ({ method, url, path, apiUrl, params, data, asset }) => {
let fullUrl = "";

try {
const parsed = parseEndpointUrl({ url, path, apiUrl, params });
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");
}
Expand Down
74 changes: 74 additions & 0 deletions tests/services/x402-asset-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import {
selectPaymentOption,
assetMatchesSelector,
formatPaymentAmount,
detectTokenType,
} from "../../src/services/x402.service.js";

const SBTC = "SP3DX3H4FEYZJZ586MFBS25ZW3HZDMEW92260QE.sbtc-token";
const USDCX = "SP120SBRRQVYYAJVVK9V3SF9RRD5TVW60HYT3DDPA.usdcx";

const accepts = [
{ network: "stacks:1", asset: "STX", amount: "29000000" },
{ network: "stacks:1", asset: SBTC, amount: "100" },
{ network: "stacks:1", asset: USDCX, amount: "29000000" },
];

describe("assetMatchesSelector", () => {
it("matches sBTC by symbol", () => {
expect(assetMatchesSelector(SBTC, "sBTC")).toBe(true);
expect(assetMatchesSelector(SBTC, "SBTC")).toBe(true);
});
it("matches STX only against native STX", () => {
expect(assetMatchesSelector("STX", "stx")).toBe(true);
expect(assetMatchesSelector(USDCX, "stx")).toBe(false);
});
it("matches SIP-10 tokens by contract name or full id", () => {
expect(assetMatchesSelector(USDCX, "usdcx")).toBe(true);
expect(assetMatchesSelector(USDCX, USDCX)).toBe(true);
expect(assetMatchesSelector(USDCX, "sbtc")).toBe(false);
});
});

describe("selectPaymentOption", () => {
it("defaults to first Stacks entry when no selector or preference", () => {
expect(selectPaymentOption(accepts).asset).toBe("STX");
});
it("honors explicit asset selector (the #613 blocked case)", () => {
expect(selectPaymentOption(accepts, "sBTC").asset).toBe(SBTC);
expect(selectPaymentOption(accepts, "usdcx").asset).toBe(USDCX);
});
it("throws with accepted-asset list when selector matches nothing", () => {
expect(() => selectPaymentOption(accepts, "wif")).toThrow(/not accepted/);
expect(() => selectPaymentOption(accepts, "wif")).toThrow(/Accepted assets/);
});
it("prefers held assets when no explicit selector", () => {
expect(selectPaymentOption(accepts, undefined, ["sBTC"]).asset).toBe(SBTC);
expect(selectPaymentOption(accepts, undefined, ["sBTC", "STX"]).asset).toBe(SBTC);
});
it("falls back to first entry when held assets match nothing", () => {
const sbtcOnly = [{ network: "stacks:1", asset: USDCX, amount: "1" }];
expect(selectPaymentOption(sbtcOnly, undefined, ["sBTC", "STX"]).asset).toBe(USDCX);
});
it("skips non-Stacks networks and throws when none compatible", () => {
const evm = [{ network: "eip155:8453", asset: "0xUSDC", amount: "1" }];
expect(() => selectPaymentOption(evm)).toThrow(/No compatible Stacks/);
});
});

describe("formatPaymentAmount (#613 bug 2 — mislabeled asset)", () => {
it("formats native STX as STX", () => {
expect(formatPaymentAmount("29000000", "STX")).toMatch(/STX/);
});
it("formats sBTC as sBTC", () => {
expect(detectTokenType(SBTC)).toBe("sBTC");
expect(formatPaymentAmount("100", SBTC)).toMatch(/sBTC/);
});
it("never labels an unknown SIP-10 token as STX", () => {
const out = formatPaymentAmount("29000000", USDCX);
expect(out).not.toMatch(/\bSTX\b/);
expect(out).toContain("usdcx");
expect(out).toContain(USDCX);
});
});