diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddc950..1798e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,17 +17,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mcpc connect` (with no arguments) now auto-discovers standard MCP config files (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `~/.claude.json`, Claude Desktop, Windsurf, Kiro, etc.) in the current directory and home directory, and connects every server defined across them. Entries with duplicate session names are deduplicated (project-scoped files win over global ones). VS Code's `"servers"` key is also supported. - `mcpc connect` auto-connects to `mcp.apify.com` as `@apify` when the `APIFY_API_TOKEN` environment variable is set, using it as a Bearer token. Existing live sessions are reused without restart. +- `mcpc x402 sign` supports the x402 `upto` scheme alongside `exact`. Use `--scheme ` to pick a preference (default `auto` prefers `upto`, falls back to `exact`). The signer auto-grants a one-time `USDC.approve(PERMIT2, MAX_UINT256)` allowance on first upto sign; pass `--no-approve` to skip. +- `mcpc connect --x402 [auto|upto|exact]` enables x402 auto-payment with an optional scheme preference. Bare `--x402` defaults to `auto` (prefer upto, fall back to exact). The choice is persisted to `sessions.json` and reused on `mcpc restart`. Flag position is unrestricted β€” `mcpc connect --x402 mcp.apify.com @s` and `mcpc connect mcp.apify.com @s --x402` both work. +- Sessions using x402 auto-payment now show a yellow `[x402]` marker in session listings, alongside the existing OAuth and proxy markers. ### Changed - Stdio (command-based) config entries are now skipped by default when connecting from a config file (`mcpc connect `). Pass `--stdio` to include them. Single-entry connects (`mcpc connect file:entry @session`) are not affected. +- x402 debug logs now announce the selected scheme (`scheme=upto` / `scheme=exact`) up front and print USD amounts with 6-decimal precision (USDC atomic unit). Enable with `--verbose` or `MCPC_VERBOSE=1`. - **Breaking:** `mcpc connect --json` now always returns an array of `InitializeResult` objects (extended with `toolNames` and `_mcpc` metadata), regardless of whether you connect a single server, a config file, or auto-discover all configs. Skipped/failed entries carry `_mcpc.status` (`created` | `active` | `failed` | `skipped`) and `_mcpc.skipReason` / `_mcpc.error`. The previous wrapper-object shapes (`{configFile, results, skipped}` and `{discovered, results, skipped}`) have been removed. - `tools-call --task` now prints the task ID and recovery commands when interrupted with Ctrl+C, so you can fetch or cancel the server-side task later - Human-mode `tools-call` / `tasks-result` output no longer prints the **Structured content** section when **Content** already has at least one visible block. The JSON dump was redundant verbose output (especially for LLMs) whenever a server returned both. The section is still shown when `content` is empty or contains only a JSON-dump duplicate of `structuredContent`; use `--json` to always get the full payload ### Fixed -- `mcpc connect` and `mcpc restart` no longer fail with `ENOENT` when the macOS Keychain prompts for a password. Credentials are now read from the keychain *before* the bridge process is spawned, so the bridge's IPC startup timer no longer races a foreground password dialog (#55) +- `mcpc connect` and `mcpc restart` no longer fail with `ENOENT` when the macOS Keychain prompts for a password. Credentials are now read from the keychain _before_ the bridge process is spawned, so the bridge's IPC startup timer no longer races a foreground password dialog (#55) - Background auto-reconnect now correctly marks sessions as `unauthorized` when the server returns 401/403, instead of leaving them stuck in `connecting` after the bridge crashed on an unhandled rejection - Sessions using a static bearer token (via `--header "Authorization: ..."`) no longer flip between `unauthorized` and `connecting` on every `mcpc` invocation β€” they stay `unauthorized` until you `mcpc login` or reconnect. OAuth-profile sessions still auto-retry because tokens may have been refreshed by another session - Stdio servers no longer fail silently: the bridge now captures the child's stderr, writes it to `~/.mcpc/logs/bridge-.log`, and appends a tail of the most recent lines to `mcpc connect` errors. This makes it obvious when a stdio server crashes on startup due to e.g. missing TLS trust (`NODE_EXTRA_CA_CERTS`), missing proxy vars, or missing credentials (#195) diff --git a/README.md b/README.md index 5c2aceb..80b341f 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ and auto-reconnects on network failures or its own crashes (10s cooldown on fail **Session states:** | State | Meaning | -|------------------| ----------------------------------------------------------------------------------------------- | +| ---------------- | ----------------------------------------------------------------------------------------------- | | 🟒`live` | Bridge process running and server responding | | 🟑`connecting` | Initial bridge startup in progress (`mcpc connect`) | | 🟑`reconnecting` | Bridge crashed or lost auth; auto-reconnecting in the background | @@ -484,13 +484,13 @@ always win over stored profiles, and credentials are never silently downgraded. is missing, expired, or invalid, `mcpc` fails with an error that includes the right `mcpc login` command to recover. -| Flag | Behavior | -| ------------------------------- | ------------------------------------------------------------------------------------------- | -| `--header "Authorization: ..."` | Use explicit header; skip OAuth auto-detection. Cannot combine with `--profile`. | -| `--profile ` | Require the named profile to exist. | -| `--no-profile` | Connect anonymously even if a `default` profile exists. | -| `--x402` | Skip OAuth auto-detection; use x402 payments instead. Combine with `--profile` to use both. | -| _(none)_ | Use `default` profile if it exists; otherwise connect anonymously. | +| Flag | Behavior | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `--header "Authorization: ..."` | Use explicit header; skip OAuth auto-detection. Cannot combine with `--profile`. | +| `--profile ` | Require the named profile to exist. | +| `--no-profile` | Connect anonymously even if a `default` profile exists. | +| `--x402 [scheme]` | Skip OAuth auto-detection; use x402 payments instead. Optional scheme: `auto` (default), `upto`, `exact`. Combine with `--profile` to use both. | +| _(none)_ | Use `default` profile if it exists; otherwise connect anonymously. | Config file headers (from `--config`) apply to servers loaded from that file. @@ -680,13 +680,12 @@ This is entirely **opt-in**: existing functionality is unaffected unless you exp ### How it works -1. **Server returns HTTP 402** with a `PAYMENT-REQUIRED` header describing the price and payment details. -2. `mcpc` parses the header, signs an [EIP-3009](https://eips.ethereum.org/EIPS/eip-3009) `TransferWithAuthorization` using your local wallet. -3. `mcpc` retries the request with a `PAYMENT-SIGNATURE` header containing the signed payment. -4. The server verifies the signature and fulfills the request. +Two schemes are supported, both signed by your local wallet: -For tools that advertise pricing in their `_meta.x402` metadata, `mcpc` can **proactively sign** payments -on the first request, avoiding the 402 round-trip entirely. +- **`exact`** β€” EIP-3009 `TransferWithAuthorization`. Settles on-chain at call-time. +- **`upto`** β€” Permit2 `PermitWitnessTransferFrom`. You sign a max cap; the facilitator settles accumulated usage later. First use auto-grants a one-time `USDC.approve(PERMIT2, MAX_UINT256)` (needs a tiny native ETH float for gas). + +Flow: server returns HTTP 402 with a `PAYMENT-REQUIRED` header β†’ `mcpc` picks the best scheme per your preference, signs, and retries with `PAYMENT-SIGNATURE` β†’ server verifies and fulfills. Tools that advertise pricing in `_meta.x402` are signed proactively, skipping the 402 round-trip. ### Wallet setup @@ -730,31 +729,38 @@ mcpc x402 sign --amount 1.00 --expiry 3600 --json **Options:** -| Option | Description | -| -------------------- | ------------------------------------------------------------- | -| `--amount ` | Override the payment amount in USD (e.g. `0.50` for $0.50) | -| `--expiry ` | Override the payment expiry in seconds from now (e.g. `3600`) | +| Option | Description | +| -------------------- | ----------------------------------------------------------------------- | +| `--amount ` | Override the payment amount in USD (e.g. `0.50` for $0.50) | +| `--expiry ` | Override the payment expiry in seconds from now (e.g. `3600`) | +| `--scheme ` | Scheme preference: `auto` (default, upto > exact), `upto`, or `exact` | +| `--no-approve` | For `upto`, skip checking and auto-approving on-chain Permit2 allowance | The command outputs the signed `PAYMENT-SIGNATURE` header value and an MCP config snippet that can be used directly with other MCP clients. ### Using x402 with MCP servers -Pass the `--x402` flag when connecting to a session or running direct commands: +Pass the `--x402` flag when connecting to a session. It accepts an optional scheme preference +(`auto`, `upto`, or `exact`); bare `--x402` defaults to `auto`. ```bash -# Create a session with x402 payment support +# Create a session with x402 payment support (auto picks the best advertised scheme) mcpc connect mcp.apify.com @apify --x402 -# The session now automatically handles 402 responses +# Pin a specific scheme β€” position doesn't matter, before or after positional args +mcpc connect --x402 upto mcp.apify.com @apify +mcpc connect mcp.apify.com @apify --x402 exact + +# The session now automatically handles 402 responses using your preference mcpc @apify tools-call expensive-tool query:="hello" -# Restart a session with x402 enabled -mcpc @apify restart --x402 +# Restart re-uses the saved scheme from sessions.json β€” no need to repeat the flag +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. +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. ### Supported networks diff --git a/src/bridge/index.ts b/src/bridge/index.ts index fd556da..c8daf6f 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -10,8 +10,8 @@ import { createServer, type Server as NetServer, type Socket } from 'net'; import { unlink } from 'fs/promises'; import { createMcpClient, CreateMcpClientOptions } from '../core/index.js'; import type { McpClient } from '../core/index.js'; -import type { ServerConfig, IpcMessage, LoggingLevel } from '../lib/index.js'; -import { KEEPALIVE_INTERVAL_MS } from '../lib/types.js'; +import type { ServerConfig, IpcMessage, LoggingLevel, X402SchemePreference } from '../lib/index.js'; +import { KEEPALIVE_INTERVAL_MS, X402_SCHEME_PREFERENCES } from '../lib/types.js'; import { createLogger, setVerbose, initFileLogger, closeFileLogger } from '../lib/index.js'; import { fileExists, @@ -74,7 +74,8 @@ interface BridgeOptions { profileName?: string; // Auth profile name for token refresh proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) - x402?: boolean; // Enable x402 auto-payment + /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ + x402?: X402SchemePreference; insecure?: boolean; // Skip TLS certificate verification } @@ -615,6 +616,7 @@ class BridgeProcess { wallet, getToolByName, paymentCache: this.x402PaymentCache, + ...(this.options.x402 && { schemePreference: this.options.x402 }), }); } @@ -1111,7 +1113,7 @@ class BridgeProcess { const paymentRequired = extractPaymentRequiredFromResult(toolResult); if (!paymentRequired) return { handled: false }; - const parsed = extractAcceptFromPaymentRequired(paymentRequired); + const parsed = extractAcceptFromPaymentRequired(paymentRequired, this.options.x402); if (!parsed) { logger.warn('Payment-required tool result but could not extract supported payment terms'); return { handled: false }; @@ -1129,7 +1131,7 @@ class BridgeProcess { }); this.x402PaymentCache.signature = signed.paymentSignatureBase64; logger.debug( - `Fresh payment signed for retry: $${signed.amountUsd.toFixed(4)} to ${signed.to} on ${signed.networkLabel}` + `Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}` ); } catch (signError) { logger.warn('Failed to sign fresh payment for 402 retry:', signError); @@ -1613,7 +1615,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402] [--insecure]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402 ] [--insecure]' ); process.exit(1); } @@ -1648,8 +1650,19 @@ async function main(): Promise { mcpSessionId = args[mcpSessionIdIndex + 1]; } - // Parse --x402 flag (for x402 payment signing) - const x402 = args.includes('--x402'); + // Parse `--x402 ` (CLI always spawns the bridge with an explicit value). + let x402: X402SchemePreference | undefined; + const x402Index = args.indexOf('--x402'); + if (x402Index !== -1) { + const value = args[x402Index + 1]; + if (value === undefined || !(X402_SCHEME_PREFERENCES as readonly string[]).includes(value)) { + console.error( + `--x402 requires a scheme: ${X402_SCHEME_PREFERENCES.join('|')} (got ${value ?? ''})` + ); + process.exit(1); + } + x402 = value as X402SchemePreference; + } // Parse --insecure flag (skip TLS certificate verification) const insecure = args.includes('--insecure'); @@ -1674,7 +1687,7 @@ async function main(): Promise { bridgeOptions.mcpSessionId = mcpSessionId; } if (x402) { - bridgeOptions.x402 = true; + bridgeOptions.x402 = x402; } if (insecure) { bridgeOptions.insecure = true; diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 142083b..3669da1 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -15,7 +15,12 @@ import { redactHeaders, } from '../../lib/index.js'; import { DISCONNECTED_THRESHOLD_MS } from '../../lib/types.js'; -import type { ServerConfig, ProxyConfig, ServerDetails } from '../../lib/types.js'; +import type { + ServerConfig, + ProxyConfig, + ServerDetails, + X402SchemePreference, +} from '../../lib/types.js'; import { formatOutput, formatSuccess, @@ -299,7 +304,7 @@ export async function connectSession( noProfile?: boolean; proxy?: string; proxyBearerToken?: string; - x402?: boolean; + x402?: X402SchemePreference; insecure?: boolean; skipDetails?: boolean; quiet?: boolean; @@ -461,7 +466,7 @@ export async function connectSession( server: sessionTransportConfig, ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), - ...(options.x402 && { x402: true }), + ...(options.x402 && { x402: options.x402 }), ...(options.insecure && { insecure: true }), // Clear any previous error status (unauthorized, expired) when reconnecting ...(isReconnect && { status: 'active' }), @@ -498,7 +503,7 @@ export async function connectSession( bridgeOptions.proxyConfig = proxyConfig; } if (options.x402) { - bridgeOptions.x402 = true; + bridgeOptions.x402 = options.x402; } if (options.insecure) { bridgeOptions.insecure = true; @@ -1019,7 +1024,7 @@ type BulkConnectOptions = { proxy?: string; proxyBearerToken?: string; stdio?: boolean; - x402?: boolean; + x402?: X402SchemePreference; insecure?: boolean; }; diff --git a/src/cli/commands/x402.ts b/src/cli/commands/x402.ts index 8f89a71..b64afeb 100644 --- a/src/cli/commands/x402.ts +++ b/src/cli/commands/x402.ts @@ -233,8 +233,10 @@ async function removeWalletCmd(options: { outputMode: OutputMode }): Promise { throw new ClientError('No wallet configured. Create one with: mcpc x402 init'); } + // Resolve scheme preference + const schemePreference = + options.scheme === 'upto' || options.scheme === 'exact' || options.scheme === 'auto' + ? options.scheme + : 'auto'; + // Parse PAYMENT-REQUIRED header - const { header, accept } = parsePaymentRequired(options.paymentRequired); + const { header, accept } = parsePaymentRequired(options.paymentRequired, schemePreference); // Resolve overrides let amountOverride: bigint | undefined; @@ -265,6 +273,7 @@ async function signPaymentCommand(options: SignOptions): Promise { resource: header.resource, ...(amountOverride !== undefined && { amountOverride }), ...(expiryOverride !== undefined && { expiryOverride }), + ...(options.noApprove === true && { skipPermit2Approval: true }), }); if (options.outputMode === 'json') { @@ -346,8 +355,10 @@ export async function handleX402Command(args: string[]): Promise { 'after', ` ${chalk.bold('sign options:')} - --amount Override amount in USD - --expiry Override expiry in seconds` + --amount Override amount in USD (for upto: max authorization cap) + --expiry Override expiry in seconds + --scheme Payment scheme: auto (default), upto, or exact + --no-approve Skip the upto Permit2 allowance check & auto-approval` ); const resolveOutputMode = (cmd: Command): OutputMode => { @@ -387,16 +398,33 @@ ${chalk.bold('sign options:')} .command('sign ') .description('Sign a payment using the wallet') .helpOption('-h, --help', 'Display help') - .option('--amount ', 'Override amount in USD') + .option( + '--amount ', + 'Override amount in USD (for upto this sets the max authorization cap)' + ) .option('--expiry ', 'Override expiry in seconds') - .action(async (paymentRequired, opts, cmd) => { - await signPaymentCommand({ + .option('--scheme ', 'Payment scheme preference (default: auto)', 'auto') + .option( + '--no-approve', + 'For the upto scheme: skip the on-chain Permit2 allowance check & auto-approval' + ) + .action( + async ( paymentRequired, - amount: opts.amount, - expiry: opts.expiry, - outputMode: resolveOutputMode(cmd), - }); - }); + opts: { amount?: string; expiry?: string; scheme?: string; approve?: boolean }, + cmd + ) => { + // Commander turns --no-approve into opts.approve = false + await signPaymentCommand({ + paymentRequired, + amount: opts.amount, + expiry: opts.expiry, + scheme: opts.scheme, + noApprove: opts.approve === false, + outputMode: resolveOutputMode(cmd), + }); + } + ); // Show help if no subcommand if (args.length === 0) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 72efb0a..bff4990 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,9 +27,11 @@ import * as tasks from './commands/tasks.js'; import * as grepCmd from './commands/grep.js'; import { handleX402Command } from './commands/x402.js'; import { clean } from './commands/clean.js'; -import type { OutputMode } from '../lib/index.js'; +import type { OutputMode, X402SchemePreference } from '../lib/index.js'; +import { X402_SCHEME_PREFERENCES } from '../lib/index.js'; import { extractOptions, + preProcessX402Argv, getVerboseFromEnv, getJsonFromEnv, validateOptions, @@ -63,7 +65,11 @@ interface HandlerOptions { verbose?: boolean; profile?: string; noProfile?: boolean; - x402?: boolean; + /** + * x402 scheme preference. Presence enables x402 for the run; value is the preference. + * `--x402` (no value) resolves to `'auto'` (prefer upto, fall back to exact). + */ + x402?: X402SchemePreference; insecure?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; @@ -95,7 +101,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.timeout) { const timeout = parseInt(opts.timeout as string, 10); if (isNaN(timeout) || timeout <= 0) { - throw new Error( + throw new ClientError( `Invalid --timeout value: "${opts.timeout as string}". Must be a positive number (seconds).` ); } @@ -107,13 +113,26 @@ function getOptionsFromCommand(command: Command): HandlerOptions { options.profile = opts.profile; } if (verbose) options.verbose = verbose; - if (opts.x402) options.x402 = true; + + // Commander returns `true` for `--x402` (no value) and a string for `--x402 `. + // Normalise to the canonical scheme preference; reject other strings loudly so + // commander's greedy [optional] arg parser can't silently eat a positional like a URL. + if (opts.x402 === true) { + options.x402 = 'auto'; + } else if (typeof opts.x402 === 'string') { + if (!(X402_SCHEME_PREFERENCES as readonly string[]).includes(opts.x402)) { + throw new ClientError( + `Invalid --x402 value: "${opts.x402}". Expected one of ${X402_SCHEME_PREFERENCES.join(', ')}, or pass --x402 with no value for the default.` + ); + } + options.x402 = opts.x402 as X402SchemePreference; + } if (opts.insecure) options.insecure = true; if (opts.schema) options.schema = opts.schema; if (opts.schemaMode) { const mode = opts.schemaMode as string; if (mode !== 'strict' && mode !== 'compatible' && mode !== 'ignore') { - throw new Error( + throw new ClientError( `Invalid --schema-mode value: "${mode}". Valid modes are: strict, compatible, ignore` ); } @@ -123,7 +142,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.maxChars) { const maxChars = parseInt(opts.maxChars as string, 10); if (isNaN(maxChars) || maxChars <= 0) { - throw new Error( + throw new ClientError( `Invalid --max-chars value: "${opts.maxChars as string}". Must be a positive number (characters).` ); } @@ -146,6 +165,9 @@ function jsonHelp(description: string, shape?: string, schemaUrl?: string): stri const SCHEMA_BASE = 'https://modelcontextprotocol.io/specification/2025-11-25/schema'; async function main(): Promise { + // Disambiguate `--x402 ` (URL, @session, etc.) so Commander's + // greedy [optional] arg parser doesn't eat the next positional as the value. + process.argv = preProcessX402Argv(process.argv); const args = process.argv.slice(2); // Set up cleanup handlers for graceful shutdown @@ -440,7 +462,10 @@ Full docs: ${docsUrl}` .option('--proxy <[host:]port>', 'Start proxy MCP server for session') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') .option('--stdio', 'Launch all local stdio servers from selected config files') - .option('--x402', 'Enable x402 auto-payment using the configured wallet') + .option( + '--x402 [scheme]', + 'Enable x402 auto-payment using the configured wallet; optional scheme: auto (default, prefer upto), upto, or exact.' + ) .addHelpText( 'after', ` @@ -496,7 +521,7 @@ ${jsonHelp( ...(opts.proxy && { proxy: opts.proxy as string }), ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), ...(opts.stdio && { stdio: true }), - ...(opts.x402 && { x402: opts.x402 as boolean }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -525,7 +550,7 @@ ${jsonHelp( ...(opts.proxy && { proxy: opts.proxy as string }), ...(opts.proxyBearerToken && { proxyBearerToken: opts.proxyBearerToken as string }), ...(opts.stdio && { stdio: true }), - ...(opts.x402 && { x402: opts.x402 as boolean }), + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); return; @@ -549,7 +574,7 @@ ${jsonHelp( config: parsed.file, proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, - x402: opts.x402, + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); } else { @@ -558,7 +583,7 @@ ${jsonHelp( ...(headers && { headers }), proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, - x402: opts.x402, + ...(globalOpts.x402 && { x402: globalOpts.x402 }), ...(globalOpts.insecure && { insecure: true }), }); } diff --git a/src/cli/output.ts b/src/cli/output.ts index e23c535..68bf46f 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1309,9 +1309,12 @@ export function formatSessionLine(session: SessionData): string { } const targetStr = truncateWithEllipsis(target, 80); - // Format auth info (transport type omitted β€” obvious from context) + // Format auth info. OAuth and x402 are mutually exclusive auth mechanisms; + // x402 takes precedence when both happen to be present on the session record. let infoStr = ''; - if (!session.server.command && session.profileName) { + if (session.x402) { + infoStr = theme.yellow('[x402]'); + } else if (!session.server.command && session.profileName) { infoStr = chalk.dim('(OAuth: ') + theme.magenta(session.profileName) + chalk.dim(')'); } diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 09c334e..68f3866 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -3,6 +3,7 @@ * Pure functions with no external dependencies for easy testing */ import { ClientError } from '../lib/index.js'; +import { X402_SCHEME_PREFERENCES } from '../lib/types.js'; /** * Check if an environment variable is set to a truthy value @@ -28,6 +29,35 @@ export function getJsonFromEnv(): boolean { return isEnvTrue(process.env.MCPC_JSON); } +/** + * Disambiguate `--x402 ` when `` is not a valid scheme. + * + * Commander's `[optional]` arg parser greedily consumes the next token as the + * option value, so `mcpc connect --x402 mcp.apify.com @s` would parse the URL + * as the scheme. Rewriting such cases to `--x402=auto` leaves `` as a + * positional. Tokens following `--x402` that match a real scheme are passed + * through unchanged. + */ +export function preProcessX402Argv(argv: string[]): string[] { + const schemes = X402_SCHEME_PREFERENCES as readonly string[]; + const out: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg !== '--x402') { + out.push(arg); + continue; + } + const next = argv[i + 1]; + if (next !== undefined && schemes.includes(next)) { + out.push(arg, next); + i++; + } else { + out.push('--x402=auto'); + } + } + return out; +} + // Global options that take a value (not boolean flags) const GLOBAL_OPTIONS_WITH_VALUES = ['--timeout', '--profile', '--max-chars']; @@ -293,7 +323,6 @@ export function validateArgValues(args: string[]): void { export function extractOptions(args: string[]): { timeout?: number; profile?: string; - x402?: boolean; insecure?: boolean; verbose: boolean; json: boolean; @@ -314,9 +343,6 @@ export function extractOptions(args: string[]): { const profile = profileIndex >= 0 && profileIndex + 1 < args.length ? args[profileIndex + 1] : undefined; - // Extract --x402 (boolean flag) - const x402 = args.includes('--x402') || undefined; - // Extract --insecure (boolean flag) const insecure = args.includes('--insecure') || undefined; @@ -324,7 +350,6 @@ export function extractOptions(args: string[]): { ...options, ...(timeout !== undefined && { timeout }), ...(profile && { profile }), - ...(x402 && { x402 }), ...(insecure && { insecure }), }; } diff --git a/src/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index f8c4cc3..cd4c582 100644 --- a/src/lib/bridge-manager.ts +++ b/src/lib/bridge-manager.ts @@ -15,7 +15,13 @@ import { spawn, type ChildProcess } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import type { ServerConfig, AuthCredentials, ProxyConfig, X402WalletCredentials } from './types.js'; +import type { + ServerConfig, + AuthCredentials, + ProxyConfig, + X402WalletCredentials, + X402SchemePreference, +} from './types.js'; import { getSocketPath, waitForFile, @@ -100,7 +106,8 @@ export interface StartBridgeOptions { headers?: Record; // Headers to send via IPC (caller stores in keychain) proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) - x402?: boolean; // Enable x402 auto-payment using the wallet + /** x402 scheme preference; presence enables x402 auto-payment, absence disables. */ + x402?: X402SchemePreference; insecure?: boolean; // Skip TLS certificate verification } @@ -188,10 +195,10 @@ export async function startBridge(options: StartBridgeOptions): Promise") profileName?: string; // Name of auth profile (for OAuth servers) - x402?: boolean; // x402 auto-payment enabled for this session + /** + * x402 auto-payment scheme preference. Presence enables x402 for the session; + * the value is the preference (`auto` = prefer upto, fall back to exact). + * Absent / undefined means x402 is disabled. + */ + x402?: X402SchemePreference; insecure?: boolean; // Skip TLS certificate verification pid?: number; // Bridge process PID protocolVersion?: string; // Negotiated MCP version diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index 0c326a8..b28f3cd 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -21,9 +21,12 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { signPayment, parsePaymentRequired, + selectAcceptEntry, + DEFAULT_PAYMENT_EXPIRY_SECONDS, type SignerWallet, type PaymentRequiredAccept, type PaymentRequiredHeader, + type SchemePreference, } from './signer.js'; import { createLogger } from '../logger.js'; @@ -32,16 +35,26 @@ const logger = createLogger('x402-middleware'); /** MCP _meta key for x402 payment (per x402 MCP spec) */ const MCP_PAYMENT_META_KEY = 'x402/payment'; -/** Payment information from tool's _meta.x402 */ +/** + * Payment information from tool's `_meta.x402`. + * + * Apify exposes two shapes side-by-side: + * - **`accepts[]`** carries every advertised scheme (post apify-mcp-server #876). + * Walk this when present β€” it's the only way to honor the session's `--x402 ` + * preference against servers that advertise multiple schemes. + * - **Flat preferred fields** mirror the server's preferred entry for back-compat + * with clients that don't iterate `accepts[]`. Used as a fallback. + */ interface ToolPaymentMeta { paymentRequired: boolean; + accepts?: PaymentRequiredAccept[]; scheme?: string; network?: string; amount?: string; asset?: string; payTo?: string; maxTimeoutSeconds?: number; - extra?: { name?: string; version?: string }; + extra?: { name?: string; version?: string; facilitatorAddress?: string }; } /** Parsed JSON-RPC request body (enough to identify tools/call) */ @@ -79,6 +92,9 @@ export interface X402FetchMiddlewareOptions { /** Shared mutable cache for reusing payment signatures across tool calls */ paymentCache: X402PaymentCache; + + /** Payment scheme preference when multiple accepts are available (default: auto) */ + schemePreference?: SchemePreference; } /** @@ -93,11 +109,17 @@ export function createX402FetchMiddleware( baseFetch: FetchLike, options: X402FetchMiddlewareOptions ): FetchLike { - const { wallet, getToolByName, paymentCache } = options; + const { wallet, getToolByName, paymentCache, schemePreference } = options; return async (url: string | URL, init?: RequestInit): Promise => { // Try to get a payment signature (cached or freshly signed) for tools/call requests - const paymentSignature = await getOrSignPayment(init, wallet, getToolByName, paymentCache); + const paymentSignature = await getOrSignPayment( + init, + wallet, + getToolByName, + paymentCache, + schemePreference + ); if (paymentSignature) { const enhancedInit = injectPayment(init, paymentSignature); const response = await baseFetch(url, enhancedInit); @@ -110,7 +132,15 @@ export function createX402FetchMiddleware( // HTTP 402 β€” invalidate cache and fall through to fallback logger.debug('Payment rejected (HTTP 402), invalidating cache'); paymentCache.signature = null; - return handle402Fallback(url, init, response, baseFetch, wallet, paymentCache); + return handle402Fallback( + url, + init, + response, + baseFetch, + wallet, + paymentCache, + schemePreference + ); } // No payment needed β€” make request normally @@ -118,7 +148,15 @@ export function createX402FetchMiddleware( // Check for HTTP 402 fallback if (response.status === 402) { - return handle402Fallback(url, init, response, baseFetch, wallet, paymentCache); + return handle402Fallback( + url, + init, + response, + baseFetch, + wallet, + paymentCache, + schemePreference + ); } return response; @@ -134,7 +172,8 @@ async function getOrSignPayment( init: RequestInit | undefined, wallet: SignerWallet, getToolByName: ((name: string) => Tool | undefined) | undefined, - paymentCache: X402PaymentCache + paymentCache: X402PaymentCache, + schemePreference?: SchemePreference ): Promise { if (!getToolByName || !init?.body) { return undefined; @@ -171,29 +210,18 @@ async function getOrSignPayment( return paymentCache.signature; } - // Check if we have enough info to sign - if (!x402.scheme || !x402.network || !x402.amount || !x402.asset || !x402.payTo) { + const accept = selectAcceptFromToolMeta(x402, schemePreference); + if (!accept) { logger.debug( - `Tool "${toolName}" has x402 metadata but missing fields, skipping payment signing` + `Tool "${toolName}" _meta.x402 does not advertise a usable accept for schemePreference=${schemePreference ?? 'auto'}, deferring to 402 fallback` ); return undefined; } - // Build accept from tool metadata and sign fresh - const accept: PaymentRequiredAccept = { - scheme: x402.scheme, - network: x402.network, - amount: x402.amount, - asset: x402.asset, - payTo: x402.payTo, - maxTimeoutSeconds: x402.maxTimeoutSeconds || 3600, - ...(x402.extra && { extra: x402.extra }), - }; - try { const result = await signPayment({ wallet, accept }); logger.debug( - `Fresh payment signed: $${result.amountUsd.toFixed(4)} to ${result.to} on ${result.networkLabel}` + `Fresh payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); paymentCache.signature = result.paymentSignatureBase64; return result.paymentSignatureBase64; @@ -213,7 +241,8 @@ async function handle402Fallback( response402: Response, baseFetch: FetchLike, wallet: SignerWallet, - paymentCache: X402PaymentCache + paymentCache: X402PaymentCache, + schemePreference?: SchemePreference ): Promise { // Extract PAYMENT-REQUIRED header (case-insensitive) const paymentRequiredBase64 = @@ -229,7 +258,7 @@ async function handle402Fallback( let header: PaymentRequiredHeader; let accept: PaymentRequiredAccept; try { - ({ header, accept } = parsePaymentRequired(paymentRequiredBase64)); + ({ header, accept } = parsePaymentRequired(paymentRequiredBase64, schemePreference)); } catch (error) { logger.warn('Failed to parse PAYMENT-REQUIRED header:', error); return response402; @@ -244,7 +273,7 @@ async function handle402Fallback( }); logger.debug( - `402 fallback payment signed: $${result.amountUsd.toFixed(4)} to ${result.to} on ${result.networkLabel}` + `402 fallback payment signed: scheme=${accept.scheme} amount=$${result.amountUsd.toFixed(6)} to=${result.to} network=${result.networkLabel}` ); // Cache the freshly signed payment for subsequent calls @@ -294,12 +323,51 @@ function extractToolCallName(body: RequestInit['body'] | undefined): string | un } /** - * Extract `PaymentRequiredAccept` from a PaymentRequired object. - * Returns the first "exact" scheme accept entry, or undefined if not found. + * Pick an accept entry from a tool's `_meta.x402` block honoring `schemePreference`. + * + * Prefers the spec-shaped `accepts[]` array when present; falls back to the flat fields + * only when the preference matches the flat scheme (or the preference is `auto`). + * Returning undefined defers signing to the 402 fallback path, which re-runs the + * selector against the authoritative PAYMENT-REQUIRED header. + */ +function selectAcceptFromToolMeta( + x402: ToolPaymentMeta, + schemePreference?: SchemePreference +): PaymentRequiredAccept | undefined { + const preference = schemePreference ?? 'auto'; + + if (Array.isArray(x402.accepts) && x402.accepts.length > 0) { + return selectAcceptEntry(x402.accepts, preference); + } + + if (!x402.scheme || !x402.network || !x402.amount || !x402.asset || !x402.payTo) { + return undefined; + } + if (preference !== 'auto' && preference !== x402.scheme) { + return undefined; + } + + return { + scheme: x402.scheme, + network: x402.network, + amount: x402.amount, + asset: x402.asset, + payTo: x402.payTo, + maxTimeoutSeconds: x402.maxTimeoutSeconds || DEFAULT_PAYMENT_EXPIRY_SECONDS, + ...(x402.extra && { extra: x402.extra }), + }; +} + +/** + * Extract `PaymentRequiredAccept` from a PaymentRequired object honoring scheme preference. * - * The data is expected to have the shape: `{ x402Version, accepts: [...] }` + * Expected shape: `{ x402Version, accepts: [...] }`. Default preference is `auto` + * (prefer upto, fall back to exact) when the caller doesn't pin one. */ -export function extractAcceptFromPaymentRequired(data: unknown): +export function extractAcceptFromPaymentRequired( + data: unknown, + schemePreference?: SchemePreference +): | { accept: PaymentRequiredAccept; resource?: { url?: string; description?: string; mimeType?: string }; @@ -310,8 +378,11 @@ export function extractAcceptFromPaymentRequired(data: unknown): const obj = data as Record; if (!Array.isArray(obj.accepts) || obj.accepts.length === 0) return undefined; - const accept = (obj.accepts as PaymentRequiredAccept[]).find((a) => a.scheme === 'exact'); - if (!accept || !accept.payTo || !accept.amount || !accept.network || !accept.asset) { + const accept = selectAcceptEntry( + obj.accepts as PaymentRequiredAccept[], + schemePreference ?? 'auto' + ); + if (!accept) { return undefined; } @@ -365,7 +436,7 @@ export function extractPaymentRequiredFromResult( const content = toolResult.content; if (!Array.isArray(content) || content.length === 0) return undefined; - const first = content[0] as ToolResultContent | undefined; + const first = content[0]; if (!first || first.type !== 'text' || typeof first.text !== 'string') return undefined; try { diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts index 4217033..94c2f25 100644 --- a/src/lib/x402/signer.ts +++ b/src/lib/x402/signer.ts @@ -1,13 +1,25 @@ /** * x402 payment signing logic - * Reusable module for signing EIP-3009 TransferWithAuthorization payments. + * Reusable module for signing EIP-3009 TransferWithAuthorization (exact scheme) + * and Permit2 permitWitnessTransferFrom (upto scheme) payments. * Used by both the CLI `x402 sign` command and the fetch middleware. */ import { privateKeyToAccount } from 'viem/accounts'; -import { createWalletClient, http, type Hex } from 'viem'; +import { + createPublicClient, + createWalletClient, + encodeFunctionData, + getAddress, + http, + type Hex, +} from 'viem'; import { base, baseSepolia } from 'viem/chains'; import { ClientError } from '../errors.js'; +import { createLogger } from '../logger.js'; +import type { X402SchemePreference } from '../types.js'; + +const logger = createLogger('x402-signer'); // --------------------------------------------------------------------------- // Constants @@ -16,6 +28,48 @@ import { ClientError } from '../errors.js'; 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; + +/** Canonical Permit2 contract address (CREATE2, same on all EVM chains). */ +const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; + +/** x402 upto scheme Permit2 proxy contract address (vanity: 0x4020…0002). */ +const X402_UPTO_PERMIT2_PROXY = '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002'; + +/** Clock-skew grace period for validAfter (seconds). */ +const VALID_AFTER_CLOCK_SKEW_SECONDS = 600; + +/** Maximum uint256 β€” used for unlimited Permit2 allowance approval. */ +const MAX_UINT256 = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); + +/** Minimal ERC-20 ABI fragments for allowance / approve. */ +const ERC20_ALLOWANCE_ABI = [ + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const; + +const ERC20_APPROVE_ABI = [ + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + }, +] as const; + const TRANSFER_WITH_AUTHORIZATION_TYPES = { TransferWithAuthorization: [ { name: 'from', type: 'address' }, @@ -27,6 +81,25 @@ const TRANSFER_WITH_AUTHORIZATION_TYPES = { ], } as const; +const UPTO_PERMIT2_WITNESS_TYPES = { + PermitWitnessTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'witness', type: 'Witness' }, + ], + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + Witness: [ + { name: 'to', type: 'address' }, + { name: 'facilitator', type: 'address' }, + { name: 'validAfter', type: 'uint256' }, + ], +} as const; + // --------------------------------------------------------------------------- // Network configs // --------------------------------------------------------------------------- @@ -64,7 +137,7 @@ export interface PaymentRequiredAccept { asset: string; payTo: string; maxTimeoutSeconds: number; - extra?: { name?: string; version?: string }; + extra?: { name?: string; version?: string; facilitatorAddress?: string }; } export interface PaymentRequiredHeader { @@ -73,6 +146,8 @@ export interface PaymentRequiredHeader { accepts: PaymentRequiredAccept[]; } +export type SchemePreference = X402SchemePreference; + /** Minimal wallet info needed for signing */ export interface SignerWallet { privateKey: string; // Hex with 0x prefix @@ -85,8 +160,16 @@ export interface SignPaymentInput { resource?: PaymentRequiredHeader['resource']; /** Override amount in atomic units (default: from accept.amount) */ amountOverride?: bigint; - /** Override expiry in seconds (default: from accept.maxTimeoutSeconds or 3600) */ + /** Override expiry in seconds (default: `accept.maxTimeoutSeconds` or `DEFAULT_PAYMENT_EXPIRY_SECONDS`) */ expiryOverride?: number; + /** + * For the upto scheme: skip the on-chain Permit2 allowance check & auto-approval. + * Default false β€” the signer will check `USDC.allowance(wallet, PERMIT2)` and submit a + * one-time `USDC.approve(PERMIT2, MAX_UINT256)` transaction if the allowance is short of + * the amount being authorized. Pass `true` if you've already approved or want to manage + * approvals yourself. + */ + skipPermit2Approval?: boolean; } export interface SignPaymentResult { @@ -116,15 +199,73 @@ function randomBytes32(): Hex { return ('0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')) as Hex; } +/** + * 256-bit random nonce for Permit2, encoded as a uint256 *decimal* string. + * + * Permit2 (used by the upto scheme) expects a uint256 nonce β€” distinct from + * EIP-3009's `bytes32` nonce. Sending the hex-encoded form to a strict facilitator + * (e.g. CDP) makes the whole `permit2Authorization` payload fail JSON-schema validation. + * + * Matches the official x402 SDK's `createPermit2Nonce()` byte-for-byte. + */ +function randomPermit2Nonce(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const hex = '0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + return BigInt(hex).toString(); +} + +// --------------------------------------------------------------------------- +// Scheme selection +// --------------------------------------------------------------------------- + +function isValidExactAccept(a: PaymentRequiredAccept): boolean { + return a.scheme === 'exact' && Boolean(a.payTo && a.amount && a.network && a.asset); +} + +function isValidUptoAccept(a: PaymentRequiredAccept): boolean { + return ( + a.scheme === 'upto' && + Boolean(a.payTo && a.amount && a.network && a.asset) && + Boolean(a.extra?.facilitatorAddress) + ); +} + +/** + * Select the best accept entry from the array based on user preference. + * + * - `auto` β†’ prefer valid `upto`, fallback to valid `exact` + * - `upto` β†’ require valid `upto`; undefined if none + * - `exact` β†’ require valid `exact`; undefined if none + */ +export function selectAcceptEntry( + accepts: PaymentRequiredAccept[], + preference: SchemePreference = 'auto' +): PaymentRequiredAccept | undefined { + if (preference === 'upto') { + return accepts.find(isValidUptoAccept); + } + if (preference === 'exact') { + return accepts.find(isValidExactAccept); + } + // auto: prefer upto, fallback exact + const upto = accepts.find(isValidUptoAccept); + if (upto) return upto; + return accepts.find(isValidExactAccept); +} + // --------------------------------------------------------------------------- // PAYMENT-REQUIRED header parsing // --------------------------------------------------------------------------- /** - * Parse a base64-encoded PAYMENT-REQUIRED header value - * Returns the parsed header and the first "exact" scheme accept entry + * Parse a base64-encoded PAYMENT-REQUIRED header value. + * Returns the parsed header and the selected accept entry based on scheme preference. */ -export function parsePaymentRequired(base64Value: string): { +export function parsePaymentRequired( + base64Value: string, + schemePreference: SchemePreference = 'auto' +): { header: PaymentRequiredHeader; accept: PaymentRequiredAccept; } { @@ -146,10 +287,12 @@ export function parsePaymentRequired(base64Value: string): { throw new ClientError('PAYMENT-REQUIRED header has no "accepts" entries.'); } - const accept = header.accepts.find((a) => a.scheme === 'exact'); + const accept = selectAcceptEntry(header.accepts, schemePreference); if (!accept) { + const requested = schemePreference === 'auto' ? 'exact or upto' : schemePreference; + const available = header.accepts.map((a) => a.scheme).join(', '); throw new ClientError( - `No "exact" scheme found in PAYMENT-REQUIRED accepts. Available: ${header.accepts.map((a) => a.scheme).join(', ')}` + `No valid "${requested}" scheme found in PAYMENT-REQUIRED accepts. Available: ${available}` ); } @@ -167,9 +310,27 @@ export function parsePaymentRequired(base64Value: string): { // --------------------------------------------------------------------------- /** - * Sign an x402 payment and return a base64-encoded PAYMENT-SIGNATURE header value + * 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; + logger.debug( + `Signing x402 payment: scheme=${accept.scheme} network=${accept.network} amount=${accept.amount} asset=${accept.asset} payTo=${accept.payTo} facilitator=${accept.extra?.facilitatorAddress ?? ''}` + ); + if (accept.scheme === 'upto') { + return signUptoPayment(input); + } + if (accept.scheme === 'exact') { + return signExactPayment(input); + } + throw new ClientError(`Unsupported x402 scheme: ${accept.scheme}`); +} + +/** + * Sign an x402 `exact` scheme payment using EIP-3009 TransferWithAuthorization. + */ +async function signExactPayment(input: SignPaymentInput): Promise { const { wallet, accept, resource } = input; // Resolve network @@ -185,7 +346,8 @@ export async function signPayment(input: SignPaymentInput): Promise { + const { wallet, tokenAddress, requiredAmount, networkConfig } = params; + const walletAddress = getAddress(wallet.address) as Hex; + const permit2 = getAddress(PERMIT2_ADDRESS) as Hex; + + const publicClient = createPublicClient({ + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + const currentAllowance = (await publicClient.readContract({ + address: tokenAddress, + abi: ERC20_ALLOWANCE_ABI, + functionName: 'allowance', + args: [walletAddress, permit2], + })) as bigint; + + if (currentAllowance >= requiredAmount) { + logger.debug( + `Permit2 allowance sufficient (${currentAllowance.toString()} >= ${requiredAmount.toString()})` + ); + return { previousAllowance: currentAllowance, newAllowance: currentAllowance }; + } + + logger.info( + `Permit2 allowance is ${currentAllowance.toString()} < ${requiredAmount.toString()} required. Submitting one-time approve(MAX_UINT256) transaction…` + ); + + const account = privateKeyToAccount(wallet.privateKey as Hex); + const walletClient = createWalletClient({ + account, + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + const approveData = encodeFunctionData({ + abi: ERC20_APPROVE_ABI, + functionName: 'approve', + args: [permit2, MAX_UINT256], + }); + + const approveTxHash = await walletClient.sendTransaction({ + to: tokenAddress, + data: approveData, + }); + + logger.info(`Permit2 approve tx submitted: ${approveTxHash}. Waiting for confirmation…`); + + const receipt = await publicClient.waitForTransactionReceipt({ hash: approveTxHash }); + if (receipt.status !== 'success') { + throw new ClientError( + `Permit2 approve transaction reverted on-chain: ${approveTxHash}. Inspect at ${networkConfig.label} explorer.` + ); + } + + // Re-read to confirm. + const newAllowance = (await publicClient.readContract({ + address: tokenAddress, + abi: ERC20_ALLOWANCE_ABI, + functionName: 'allowance', + args: [walletAddress, permit2], + })) as bigint; + + logger.info( + `Permit2 approve confirmed in block ${receipt.blockNumber}. New allowance: ${newAllowance.toString()}` + ); + + return { approveTxHash, previousAllowance: currentAllowance, newAllowance }; +} + +/** + * Sign an x402 `upto` scheme payment using Permit2 permitWitnessTransferFrom. + * The payer authorizes a maximum amount; the facilitator settles the actual usage later. + * + * Before signing, this checks `USDC.allowance(wallet, PERMIT2)` and, if insufficient, submits + * a one-time `USDC.approve(PERMIT2, MAX_UINT256)` transaction so the upto scheme can actually + * settle on-chain. Pass `skipPermit2Approval: true` to bypass the check. + */ +async function signUptoPayment(input: SignPaymentInput): Promise { + const { wallet, accept, resource } = input; + + // Resolve network + const networkConfig = NETWORKS[accept.network]; + if (!networkConfig) { + throw new ClientError( + `Unknown network "${accept.network}" in payment requirements. Supported: ${Object.keys(NETWORKS).join(', ')}` + ); + } + + // Resolve amount (max authorization cap) + const amountAtomicUnits = input.amountOverride ?? BigInt(accept.amount); + const amountUsd = Number(amountAtomicUnits) / 10 ** USDC_DECIMALS; + + // Resolve expiry + const expirySeconds = + (input.expiryOverride ?? accept.maxTimeoutSeconds) || DEFAULT_PAYMENT_EXPIRY_SECONDS; + + // Validate facilitator address (required by upto scheme for witness binding) + const facilitatorAddress = accept.extra?.facilitatorAddress; + if (!facilitatorAddress) { + throw new ClientError( + 'upto scheme requires facilitatorAddress in paymentRequirements.extra. ' + + 'Ensure the server is configured with an upto facilitator.' + ); + } + + // EIP-3009 metadata (used for token contract identification in extra) + const tokenName = accept.extra?.name ?? 'USDC'; + const tokenVersion = accept.extra?.version ?? '2'; + + // Ensure Permit2 has sufficient ERC-20 allowance from the payer's wallet. + // Without this, the on-chain settle will revert. One-time setup per (wallet, token). + if (!input.skipPermit2Approval) { + await ensurePermit2Allowance({ + wallet, + tokenAddress: getAddress(accept.asset) as Hex, + requiredAmount: amountAtomicUnits, + networkConfig, + }); + } + + // Sign with Permit2 domain (NOT the token contract domain) + const account = privateKeyToAccount(wallet.privateKey as Hex); + const walletClient = createWalletClient({ + account, + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + // Permit2 expects a uint256 nonce as a *decimal* string β€” NOT a bytes32 hex string + // like EIP-3009 uses. Strict facilitators (CDP) reject the hex form with a misleading + // "schema requires authorization, transaction" error. + const nonce = randomPermit2Nonce(); + const now = Math.floor(Date.now() / 1000); + const validAfter = (now - VALID_AFTER_CLOCK_SKEW_SECONDS).toString(); + const deadline = (now + expirySeconds).toString(); + + if (BigInt(deadline) <= BigInt(validAfter)) { + throw new ClientError( + `Invalid time window: deadline (${deadline}) must be after validAfter (${validAfter}). ` + + `Check that maxTimeoutSeconds (${accept.maxTimeoutSeconds}) is positive.` + ); + } + + const chainId = networkConfig.chain.id; + + const signature = await walletClient.signTypedData({ + domain: { + name: 'Permit2', + chainId, + verifyingContract: PERMIT2_ADDRESS as Hex, + }, + types: UPTO_PERMIT2_WITNESS_TYPES, + primaryType: 'PermitWitnessTransferFrom', + message: { + permitted: { + token: getAddress(accept.asset), + amount: amountAtomicUnits, + }, + spender: getAddress(X402_UPTO_PERMIT2_PROXY), + nonce: BigInt(nonce), + deadline: BigInt(deadline), + witness: { + to: getAddress(accept.payTo), + facilitator: getAddress(facilitatorAddress), + validAfter: BigInt(validAfter), + }, + }, + }); + + // Build x402 payload + const paymentPayload = { + x402Version: X402_VERSION, + resource: resource ?? { + url: 'https://mcp.apify.com/mcp', + description: 'MCP Server', + mimeType: 'application/json', + }, + payload: { + signature, + permit2Authorization: { + permitted: { + token: getAddress(accept.asset), + amount: amountAtomicUnits.toString(), + }, + from: account.address, + spender: X402_UPTO_PERMIT2_PROXY, + nonce, + deadline, + witness: { + to: getAddress(accept.payTo), + facilitator: getAddress(facilitatorAddress), + validAfter, + }, + }, + }, + accepted: { + scheme: 'upto', + network: networkConfig.networkId, + asset: accept.asset, + amount: amountAtomicUnits.toString(), + payTo: accept.payTo, + maxTimeoutSeconds: expirySeconds, + // `facilitatorAddress` is guaranteed non-empty here β€” the early throw above rejects + // an upto accept without it. Spread-with-guard would be dead code. + extra: { name: tokenName, version: tokenVersion, facilitatorAddress }, + }, + }; + + const paymentSignatureBase64 = Buffer.from(JSON.stringify(paymentPayload)).toString('base64'); + + return { + paymentSignatureBase64, + from: account.address, + to: accept.payTo, + amountUsd, + amountAtomicUnits, + networkLabel: networkConfig.label, + expiresAt: new Date(Number(deadline) * 1000), + }; +} diff --git a/test/unit/cli/parser.test.ts b/test/unit/cli/parser.test.ts index 709f4ad..a2179df 100644 --- a/test/unit/cli/parser.test.ts +++ b/test/unit/cli/parser.test.ts @@ -11,6 +11,7 @@ import { optionTakesValue, hasSubcommand, suggestCommand, + preProcessX402Argv, } from '../../../src/cli/parser.js'; import { ClientError } from '../../../src/lib/errors.js'; @@ -570,3 +571,75 @@ describe('suggestCommand', () => { expect(suggestCommand('tools-lst', commands, 1)).toBe('tools-list'); }); }); + +describe('preProcessX402Argv', () => { + // Mirrors process.argv where indices 0 and 1 are node + script path. + const head = ['node', 'mcpc']; + + it('rewrites `--x402 ` so the URL stays a positional', () => { + expect(preProcessX402Argv([...head, 'connect', '--x402', 'mcp.apify.com', '@s'])).toEqual([ + ...head, + 'connect', + '--x402=auto', + 'mcp.apify.com', + '@s', + ]); + }); + + it('passes `--x402 ` through unchanged for each valid scheme', () => { + for (const scheme of ['auto', 'upto', 'exact']) { + expect(preProcessX402Argv([...head, 'connect', '--x402', scheme, 'mcp.apify.com'])).toEqual([ + ...head, + 'connect', + '--x402', + scheme, + 'mcp.apify.com', + ]); + } + }); + + it('rewrites bare `--x402` at the end of argv to `--x402=auto`', () => { + expect(preProcessX402Argv([...head, 'connect', 'mcp.apify.com', '--x402'])).toEqual([ + ...head, + 'connect', + 'mcp.apify.com', + '--x402=auto', + ]); + }); + + it('leaves `--x402=` (equals form) untouched', () => { + expect(preProcessX402Argv([...head, 'connect', '--x402=upto', 'mcp.apify.com'])).toEqual([ + ...head, + 'connect', + '--x402=upto', + 'mcp.apify.com', + ]); + }); + + it('rewrites `--x402 ` so the invalid token stays a positional', () => { + // The bogus token then surfaces as an extra positional, which Commander rejects loudly. + expect(preProcessX402Argv([...head, 'connect', '--x402', 'bogus', 'mcp.apify.com'])).toEqual([ + ...head, + 'connect', + '--x402=auto', + 'bogus', + 'mcp.apify.com', + ]); + }); + + it('does not touch the `x402` subcommand (no leading dashes)', () => { + expect(preProcessX402Argv([...head, 'x402', 'sign', '--scheme', 'upto'])).toEqual([ + ...head, + 'x402', + 'sign', + '--scheme', + 'upto', + ]); + }); + + it('handles multiple `--x402` occurrences independently', () => { + expect( + preProcessX402Argv([...head, '--x402', 'mcp.apify.com', 'other', '--x402', 'exact']) + ).toEqual([...head, '--x402=auto', 'mcp.apify.com', 'other', '--x402', 'exact']); + }); +}); diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts new file mode 100644 index 0000000..49f925e --- /dev/null +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -0,0 +1,226 @@ +/** + * Tests for the proactive-sign path and the tool-result retry path's scheme handling. + * + * Regression target: `--x402 exact` must not be silently overridden by the + * proactive `_meta.x402` path or by the tool-result retry helper. Both used to + * hard-code `auto` and pick whatever the server preferred (which now defaults to + * `upto` after apify-mcp-server #876). + */ + +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; + +import { + createX402FetchMiddleware, + extractAcceptFromPaymentRequired, + type X402PaymentCache, +} from '../../../../src/lib/x402/fetch-middleware.js'; +import type { PaymentRequiredAccept, SignerWallet } from '../../../../src/lib/x402/signer.js'; + +// --------------------------------------------------------------------------- +// Mocks β€” vi.mock is hoisted above local const declarations +// --------------------------------------------------------------------------- + +const { mockSignPayment } = vi.hoisted(() => ({ mockSignPayment: vi.fn() })); + +vi.mock('../../../../src/lib/x402/signer.js', async () => { + const actual = await vi.importActual( + '../../../../src/lib/x402/signer.js' + ); + return { + ...actual, + signPayment: (...args: unknown[]) => mockSignPayment(...args), + }; +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const WALLET: SignerWallet = { + privateKey: '0x1111111111111111111111111111111111111111111111111111111111111111', + address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', +}; + +const EXACT_ACCEPT: PaymentRequiredAccept = { + scheme: 'exact', + network: 'eip155:8453', + amount: '1000000', + asset: '0xExactAsset', + payTo: '0xPayee', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, +}; + +const UPTO_ACCEPT: PaymentRequiredAccept = { + scheme: 'upto', + network: 'eip155:8453', + amount: '1000000', + asset: '0xUptoAsset', + payTo: '0xPayee', + maxTimeoutSeconds: 18_000, + extra: { name: 'USDC', version: '2', facilitatorAddress: '0xFacilitator' }, +}; + +function makePaidTool(metaX402: Record): Tool { + return { + name: 'paid-tool', + description: 'Paid tool', + inputSchema: { type: 'object' }, + _meta: { x402: { paymentRequired: true, ...metaX402 } }, + } as unknown as Tool; +} + +function toolsCallBody(toolName: string): string { + return JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: toolName, arguments: {} }, + }); +} + +beforeEach(() => { + mockSignPayment.mockReset(); + mockSignPayment.mockResolvedValue({ + paymentSignatureBase64: 'mock-signature-base64', + from: WALLET.address, + to: '0xPayee', + amountUsd: 1, + amountAtomicUnits: 1_000_000n, + networkLabel: 'Base Mainnet', + expiresAt: new Date(), + }); +}); + +// --------------------------------------------------------------------------- +// proactive-sign path β€” getOrSignPayment via createX402FetchMiddleware +// --------------------------------------------------------------------------- + +describe('createX402FetchMiddleware proactive sign', () => { + 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 }; + const fetchFn = createX402FetchMiddleware( + vi.fn().mockResolvedValue(new Response('', { status: 200 })), + { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'exact', + } + ); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).toHaveBeenCalledTimes(1); + const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; + expect(accept.scheme).toBe('exact'); + expect(accept.asset).toBe('0xExactAsset'); + }); + + it('with schemePreference=upto and accepts=[exact, upto], signs upto', async () => { + const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...EXACT_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const fetchFn = createX402FetchMiddleware( + vi.fn().mockResolvedValue(new Response('', { status: 200 })), + { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'upto', + } + ); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; + expect(accept.scheme).toBe('upto'); + expect(accept.asset).toBe('0xUptoAsset'); + }); + + it('with schemePreference=exact and accepts=[upto] only, skips proactive sign', async () => { + const tool = makePaidTool({ accepts: [UPTO_ACCEPT], ...UPTO_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'exact', + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).not.toHaveBeenCalled(); + expect(baseFetch).toHaveBeenCalledTimes(1); + }); + + it('with schemePreference=exact and legacy flat-only _meta.x402 advertising upto, defers to 402 fallback', async () => { + // Pre-#876 server: flat fields only, no accepts[]. Server's preferred scheme is upto. + const tool = makePaidTool({ ...UPTO_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + schemePreference: 'exact', + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).not.toHaveBeenCalled(); + }); + + it('with schemePreference=auto and accepts=[exact, upto], prefers upto', async () => { + const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...UPTO_ACCEPT }); + const cache: X402PaymentCache = { signature: null }; + const fetchFn = createX402FetchMiddleware( + vi.fn().mockResolvedValue(new Response('', { status: 200 })), + { + wallet: WALLET, + getToolByName: () => tool, + paymentCache: cache, + // schemePreference unset β†’ defaults to auto + } + ); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + const accept = mockSignPayment.mock.calls[0]?.[0]?.accept as PaymentRequiredAccept; + expect(accept.scheme).toBe('upto'); + }); +}); + +// --------------------------------------------------------------------------- +// tool-result retry path β€” extractAcceptFromPaymentRequired +// --------------------------------------------------------------------------- + +describe('extractAcceptFromPaymentRequired', () => { + const paymentRequired = { + x402Version: 2, + accepts: [EXACT_ACCEPT, UPTO_ACCEPT], + resource: { url: 'mcp://tool/foo', description: 'foo' }, + }; + + it('defaults to auto (prefers upto) when schemePreference is omitted', () => { + const result = extractAcceptFromPaymentRequired(paymentRequired); + expect(result?.accept.scheme).toBe('upto'); + }); + + it('honors schemePreference=exact', () => { + const result = extractAcceptFromPaymentRequired(paymentRequired, 'exact'); + expect(result?.accept.scheme).toBe('exact'); + }); + + it('honors schemePreference=upto', () => { + const result = extractAcceptFromPaymentRequired(paymentRequired, 'upto'); + expect(result?.accept.scheme).toBe('upto'); + }); + + it('returns undefined when schemePreference=exact and only upto is available', () => { + const uptoOnly = { x402Version: 2, accepts: [UPTO_ACCEPT] }; + const result = extractAcceptFromPaymentRequired(uptoOnly, 'exact'); + expect(result).toBeUndefined(); + }); +}); diff --git a/test/unit/lib/x402/signer.test.ts b/test/unit/lib/x402/signer.test.ts new file mode 100644 index 0000000..569e5bf --- /dev/null +++ b/test/unit/lib/x402/signer.test.ts @@ -0,0 +1,407 @@ +/** + * Unit tests for x402 signer (exact + upto schemes) + */ + +import { ClientError } from '../../../../src/lib/errors.js'; +import { + parsePaymentRequired, + selectAcceptEntry, + signPayment, + X402_VERSION, + type PaymentRequiredAccept, + type SignerWallet, +} from '../../../../src/lib/x402/signer.js'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const MOCK_SIGNATURE = '0xdeadbeef1234567890'; +const MOCK_ADDRESS = '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B'; +const MOCK_APPROVE_TX_HASH = '0xabcdef1234567890'; + +// `vi.mock` is hoisted above local `const` declarations, so the mock fns used +// inside the factory must come from `vi.hoisted`. Same pattern as grep.test.ts. +const { mockReadContract, mockSendTransaction, mockWaitForTransactionReceipt, mockSignTypedData } = + vi.hoisted(() => ({ + mockReadContract: vi.fn(), + mockSendTransaction: vi.fn(), + mockWaitForTransactionReceipt: vi.fn(), + mockSignTypedData: vi.fn(), + })); + +vi.mock('viem', () => ({ + createPublicClient: vi.fn().mockReturnValue({ + readContract: (...args: unknown[]) => mockReadContract(...args), + waitForTransactionReceipt: (...args: unknown[]) => mockWaitForTransactionReceipt(...args), + }), + createWalletClient: vi.fn().mockReturnValue({ + signTypedData: (...args: unknown[]) => mockSignTypedData(...args), + sendTransaction: (...args: unknown[]) => mockSendTransaction(...args), + }), + encodeFunctionData: vi.fn().mockReturnValue('0xencodedapprovedata'), + getAddress: vi.fn((addr: string) => addr.toLowerCase()), + http: vi.fn().mockReturnValue('http-transport'), +})); + +vi.mock('viem/accounts', () => ({ + privateKeyToAccount: vi.fn().mockReturnValue({ + address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + }), +})); + +// Default mock behaviour β€” allowance is sufficient, no approve needed, signing succeeds. +beforeEach(() => { + mockReadContract.mockReset(); + mockSendTransaction.mockReset(); + mockWaitForTransactionReceipt.mockReset(); + mockSignTypedData.mockReset(); + mockReadContract.mockResolvedValue(BigInt('1000000000000000000000000')); // huge allowance + mockSendTransaction.mockResolvedValue(MOCK_APPROVE_TX_HASH); + mockWaitForTransactionReceipt.mockResolvedValue({ status: 'success', blockNumber: 1n }); + mockSignTypedData.mockResolvedValue(MOCK_SIGNATURE); +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_EXACT_ACCEPT: PaymentRequiredAccept = { + scheme: 'exact', + network: 'eip155:84532', + amount: '1000000', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + payTo: '0xdf278412ecbe00d6381408f739eb8da60542a0c4', + maxTimeoutSeconds: 60, + extra: { name: 'USDC', version: '2' }, +}; + +const VALID_UPTO_ACCEPT: PaymentRequiredAccept = { + scheme: 'upto', + network: 'eip155:84532', + amount: '5000000', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + payTo: '0xdf278412ecbe00d6381408f739eb8da60542a0c4', + maxTimeoutSeconds: 3600, + extra: { + name: 'USDC', + version: '2', + facilitatorAddress: '0x4020a4f3b7b90cca423b9fabcc0ce57c6c240002', + }, +}; + +const MOCK_WALLET: SignerWallet = { + privateKey: '0x1234567890abcdef', + address: MOCK_ADDRESS, +}; + +function buildPaymentRequired(accepts: PaymentRequiredAccept[]): string { + const header = { + x402Version: 2, + resource: { + url: 'https://mcp.apify.com/mcp', + description: 'MCP Server', + mimeType: 'application/json', + }, + accepts, + }; + return Buffer.from(JSON.stringify(header)).toString('base64'); +} + +// --------------------------------------------------------------------------- +// selectAcceptEntry +// --------------------------------------------------------------------------- + +describe('selectAcceptEntry', () => { + it('auto: prefers upto over exact', () => { + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT], 'auto'); + expect(result?.scheme).toBe('upto'); + }); + + it('auto: falls back to exact when upto is invalid', () => { + const invalidUpto = { ...VALID_UPTO_ACCEPT, extra: { name: 'USDC' } }; // missing facilitatorAddress + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, invalidUpto], 'auto'); + expect(result?.scheme).toBe('exact'); + }); + + it('auto: returns undefined when nothing valid', () => { + const result = selectAcceptEntry([], 'auto'); + expect(result).toBeUndefined(); + }); + + it('upto: returns upto when valid', () => { + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT], 'upto'); + expect(result?.scheme).toBe('upto'); + }); + + it('upto: returns undefined when upto invalid', () => { + const invalidUpto = { ...VALID_UPTO_ACCEPT, extra: {} }; + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, invalidUpto], 'upto'); + expect(result).toBeUndefined(); + }); + + it('exact: returns exact when valid', () => { + const result = selectAcceptEntry([VALID_UPTO_ACCEPT, VALID_EXACT_ACCEPT], 'exact'); + expect(result?.scheme).toBe('exact'); + }); + + it('exact: returns undefined when exact invalid', () => { + const invalidExact = { ...VALID_EXACT_ACCEPT, payTo: '' }; + const result = selectAcceptEntry([invalidExact, VALID_UPTO_ACCEPT], 'exact'); + expect(result).toBeUndefined(); + }); + + it('auto: prefers first valid upto', () => { + const secondUpto = { ...VALID_UPTO_ACCEPT, amount: '9999999' }; + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT, secondUpto], 'auto'); + expect(result?.amount).toBe('5000000'); + }); + + it('auto: prefers first valid exact when no upto', () => { + const secondExact = { ...VALID_EXACT_ACCEPT, amount: '9999999' }; + const result = selectAcceptEntry([VALID_EXACT_ACCEPT, secondExact], 'auto'); + expect(result?.amount).toBe('1000000'); + }); +}); + +// --------------------------------------------------------------------------- +// parsePaymentRequired +// --------------------------------------------------------------------------- + +describe('parsePaymentRequired', () => { + it('parses exact-only header with auto', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT]); + const { header, accept } = parsePaymentRequired(b64, 'auto'); + expect(header.x402Version).toBe(2); + expect(accept.scheme).toBe('exact'); + }); + + it('parses upto-only header with auto', () => { + const b64 = buildPaymentRequired([VALID_UPTO_ACCEPT]); + const { header, accept } = parsePaymentRequired(b64, 'auto'); + expect(accept.scheme).toBe('upto'); + }); + + it('mixed header: auto selects upto', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT]); + const { accept } = parsePaymentRequired(b64, 'auto'); + expect(accept.scheme).toBe('upto'); + }); + + it('mixed header: force exact', () => { + const b64 = buildPaymentRequired([VALID_UPTO_ACCEPT, VALID_EXACT_ACCEPT]); + const { accept } = parsePaymentRequired(b64, 'exact'); + expect(accept.scheme).toBe('exact'); + }); + + it('mixed header: force upto', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT, VALID_UPTO_ACCEPT]); + const { accept } = parsePaymentRequired(b64, 'upto'); + expect(accept.scheme).toBe('upto'); + }); + + it('throws on invalid JSON after base64 decode', () => { + const b64 = Buffer.from('not-json').toString('base64'); + expect(() => parsePaymentRequired(b64)).toThrow(ClientError); + expect(() => parsePaymentRequired(b64)).toThrow('not valid JSON'); + }); + + it('throws on empty accepts', () => { + const b64 = buildPaymentRequired([]); + expect(() => parsePaymentRequired(b64)).toThrow(ClientError); + expect(() => parsePaymentRequired(b64)).toThrow('no "accepts" entries'); + }); + + it('throws when no matching scheme (auto)', () => { + const b64 = buildPaymentRequired([{ ...VALID_EXACT_ACCEPT, scheme: 'unknown' }]); + expect(() => parsePaymentRequired(b64, 'auto')).toThrow(ClientError); + expect(() => parsePaymentRequired(b64, 'auto')).toThrow('exact or upto'); + }); + + it('throws when forced scheme unavailable', () => { + const b64 = buildPaymentRequired([VALID_EXACT_ACCEPT]); + expect(() => parsePaymentRequired(b64, 'upto')).toThrow(ClientError); + expect(() => parsePaymentRequired(b64, 'upto')).toThrow('upto'); + }); +}); + +// --------------------------------------------------------------------------- +// signPayment +// --------------------------------------------------------------------------- + +describe('signPayment', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exact scheme: produces correct payload shape', async () => { + const result = await signPayment({ wallet: MOCK_WALLET, accept: VALID_EXACT_ACCEPT }); + + expect(result.from).toBe(MOCK_ADDRESS); + expect(result.to).toBe(VALID_EXACT_ACCEPT.payTo); + expect(result.networkLabel).toBe('Base Sepolia (testnet)'); + + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.x402Version).toBe(X402_VERSION); + expect(payload.accepted.scheme).toBe('exact'); + expect(payload.payload.authorization).toBeDefined(); + expect(payload.payload.authorization.from).toBe(MOCK_ADDRESS); + expect(payload.payload.authorization.to).toBe(VALID_EXACT_ACCEPT.payTo); + expect(payload.payload.authorization.value).toBe(VALID_EXACT_ACCEPT.amount); + expect(payload.payload.permit2Authorization).toBeUndefined(); + }); + + it('upto scheme: produces correct payload shape with permit2Authorization', async () => { + const result = await signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT }); + + expect(result.from).toBe(MOCK_ADDRESS); + expect(result.to).toBe(VALID_UPTO_ACCEPT.payTo); + expect(result.networkLabel).toBe('Base Sepolia (testnet)'); + + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.x402Version).toBe(X402_VERSION); + expect(payload.accepted.scheme).toBe('upto'); + expect(payload.payload.permit2Authorization).toBeDefined(); + expect(payload.payload.authorization).toBeUndefined(); + + const permit2 = payload.payload.permit2Authorization; + expect(permit2.from).toBe(MOCK_ADDRESS); + expect(permit2.spender).toBe('0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002'); + expect(permit2.permitted.token).toBe(VALID_UPTO_ACCEPT.asset.toLowerCase()); + expect(permit2.permitted.amount).toBe(VALID_UPTO_ACCEPT.amount); + expect(permit2.witness.to).toBe(VALID_UPTO_ACCEPT.payTo.toLowerCase()); + expect(permit2.witness.facilitator).toBe( + VALID_UPTO_ACCEPT.extra!.facilitatorAddress!.toLowerCase() + ); + + // accepted should include facilitatorAddress + expect(payload.accepted.extra.facilitatorAddress).toBe( + VALID_UPTO_ACCEPT.extra!.facilitatorAddress + ); + }); + + it('upto scheme: throws when facilitatorAddress missing', async () => { + const invalidUpto = { ...VALID_UPTO_ACCEPT, extra: { name: 'USDC', version: '2' } }; + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalidUpto })).rejects.toThrow( + ClientError + ); + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalidUpto })).rejects.toThrow( + 'facilitatorAddress' + ); + }); + + it('exact scheme: uses amountOverride', async () => { + const result = await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_EXACT_ACCEPT, + amountOverride: 2000000n, + }); + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.accepted.amount).toBe('2000000'); + expect(payload.payload.authorization.value).toBe('2000000'); + expect(result.amountUsd).toBe(2); + }); + + it('upto scheme: uses amountOverride as max cap', async () => { + const result = await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_UPTO_ACCEPT, + amountOverride: 3000000n, + }); + const payload = JSON.parse( + Buffer.from(result.paymentSignatureBase64, 'base64').toString('utf-8') + ); + expect(payload.accepted.amount).toBe('3000000'); + expect(payload.payload.permit2Authorization.permitted.amount).toBe('3000000'); + expect(result.amountUsd).toBe(3); + }); + + it('unsupported scheme: throws', async () => { + const invalid = { ...VALID_EXACT_ACCEPT, scheme: 'unknown' }; + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalid })).rejects.toThrow( + 'Unsupported x402 scheme: unknown' + ); + }); + + it('unknown network: throws', async () => { + const invalid = { ...VALID_EXACT_ACCEPT, network: 'eip155:99999' }; + await expect(signPayment({ wallet: MOCK_WALLET, accept: invalid })).rejects.toThrow( + 'Unknown network' + ); + }); + + // ------------------------------------------------------------------------- + // upto scheme β€” Permit2 allowance auto-approval + // ------------------------------------------------------------------------- + + describe('upto: Permit2 allowance auto-approval', () => { + it('skips approval when existing allowance >= required amount', async () => { + // VALID_UPTO_ACCEPT.amount is "5000000" β€” return exactly that. + mockReadContract.mockResolvedValueOnce(BigInt('5000000')); + + await signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT }); + + expect(mockReadContract).toHaveBeenCalledTimes(1); + expect(mockSendTransaction).not.toHaveBeenCalled(); + expect(mockWaitForTransactionReceipt).not.toHaveBeenCalled(); + }); + + it('sends approve(MAX_UINT256) when allowance is short, then signs', async () => { + // First read: insufficient. Second read after approve: max. + mockReadContract + .mockResolvedValueOnce(0n) + .mockResolvedValueOnce(BigInt('1000000000000000000000000')); + + const result = await signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + const sendArgs = mockSendTransaction.mock.calls[0]?.[0] as { + to: string; + data: string; + }; + expect(sendArgs.to).toBe(VALID_UPTO_ACCEPT.asset.toLowerCase()); + expect(sendArgs.data).toBe('0xencodedapprovedata'); + expect(mockWaitForTransactionReceipt).toHaveBeenCalledWith({ hash: MOCK_APPROVE_TX_HASH }); + expect(mockReadContract).toHaveBeenCalledTimes(2); // before + after + expect(result.paymentSignatureBase64).toBeDefined(); + }); + + it('throws when approve transaction reverts on-chain', async () => { + mockReadContract.mockResolvedValueOnce(0n); + mockWaitForTransactionReceipt.mockResolvedValueOnce({ + status: 'reverted', + blockNumber: 1n, + }); + + await expect(signPayment({ wallet: MOCK_WALLET, accept: VALID_UPTO_ACCEPT })).rejects.toThrow( + 'reverted on-chain' + ); + }); + + it('skipPermit2Approval bypasses the allowance check entirely', async () => { + mockReadContract.mockResolvedValueOnce(0n); // would normally trigger approve + + await signPayment({ + wallet: MOCK_WALLET, + accept: VALID_UPTO_ACCEPT, + skipPermit2Approval: true, + }); + + expect(mockReadContract).not.toHaveBeenCalled(); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + + it('exact scheme does not perform allowance check', async () => { + await signPayment({ wallet: MOCK_WALLET, accept: VALID_EXACT_ACCEPT }); + + expect(mockReadContract).not.toHaveBeenCalled(); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + }); +});