From 78ed81f46049759e86661b182b19ea3f28b44324 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 13:22:15 +0000 Subject: [PATCH] Add --x402-max-amount local spend limit for x402 sessions An x402 session currently pays whatever a tool call turns out to cost. --x402-max-amount caps every single payment: the amount is checked in signPayment(), the one choke point all three payment paths go through (proactive _meta.x402 signing, HTTP 402 challenges, and payment-required tool results), so a payment over the cap is refused before anything is signed and the call fails with the amount that was asked for. The cap is stored in sessions.json and restored on reconnect and restart, so a crashed session comes back capped rather than uncapped. Refs #369, which proposed the same local guard alongside a hardcoded third-party payment-approval service. This is the local half only: no network call, no vendor, no extra payment per tool call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011DCnYpi347tXWKMrhmVgLB --- CHANGELOG.md | 1 + README.md | 18 +++++++ docs/REFERENCE.md | 5 ++ skills/mcpc/SKILL.md | 2 + src/bridge/index.ts | 36 ++++++++++++- src/cli/commands/connect.ts | 34 +++++++++++++ src/cli/commands/sessions.ts | 1 + src/cli/index.ts | 24 +++++++++ src/cli/output.ts | 8 ++- src/lib/bridge-manager.ts | 11 ++++ src/lib/types.ts | 5 ++ src/lib/x402/fetch-middleware.ts | 37 +++++++++++--- src/lib/x402/limits.ts | 53 +++++++++++++++++++ src/lib/x402/signer.ts | 37 +++++++++++++- test/unit/cli/output.test.ts | 12 +++++ test/unit/lib/x402/fetch-middleware.test.ts | 55 ++++++++++++++++++++ test/unit/lib/x402/limits.test.ts | 54 ++++++++++++++++++++ test/unit/lib/x402/signer.test.ts | 56 +++++++++++++++++++++ 18 files changed, 438 insertions(+), 11 deletions(-) create mode 100644 src/lib/x402/limits.ts create mode 100644 test/unit/lib/x402/limits.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e470d47..62cac479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New `mcpc connect --x402-max-amount ` option that caps every single x402 payment, e.g. `--x402-max-amount 0.50`. A payment above the cap fails instead of being signed, whatever the server asks for. The cap is stored with the session and reused on restart. - New [REFERENCE.md](docs/REFERENCE.md) with the full `--help` output of every mcpc command, generated from the CLI itself so it always matches the release. - `mcpc @session` and `server-discover` now show the description and website URL a server advertises about itself, right below its name. diff --git a/README.md b/README.md index f75d77aa..722859f5 100644 --- a/README.md +++ b/README.md @@ -829,6 +829,24 @@ mcpc @apify restart When `--x402` is active, a fetch middleware wraps all HTTP requests to the MCP server. If any request returns HTTP 402, the middleware transparently signs and retries. Your scheme preference is persisted in `sessions.json` and reused on every reconnect or restart. +### Limiting how much a session can spend + +Without a limit, a session pays whatever a tool call turns out to cost. `--x402-max-amount ` +caps every single payment — the price the server asks for, or for the `upto` scheme the maximum +authorization you sign: + +```bash +# Never sign a single payment above $0.50 +mcpc connect mcp.apify.com @apify --x402 --x402-max-amount 0.50 +``` + +A payment above the cap is refused locally, before anything is signed, and the tool call fails with +the amount that was asked for. The check covers all three payment paths: proactively priced tools, +HTTP 402 challenges, and payment-required tool results. + +The cap is stored in `sessions.json` and reused on every reconnect and restart, so a crashed +session comes back capped. To change it, close the session and connect again. + ### Supported networks | Network | Status | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index cd10ee8d..fad34bf1 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -120,6 +120,7 @@ Options: --stdio Launch all local stdio servers from selected config files --protocol-version Pin the MCP protocol version (see below) --x402 [scheme] Enable x402 auto-payment (see below) + --x402-max-amount Refuse any single x402 payment above this amount --json Output in JSON format Server formats: @@ -152,6 +153,10 @@ Protocol version: x402 payments (experimental): --x402 pays for paid tool calls from the wallet set up with mcpc x402. Schemes: auto (default, prefers upto), upto, exact. + --x402-max-amount caps every single payment, e.g. --x402-max-amount 0.50. + A payment above the cap fails instead of being signed, whatever the server asks + for. The cap is stored with the session and reused on restart; close the session + to change it. Output: For a single server, shows session, server info, capabilities, and tools. diff --git a/skills/mcpc/SKILL.md b/skills/mcpc/SKILL.md index 0cf56560..870b94c9 100644 --- a/skills/mcpc/SKILL.md +++ b/skills/mcpc/SKILL.md @@ -244,6 +244,8 @@ mcpc @apify skills-get --raw # print the SKILL.md markdown (pipe to a (`--no-profile`, `--stdio`, `--proxy`, and `-H` are options of `connect`, not global flags.) `mcpc` also has experimental `--x402` auto-payment for paid MCP tools — see `mcpc help x402`. +When spending unattended, add `--x402-max-amount ` (e.g. `--x402-max-amount 0.50`): any single +payment above it is refused instead of signed, and the cap survives session restarts. ## Debugging diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 141d83df..a6826e29 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -75,6 +75,8 @@ import type { ProxyConfig } from '../lib/types.js'; // only here and load the implementations lazily at the x402-gated call sites. import type { X402PaymentCache } from '../lib/x402/fetch-middleware.js'; import type { SignerWallet } from '../lib/x402/signer.js'; +// Spend-limit helpers are deliberately dependency-free, so they stay a static import. +import { X402PaymentLimitError, parseMaxAmountUsd, usdToAtomicUnits } from '../lib/x402/limits.js'; import type { FetchLike } from '@modelcontextprotocol/client'; // HTTP proxy and TLS settings are configured in main() after parsing --insecure flag @@ -96,6 +98,8 @@ interface BridgeOptions { protocolVersion?: string; // Protocol version negotiated by the resumed session (only set with mcpSessionId) /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ x402?: X402SchemePreference; + /** Local spend limit in USD applied to every single x402 payment. */ + x402MaxAmountUsd?: number; insecure?: boolean; // Skip TLS certificate verification } @@ -678,11 +682,13 @@ class BridgeProcess { return this.client?.getCachedTools()?.find((t: Tool) => t.name === name); }; const { createX402FetchMiddleware } = await import('../lib/x402/fetch-middleware.js'); + const maxAmountAtomicUnits = this.x402MaxAmountAtomicUnits(); customFetch = createX402FetchMiddleware(proxyFetch, { wallet, getToolByName, paymentCache: this.x402PaymentCache, ...(this.options.x402 && { schemePreference: this.options.x402 }), + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), }); } @@ -1301,6 +1307,12 @@ class BridgeProcess { } } + /** The session's `--x402-max-amount` in atomic units, or undefined when uncapped. */ + private x402MaxAmountAtomicUnits(): bigint | undefined { + const maxAmountUsd = this.options.x402MaxAmountUsd; + return maxAmountUsd === undefined ? undefined : usdToAtomicUnits(maxAmountUsd); + } + /** * Handle a tool result that contains x402 payment-required data. * Signs a fresh payment, caches it, and retries the tool call once. @@ -1337,18 +1349,23 @@ class BridgeProcess { // Invalidate cache and sign fresh this.x402PaymentCache.signature = null; + const { signPayment } = await import('../lib/x402/signer.js'); + const maxAmountAtomicUnits = this.x402MaxAmountAtomicUnits(); try { - const { signPayment } = await import('../lib/x402/signer.js'); const signed = await signPayment({ wallet: this.x402Wallet, accept: parsed.accept, resource: parsed.resource, + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), }); this.x402PaymentCache.signature = signed.paymentSignatureBase64; logger.debug( `Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}` ); } catch (signError) { + // Refusing to exceed the spend limit must reach the caller — returning the + // payment-required result instead would read as "the server wants payment". + if (signError instanceof X402PaymentLimitError) throw signError; logger.warn('Failed to sign fresh payment for 402 retry:', signError); return { handled: false }; } @@ -1899,7 +1916,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--protocol-version ] [--x402 ] [--insecure]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--protocol-version ] [--x402 ] [--x402-max-amount ] [--insecure]' ); process.exit(1); } @@ -1955,6 +1972,18 @@ async function main(): Promise { x402 = value as X402SchemePreference; } + // Parse `--x402-max-amount ` (local spend limit for every single payment). + let x402MaxAmountUsd: number | undefined; + const x402MaxAmountIndex = args.indexOf('--x402-max-amount'); + if (x402MaxAmountIndex !== -1) { + try { + x402MaxAmountUsd = parseMaxAmountUsd(args[x402MaxAmountIndex + 1] ?? ''); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + } + // Parse --insecure flag (skip TLS certificate verification) const insecure = args.includes('--insecure'); @@ -1983,6 +2012,9 @@ async function main(): Promise { if (x402) { bridgeOptions.x402 = x402; } + if (x402MaxAmountUsd !== undefined) { + bridgeOptions.x402MaxAmountUsd = x402MaxAmountUsd; + } if (insecure) { bridgeOptions.insecure = true; } diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index 5d133e8a..56b44f12 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -50,6 +50,7 @@ import { storeKeychainProxyBearerToken, } from '../../lib/auth/keychain.js'; import { getWallet } from '../../lib/wallets.js'; +import { formatUsdAmount, usdToAtomicUnits } from '../../lib/x402/limits.js'; import chalk from 'chalk'; // ora is loaded lazily at the spinner call site — it is only needed for // human-mode bulk connects and costs ~50 ms at import. @@ -145,6 +146,7 @@ type ConnectSessionOptions = { proxyBearerToken?: string; protocolVersion?: string; x402?: X402SchemePreference; + x402MaxAmountUsd?: number; insecure?: boolean; skipDetails?: boolean; quiet?: boolean; @@ -292,12 +294,32 @@ export async function connectSession( // Validate --protocol-version (if provided) assertSupportedProtocolVersion(options.protocolVersion); + if (options.x402MaxAmountUsd !== undefined && !options.x402) { + throw new ClientError('--x402-max-amount requires --x402'); + } + + /** Render a stored or requested spend limit for error messages. */ + const formatLimit = (maxAmountUsd: number | undefined): string => + maxAmountUsd === undefined ? 'no limit' : formatUsdAmount(usdToAtomicUnits(maxAmountUsd)); + // Check if session already exists const existingSession = await getSession(name); if (existingSession) { const bridgeStatus = getBridgeStatus(existingSession); if (bridgeStatus === 'live') { + // A live bridge keeps the limit it was started with, so silently ignoring a + // different --x402-max-amount would leave the session spending on the old one. + if ( + options.x402MaxAmountUsd !== undefined && + options.x402MaxAmountUsd !== existingSession.x402MaxAmountUsd + ) { + throw new ClientError( + `Session ${name} is already active with a different x402 spend limit ` + + `(${formatLimit(existingSession.x402MaxAmountUsd)}, requested ${formatLimit(options.x402MaxAmountUsd)}). ` + + `To change it, run: mcpc ${name} close` + ); + } // Session exists and bridge is running - just show server info if (options.outputMode === 'human' && !options.quiet) { console.log(formatSuccess(`Session ${name} is already active`)); @@ -389,6 +411,11 @@ export async function connectSession( logger.debug(`Using x402 wallet: ${wallet.address}`); } + // A bare reconnect (`mcpc connect @name` after a crash) must not drop the + // spend limit the session was created with — restoring it keeps the guard on until + // the session is explicitly closed. + const effectiveX402MaxAmountUsd = options.x402MaxAmountUsd ?? existingSession?.x402MaxAmountUsd; + // Create or update session record (without pid - that comes from startBridge) // Store serverConfig with headers redacted (actual values in keychain) const isReconnect = !!existingSession; @@ -403,6 +430,9 @@ export async function connectSession( ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), ...(options.x402 && { x402: options.x402 }), + ...(effectiveX402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: effectiveX402MaxAmountUsd, + }), ...(options.insecure && { insecure: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), @@ -432,6 +462,9 @@ export async function connectSession( ...(profileName && { profileName }), ...(proxyConfig && { proxyConfig }), ...(options.x402 && { x402: options.x402 }), + ...(effectiveX402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: effectiveX402MaxAmountUsd, + }), ...(options.insecure && { insecure: true }), }; @@ -646,6 +679,7 @@ type BulkConnectOptions = { stdio?: boolean; protocolVersion?: string; x402?: X402SchemePreference; + x402MaxAmountUsd?: number; insecure?: boolean; }; diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 96cd022e..a845149f 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -438,6 +438,7 @@ export async function restartSession( ...(profileName && { profileName }), ...(session.proxy && { proxyConfig: session.proxy }), ...(session.x402 && { x402: session.x402 }), + ...(session.x402MaxAmountUsd !== undefined && { x402MaxAmountUsd: session.x402MaxAmountUsd }), ...(session.insecure && { insecure: session.insecure }), }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 3c3d18d1..e95ecc96 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,6 +35,7 @@ import { clean } from './commands/clean.js'; import { MCPC_OAUTH_CALLBACK_HOSTS, MCPC_OAUTH_CALLBACK_PORTS } from '../lib/auth/oauth-utils.js'; import type { OutputMode, X402SchemePreference } from '../lib/index.js'; import { X402_SCHEME_PREFERENCES } from '../lib/index.js'; +import { parseMaxAmountUsd } from '../lib/x402/limits.js'; import { extractOptions, preProcessSkillArgv, @@ -89,6 +90,8 @@ interface HandlerOptions { * `--x402` (no value) resolves to `'auto'` (prefer upto, fall back to exact). */ x402?: X402SchemePreference; + /** Local spend limit in USD applied to every single x402 payment. */ + x402MaxAmountUsd?: number; insecure?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; @@ -146,6 +149,10 @@ function getOptionsFromCommand(command: Command): HandlerOptions { } options.x402 = opts.x402 as X402SchemePreference; } + if (opts.x402MaxAmount !== undefined) { + options.x402MaxAmountUsd = parseMaxAmountUsd(opts.x402MaxAmount as string); + if (!options.x402) throw new ClientError('--x402-max-amount requires --x402.'); + } if (opts.insecure) options.insecure = true; if (opts.schema) options.schema = opts.schema; if (opts.schemaMode) { @@ -489,6 +496,7 @@ Full docs: ${docsUrl}` .option('--stdio', 'Launch all local stdio servers from selected config files') .option('--protocol-version ', 'Pin the MCP protocol version (see below)') .option('--x402 [scheme]', 'Enable x402 auto-payment (see below)') + .option('--x402-max-amount ', 'Refuse any single x402 payment above this amount') .addHelpText( 'after', ` @@ -522,6 +530,10 @@ ${chalk.bold('Protocol version:')} ${chalk.bold('x402 payments (experimental):')} --x402 pays for paid tool calls from the wallet set up with mcpc x402. Schemes: auto (default, prefers upto), upto, exact. + --x402-max-amount caps every single payment, e.g. --x402-max-amount 0.50. + A payment above the cap fails instead of being signed, whatever the server asks + for. The cap is stored with the session and reused on restart; close the session + to change it. ${outputHelp([ 'For a single server, shows session, server info, capabilities, and tools.', 'Bulk connects list every session with its state, then a summary.', @@ -553,6 +565,9 @@ ${outputHelp([ ...(opts.stdio && { stdio: true }), ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); // Trailing blank line to match the spacing of other commands (human mode only). @@ -585,6 +600,9 @@ ${outputHelp([ ...(opts.stdio && { stdio: true }), ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -610,6 +628,9 @@ ${outputHelp([ proxyBearerToken: opts.proxyBearerToken, ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); } else { @@ -620,6 +641,9 @@ ${outputHelp([ proxyBearerToken: opts.proxyBearerToken, ...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }), ...(globalOpts.x402 && { x402: globalOpts.x402 }), + ...(globalOpts.x402MaxAmountUsd !== undefined && { + x402MaxAmountUsd: globalOpts.x402MaxAmountUsd, + }), ...(globalOpts.insecure && { insecure: true }), }); } diff --git a/src/cli/output.ts b/src/cli/output.ts index 2a635336..d989013a 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -4,6 +4,7 @@ */ import chalk from 'chalk'; +import { formatUsdAmount, usdToAtomicUnits } from '../lib/x402/limits.js'; import type { DiscoverResult, GetPromptResult, @@ -1545,7 +1546,12 @@ export function formatSessionLine(session: SessionData): string { // x402 takes precedence when both happen to be present on the session record. let infoStr = ''; if (session.x402) { - infoStr = theme.yellow('[x402]'); + // Show the spend limit — it is the difference between a capped wallet and an open one. + infoStr = theme.yellow( + session.x402MaxAmountUsd === undefined + ? '[x402]' + : `[x402 max ${formatUsdAmount(usdToAtomicUnits(session.x402MaxAmountUsd))}]` + ); } else if (!session.server.command && session.profileName) { infoStr = chalk.dim('(OAuth: ') + theme.magenta(session.profileName) + chalk.dim(')'); } diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index 1497ceaf..7113d84f 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -120,6 +120,8 @@ export interface StartBridgeOptions { protocolVersion?: string; // Protocol version negotiated by the resumed session (only pass with mcpSessionId) /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ x402?: X402SchemePreference; + /** Local spend limit in USD applied to every single x402 payment. */ + x402MaxAmountUsd?: number; insecure?: boolean; // Skip TLS certificate verification } @@ -153,6 +155,7 @@ export async function startBridge(options: StartBridgeOptions): Promise => { // Try to get a payment signature (cached or freshly signed) for tools/call requests @@ -134,7 +141,8 @@ export function createX402FetchMiddleware( wallet, getToolByName, paymentCache, - schemePreference + schemePreference, + maxAmountAtomicUnits ); if (paymentSignature) { const enhancedInit = injectPayment(init, paymentSignature); @@ -155,7 +163,8 @@ export function createX402FetchMiddleware( baseFetch, wallet, paymentCache, - schemePreference + schemePreference, + maxAmountAtomicUnits ); } @@ -171,7 +180,8 @@ export function createX402FetchMiddleware( baseFetch, wallet, paymentCache, - schemePreference + schemePreference, + maxAmountAtomicUnits ); } @@ -189,7 +199,8 @@ async function getOrSignPayment( wallet: SignerWallet, getToolByName: ((name: string) => Tool | undefined) | undefined, paymentCache: X402PaymentCache, - schemePreference?: SchemePreference + schemePreference?: SchemePreference, + maxAmountAtomicUnits?: bigint ): Promise { if (!init?.body) { return undefined; @@ -247,13 +258,20 @@ async function getOrSignPayment( } try { - const result = await signPayment({ wallet, accept }); + const result = await signPayment({ + wallet, + accept, + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), + }); logger.debug( `Fresh payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); paymentCache.signature = result.paymentSignatureBase64; return result.paymentSignatureBase64; } catch (error) { + // Over the spend limit is a decision, not a failure to sign: surface it instead of + // letting the call go out unpaid and get charged through the 402 path. + if (error instanceof X402PaymentLimitError) throw error; logger.warn(`Payment signing failed for tool "${toolName}":`, error); return undefined; } @@ -270,7 +288,8 @@ async function handle402Fallback( baseFetch: FetchLike, wallet: SignerWallet, paymentCache: X402PaymentCache, - schemePreference?: SchemePreference + schemePreference?: SchemePreference, + maxAmountAtomicUnits?: bigint ): Promise { // Extract PAYMENT-REQUIRED header (case-insensitive) const paymentRequiredBase64 = @@ -298,6 +317,7 @@ async function handle402Fallback( wallet, accept, resource: header.resource, + ...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }), }); logger.debug( @@ -318,6 +338,9 @@ async function handle402Fallback( const retryInit = injectPayment(originalInit, result.paymentSignatureBase64); return await baseFetch(url, retryInit); } catch (error) { + // The caller asked for the payment to be refused above the limit — returning the 402 + // here would hide that behind the server's own "payment required" message. + if (error instanceof X402PaymentLimitError) throw error; logger.warn('402 fallback signing failed:', error); return response402; } diff --git a/src/lib/x402/limits.ts b/src/lib/x402/limits.ts new file mode 100644 index 00000000..effd0d2a --- /dev/null +++ b/src/lib/x402/limits.ts @@ -0,0 +1,53 @@ +/** + * Local spend limit for x402 auto-payments. + * + * Deliberately dependency-free (no viem, no signer) so the CLI can validate + * `--x402-max-amount` at startup without pulling in the bundled crypto code. + * + * The limit is enforced in `signPayment()`, the single choke point every + * automatic payment path goes through: proactive `_meta.x402` signing, the + * HTTP 402 fallback, and the bridge's payment-required tool-result retry. + */ + +import { ClientError } from '../errors.js'; + +/** Decimals of the stablecoins x402 settles in (USDC on Base). */ +export const USDC_DECIMALS = 6; + +/** Smallest payment that can be expressed on-chain, in USD. */ +export const MIN_AMOUNT_USD = 1 / 10 ** USDC_DECIMALS; + +/** + * A payment was refused locally because it exceeded the session's spend limit. + * + * Distinct from other signing failures on purpose: the payment paths swallow + * signing errors and let the request go out unpaid, which for a spend limit + * would silently degrade into "call the paid tool without paying". Callers + * re-throw this one so the limit is always visible. + */ +export class X402PaymentLimitError extends ClientError {} + +/** Convert a USD amount to atomic units of a 6-decimal stablecoin. */ +export function usdToAtomicUnits(usd: number): bigint { + return BigInt(Math.round(usd * 10 ** USDC_DECIMALS)); +} + +/** Format atomic units as USD, keeping cents but not trailing noise ($0.50, $0.000125). */ +export function formatUsdAmount(atomicUnits: bigint): string { + const usd = Number(atomicUnits) / 10 ** USDC_DECIMALS; + return `$${usd.toFixed(USDC_DECIMALS).replace(/(\.\d{2}\d*?)0+$/, '$1')}`; +} + +/** + * Validate a `--x402-max-amount` value and return it in USD. + * Throws a `ClientError` with the expected format when it is not a usable amount. + */ +export function parseMaxAmountUsd(value: string | number): number { + const usd = typeof value === 'number' ? value : Number(value.trim()); + if (!Number.isFinite(usd) || usd < MIN_AMOUNT_USD) { + throw new ClientError( + `Invalid --x402-max-amount value: "${String(value)}". Expected a dollar amount of at least ${MIN_AMOUNT_USD.toFixed(USDC_DECIMALS)} (e.g. 0.50).` + ); + } + return usd; +} diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index 3254a4f3..3c720797 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -19,6 +19,7 @@ import { import { ClientError } from '../errors.js'; import { createLogger } from '../logger.js'; import type { X402SchemePreference } from '../types.js'; +import { USDC_DECIMALS, X402PaymentLimitError, formatUsdAmount } from './limits.js'; const logger = createLogger('x402-signer'); @@ -27,7 +28,6 @@ const logger = createLogger('x402-signer'); // --------------------------------------------------------------------------- export const X402_VERSION = 2; -const USDC_DECIMALS = 6; /** Fallback expiry when neither the caller nor the `accept` advertise one. */ export const DEFAULT_PAYMENT_EXPIRY_SECONDS = 3600; @@ -171,6 +171,12 @@ export interface SignPaymentInput { * approvals yourself. */ skipPermit2Approval?: boolean; + /** + * Local spend limit in atomic units. When set, signing a payment that authorizes + * more than this throws `X402PaymentLimitError` instead — for the `upto` scheme + * that caps the maximum authorization, not the amount finally captured. + */ + maxAmountAtomicUnits?: bigint; } export interface SignPaymentResult { @@ -310,12 +316,41 @@ export function parsePaymentRequired( // Signing // --------------------------------------------------------------------------- +/** + * Refuse a payment that authorizes more than the session's `--x402-max-amount`. + * + * Runs before any scheme-specific signing so every automatic payment path — proactive + * `_meta.x402` signing, the HTTP 402 fallback, and the bridge's payment-required retry — + * is capped by the same check, whatever terms the server sends. + */ +function assertWithinSpendLimit(input: SignPaymentInput): void { + const { accept, amountOverride, maxAmountAtomicUnits } = input; + if (maxAmountAtomicUnits === undefined) return; + + let amountAtomicUnits: bigint; + try { + amountAtomicUnits = amountOverride ?? BigInt(accept.amount); + } catch { + throw new X402PaymentLimitError( + `x402 payment refused: amount "${accept.amount}" is not a valid atomic-unit integer, so it cannot be checked against --x402-max-amount.` + ); + } + + if (amountAtomicUnits <= maxAmountAtomicUnits) return; + throw new X402PaymentLimitError( + `x402 payment refused: ${formatUsdAmount(amountAtomicUnits)} to ${accept.payTo} exceeds the ` + + `${formatUsdAmount(maxAmountAtomicUnits)} limit set by --x402-max-amount. ` + + `To allow it, reconnect the session with a higher --x402-max-amount.` + ); +} + /** * Sign an x402 payment and return a base64-encoded PAYMENT-SIGNATURE header value. * Delegates to scheme-specific signers based on `accept.scheme`. */ export async function signPayment(input: SignPaymentInput): Promise { const { accept } = input; + assertWithinSpendLimit(input); logger.debug( `Signing x402 payment: scheme=${accept.scheme} network=${accept.network} amount=${accept.amount} asset=${accept.asset} payTo=${accept.payTo} facilitator=${accept.extra?.facilitatorAddress ?? ''}` ); diff --git a/test/unit/cli/output.test.ts b/test/unit/cli/output.test.ts index dee35e83..cd773e69 100644 --- a/test/unit/cli/output.test.ts +++ b/test/unit/cli/output.test.ts @@ -2186,6 +2186,18 @@ describe('formatSessionLine', () => { expect(output).not.toContain('stdio'); }); + it('should show the x402 spend limit when the session has one', () => { + const base: SessionData = { + name: '@paid', + server: { url: 'https://mcp.example.com' }, + x402: 'auto', + createdAt: '2025-01-01T00:00:00Z', + }; + + expect(formatSessionLine(base)).toContain('[x402]'); + expect(formatSessionLine({ ...base, x402MaxAmountUsd: 0.5 })).toContain('[x402 max $0.50]'); + }); + it('should include proxy info when configured', () => { const session: SessionData = { name: '@proxy-test', diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts index 23b48139..e023e663 100644 --- a/test/unit/lib/x402/fetch-middleware.test.ts +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -15,6 +15,7 @@ import { type X402PaymentCache, } from '../../../../src/lib/x402/fetch-middleware.js'; import type { PaymentRequiredAccept, SignerWallet } from '../../../../src/lib/x402/signer.js'; +import { X402PaymentLimitError } from '../../../../src/lib/x402/limits.js'; // --------------------------------------------------------------------------- // Mocks — vi.mock is hoisted above local const declarations @@ -172,6 +173,22 @@ describe('createX402FetchMiddleware proactive sign', () => { expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBeNull(); }); + it('fails the call instead of sending it unpaid when the payment is over the limit', async () => { + mockSignPayment.mockRejectedValue(new X402PaymentLimitError('x402 payment refused: $1.00')); + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => makePaidTool({ accepts: [EXACT_ACCEPT] }), + paymentCache: { signature: null }, + maxAmountAtomicUnits: 500_000n, + }); + + await expect( + fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }) + ).rejects.toThrow('x402 payment refused'); + expect(baseFetch).not.toHaveBeenCalled(); + }); + it('with schemePreference=exact and accepts=[exact, upto], signs exact', async () => { const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...UPTO_ACCEPT }); const cache: X402PaymentCache = { signature: null }; @@ -303,6 +320,44 @@ describe('createX402FetchMiddleware HTTP 402 fallback', () => { const init = baseFetch.mock.calls[2]?.[1] as RequestInit; expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBe('mock-signature-base64'); }); + + it('passes the spend limit to the signer', async () => { + const baseFetch = vi + .fn() + .mockResolvedValueOnce( + new Response('', { status: 402, headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader } }) + ) + .mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => undefined, + paymentCache: { signature: null }, + maxAmountAtomicUnits: 500_000n, + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment.mock.calls[0]?.[0]?.maxAmountAtomicUnits).toBe(500_000n); + }); + + it('surfaces a refused payment instead of returning the 402 to the caller', async () => { + mockSignPayment.mockRejectedValue(new X402PaymentLimitError('x402 payment refused: $1.00')); + const baseFetch = vi + .fn() + .mockResolvedValue( + new Response('', { status: 402, headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader } }) + ); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => undefined, + paymentCache: { signature: null }, + maxAmountAtomicUnits: 500_000n, + }); + + await expect( + fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }) + ).rejects.toThrow('x402 payment refused'); + }); }); // --------------------------------------------------------------------------- diff --git a/test/unit/lib/x402/limits.test.ts b/test/unit/lib/x402/limits.test.ts new file mode 100644 index 00000000..dec8344a --- /dev/null +++ b/test/unit/lib/x402/limits.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for the x402 local spend limit helpers (`--x402-max-amount`). + */ + +import { ClientError } from '../../../../src/lib/errors.js'; +import { + MIN_AMOUNT_USD, + formatUsdAmount, + parseMaxAmountUsd, + usdToAtomicUnits, +} from '../../../../src/lib/x402/limits.js'; + +describe('usdToAtomicUnits', () => { + it('converts dollars to 6-decimal atomic units', () => { + expect(usdToAtomicUnits(1)).toBe(1_000_000n); + expect(usdToAtomicUnits(0.5)).toBe(500_000n); + expect(usdToAtomicUnits(MIN_AMOUNT_USD)).toBe(1n); + }); + + it('rounds binary-float amounts to the nearest atomic unit', () => { + // 0.07 * 1e6 is 70000.00000000001 in IEEE 754 — must not truncate to 69999 + expect(usdToAtomicUnits(0.07)).toBe(70_000n); + expect(usdToAtomicUnits(0.29)).toBe(290_000n); + }); +}); + +describe('formatUsdAmount', () => { + it('keeps cents but drops trailing noise', () => { + expect(formatUsdAmount(500_000n)).toBe('$0.50'); + expect(formatUsdAmount(1_000_000n)).toBe('$1.00'); + expect(formatUsdAmount(1_234_500n)).toBe('$1.2345'); + }); + + it('keeps sub-cent amounts readable', () => { + expect(formatUsdAmount(125n)).toBe('$0.000125'); + expect(formatUsdAmount(1n)).toBe('$0.000001'); + }); +}); + +describe('parseMaxAmountUsd', () => { + it('accepts positive dollar amounts', () => { + expect(parseMaxAmountUsd('0.50')).toBe(0.5); + expect(parseMaxAmountUsd('2')).toBe(2); + expect(parseMaxAmountUsd(' 1.25 ')).toBe(1.25); + }); + + it.each(['', '0', '-1', 'abc', '1.0abc', 'Infinity', '0.0000001'])( + 'rejects %o', + (value: string) => { + expect(() => parseMaxAmountUsd(value)).toThrow(ClientError); + expect(() => parseMaxAmountUsd(value)).toThrow('--x402-max-amount'); + } + ); +}); diff --git a/test/unit/lib/x402/signer.test.ts b/test/unit/lib/x402/signer.test.ts index d15f9070..897efa6d 100644 --- a/test/unit/lib/x402/signer.test.ts +++ b/test/unit/lib/x402/signer.test.ts @@ -11,6 +11,7 @@ import { type PaymentRequiredAccept, type SignerWallet, } from '../../../../src/lib/x402/signer.js'; +import { X402PaymentLimitError } from '../../../../src/lib/x402/limits.js'; // --------------------------------------------------------------------------- // Mocks @@ -326,6 +327,61 @@ describe('signPayment', () => { expect(result.amountUsd).toBe(3); }); + // ------------------------------------------------------------------------- + // local spend limit (--x402-max-amount) + // ------------------------------------------------------------------------- + + it('signs when the amount is at the spend limit', async () => { + const result = await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, + maxAmountAtomicUnits: BigInt(VALID_EXACT_ACCEPT.amount), + }); + expect(result.paymentSignatureBase64).toBeTruthy(); + }); + + it('refuses to sign above the spend limit, naming both amounts', async () => { + const attempt = signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, // $1.00 + maxAmountAtomicUnits: 500_000n, // $0.50 + }); + await expect(attempt).rejects.toBeInstanceOf(X402PaymentLimitError); + await expect(attempt).rejects.toThrow('$1.00'); + await expect(attempt).rejects.toThrow('$0.50 limit'); + }); + + it('caps the upto scheme by its maximum authorization', async () => { + await expect( + signPayment({ + wallet: MOCK_WALLET, + accept: VALID_UPTO_ACCEPT, // authorizes up to $5.00 + maxAmountAtomicUnits: 1_000_000n, + }) + ).rejects.toBeInstanceOf(X402PaymentLimitError); + }); + + it('checks amountOverride rather than the advertised amount', async () => { + await expect( + signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, + amountOverride: 9_000_000n, + maxAmountAtomicUnits: 2_000_000n, + }) + ).rejects.toBeInstanceOf(X402PaymentLimitError); + }); + + it('refuses an amount it cannot check against the limit', async () => { + await expect( + signPayment({ + wallet: MOCK_WALLET, + accept: { ...VALID_EXACT_ACCEPT, amount: 'not-a-number' }, + maxAmountAtomicUnits: 1_000_000n, + }) + ).rejects.toBeInstanceOf(X402PaymentLimitError); + }); + it('unsupported scheme: throws', async () => { const invalid = { ...VALID_EXACT_ACCEPT, scheme: 'unknown' }; await expect(signPayment({ wallet: MOCK_WALLET, accept: invalid })).rejects.toThrow(