diff --git a/CHANGELOG.md b/CHANGELOG.md index 849124ca..5002f56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,20 @@ 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`. Use `--x402=` when the flag is followed by positional arguments to avoid Commander's greedy parsing of the next token. +- 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 ### 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 843eb400..1a9a53b5 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,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 | @@ -485,13 +485,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. @@ -681,13 +681,20 @@ 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. +The x402 protocol defines different payment **schemes**: + +- **`exact`** (Standard EIP-3009): The client signs an exact `TransferWithAuthorization` on USDC. Settles on-chain immediately at call-time. +- **`upto`** (Permit2): The client signs a maximum authorization cap using Uniswap's `Permit2` witness signatures. The facilitator verifies the signature off-chain immediately, and settles the actual accumulated usage later (asynchronously). + +Regardless of the scheme, the general flow is: + +1. **Server returns HTTP 402** with a `PAYMENT-REQUIRED` header advertising its supported schemes and details. +2. `mcpc` parses the header, picks the best scheme, and signs the payment payload using your local wallet. + - For `upto`, `mcpc` automatically checks and grants the one-time on-chain Permit2 allowance if needed (requires a small native ETH float for gas). +3. `mcpc` retries the request with a `PAYMENT-SIGNATURE` header containing the signed payload. 4. The server verifies the signature and fulfills the request. -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. +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. This path is fully scheme-aware and respects your configured session preference. ### Wallet setup @@ -731,31 +738,41 @@ 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 β€” value goes after the flag +mcpc connect mcp.apify.com @apify --x402 exact + +# When --x402 precedes positional args, use the equals form to avoid Commander's +# greedy [optional] argument parser eating the URL/session as the value. +mcpc connect --x402=upto mcp.apify.com @apify + +# 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/X402_UPTO_INVESTIGATION.md b/X402_UPTO_INVESTIGATION.md new file mode 100644 index 00000000..43bc3961 --- /dev/null +++ b/X402_UPTO_INVESTIGATION.md @@ -0,0 +1,277 @@ +# x402 `upto` scheme β€” investigation & findings + +Context: while testing **apify-core PR [#27039](https://github.com/apify/apify-core/pull/27039)** ("feat(agentic-payments): add x402 upto scheme support") end-to-end against the local API, we hit a series of failures with the upto scheme that turned out to be partly client-side (mcpc gaps), partly local cache state, and primarily an **upstream CDP facilitator gap**. + +This document captures the full debugging journey, the on-chain proof that the apify-core code is spec-compliant, and the changes made to mcpc to make upto signing self-healing. + +--- + +## TL;DR + +- βœ… `exact` scheme works **end-to-end** on the local API (verify, settle, run, charge, refund β€” all confirmed on-chain on Base Sepolia). +- βœ… apify-core PR #27039's upto signing/wiring is spec-correct β€” proven by the official x402 reference facilitator at `x402.org`, which **accepts our payloads, verifies them, and settles them on-chain**. +- ❌ **CDP's `/verify` endpoint has a stale schema** β€” rejects upto-style `permit2Authorization` payloads with `'paymentPayload' is invalid: must match one of [x402V2PaymentPayload, x402V1PaymentPayload]. schema requires 'authorization', 'transaction'`. This is an upstream Coinbase issue, not anything in apify-core or mcpc. +- πŸ› Found a real bug in apify-core PR #27039: `X402Client.getAllPaymentRequirements` calls `log.error` unconditionally even when the configured kind is supported by the facilitator. Spammy logs. +- ✨ Added Permit2 allowance auto-approval to mcpc's `signUptoPayment` β€” when the wallet hasn't approved Permit2 yet, mcpc submits the one-time `approve(MAX_UINT256)` transaction before signing. + +--- + +## On-chain evidence (Base Sepolia) + +| Tx | Purpose | Hash | +|---|---|---| +| Initial settlement (exact, $1) | Apify wallet pulls $1 from payer at auth time | [`0x7a1ad9bcd5e412...`](https://sepolia.basescan.org/tx/0x7a1ad9bcd5e412f7793da006db9d5b990b857163e4bb2af98adbb80f7866e535) | +| Initial settlement (exact, $1) | Failed run (auth ok, run rejected) | [`0x3e441527...`](https://sepolia.basescan.org/tx/0x3e441527) | +| Initial settlement (exact, $1) | Successful run (`apify/python-example`) | [`0x712dc7154...`](https://sepolia.basescan.org/tx/0x712dc7154baca4b81f06fdfbcafca0704e5b784c832cd1b83858a3a73f857cf8) | +| Refund (exact, ~$1) Γ— 3 | Daemon refund of unused balance | `0x965c576d...`, `0xc7c745f9...`, `0x6e7f31b5...` | +| Refund (exact, ~$0.50) | Daemon refund after $0.50 retroactive charge | [`0x968e954e5c8f...`](https://sepolia.basescan.org/tx/0x968e954e5c8fc7eca93cc7e0e8a3776d0c9b9b88b2a098f323a7e9c1ca240007) | +| **Permit2 approve (one-time)** | mcpc's auto-approve β€” `USDC.approve(PERMIT2, MAX_UINT256)` | (issued from the test wallet) | +| **upto settle via x402.org** | x402 reference facilitator pulls $1 via `x402UptoPermit2Proxy.settle()` | [**`0xdc8449a2f72db1...`**](https://sepolia.basescan.org/tx/0xdc8449a2f72db1abce2e2d4a06e7f4e0ecfd097e98382e5b67764edb741aee01) | + +The last one is the smoking gun: it proves the apify-core PR #27039 produces an upto Permit2 witness that the official x402 facilitator accepts and settles on-chain. + +--- + +## Test environment + +- **API**: local `npm run api` from `apify/apify-core` (PR branch `feat/x402-upto-scheme`, head `deb03d8ec`) +- **Daemon**: local `npm run daemons:infinite` (settling daemon picks up finalizable payments every 2s) +- **MongoDB**: local `apify-dev` database +- **mcpc**: local build of `apify/mcp-cli` with the upto signer changes documented below +- **Network**: Base Sepolia (`eip155:84532`) +- **Test wallet**: `0xb5273f0F04Ab2e42b039F826EbA1D8E8Ab25A0A6` +- **Apify wallet**: `0xDF278412eCbE00D6381408F739eB8dA60542a0c4` +- **Test asset**: USDC (`0x036CbD53842c5426634e7929541eC2318f3dCF7e`) +- **Test actor**: `apify/python-example` (had to mongo-patch its `actorPermissionLevel` from `FULL_PERMISSIONS` β†’ `LIMITED_PERMISSIONS` and add a PAY_PER_EVENT `pricingInfos` entry to satisfy the agentic-payments preconditions) + +--- + +## Path 1 β€” `exact` scheme (works end-to-end) + +1. `POST /v2/acts/apify~python-example/run-sync` with no auth β†’ API returns `402 Payment Required` with both `upto` and `exact` accepts. +2. `mcpc x402 sign --scheme exact ` β†’ produces a valid EIP-3009 `TransferWithAuthorization` signature. +3. POST again with `PAYMENT-SIGNATURE` header β†’ API authenticates the payment, settles $1 on-chain (real CDP facilitator), creates the agentic payment record, runs the actor. +4. Run produces a result; we then retroactively bumped `chargedEventCounts.test-event += 50` and `agenticPayments.remainingBalanceUsd -= 0.5` to simulate $0.50 of usage. +5. We forced `expiresAt` to a past date on all unfinalized payments β†’ settling daemon picks them up β†’ `finalizeExactPayment` issues on-chain refunds for the unused balance (minus gas) β†’ all 4 payments finalized within 10s, all refunds confirmed on-chain. + +Conclusion: the entire `exact` flow works on Base Sepolia against the live CDP facilitator. + +--- + +## Path 2 β€” `upto` scheme (blocked by CDP) + +### Round 1: API β†’ CDP `/verify` returns 401 unauthorized + +Sign with `mcpc x402 sign --scheme upto …` β†’ POST to API β†’ API responds: + +```json +{"error":{"type":"x402-agentic-payment-unauthorized","message":"The provided payment payload is invalid or could not be verified by the facilitator."}} +``` + +The X402Client just throws on `isValid: false` and discards the underlying `invalidReason` β€” so the error is opaque from the API logs alone. We needed to talk to the facilitator directly to see the real reason. + +### Direct verify against CDP + +We wrote a helper script (`/tmp/verify-upto.sh` + `/tmp/verify-upto.mjs`) that mirrors the CDPFacilitator JWT auth and posts our signed payload + paymentRequirements to `https://api.cdp.coinbase.com/platform/v2/x402/verify`. + +The actual CDP error: + +```json +{ + "errorType": "invalid_request", + "errorMessage": "'paymentPayload' is invalid: must match one of [x402V2PaymentPayload, x402V1PaymentPayload]. schema requires 'authorization', 'transaction'", + "correlationId": "9f36ab0ecc71e68c-IAD" +} +``` + +CDP's schema validator only accepts `payload.authorization` (EIP-3009 / exact) or `payload.transaction` (likely Solana). It does NOT recognize `payload.permit2Authorization` (upto) β€” even though `/supported` advertises upto kinds for `eip155:84532`, `eip155:8453`, `eip155:137`, `eip155:42161`, etc. + +### Confirmation against the official x402 reference facilitator + +We POSTed the same payload (witness rebound to x402.org's facilitator address) to `https://www.x402.org/facilitator/verify`: + +```json +{"isValid": true, "payer": "0xb5273f0F04Ab2e42b039F826EbA1D8E8Ab25A0A6"} +``` + +…and then the actual `/settle` call: + +```json +{ + "success": true, + "transaction": "0xdc8449a2f72db1abce2e2d4a06e7f4e0ecfd097e98382e5b67764edb741aee01", + "network": "eip155:84532", + "payer": "0xb5273f0F04Ab2e42b039F826EbA1D8E8Ab25A0A6", + "amount": "1000000" +} +``` + +β†’ Real USDC moved on-chain via `x402UptoPermit2Proxy.settle()`. Our payload structure is spec-correct; CDP just hasn't updated their `/verify` schema validator to accept the upto variant yet. + +The official x402 v2 schema in [`typescript/packages/core/src/schemas/index.ts`](https://github.com/coinbase/x402/blob/main/typescript/packages/core/src/schemas/index.ts) confirms it: + +```ts +export const PaymentPayloadV2Schema = z.object({ + x402Version: z.literal(2), + resource: ResourceInfoSchema.optional(), + accepted: PaymentRequirementsV2Schema, + payload: Any, // ← Any! Spec accepts permit2Authorization here. + extensions: OptionalAny, +}); +``` + +CDP is enforcing a tighter, older schema that pre-dates the Permit2 upto branch. + +### Side effect β€” CDP returns rotating facilitator addresses + +CDP load-balances `/supported` responses across multiple signers. For `eip155:84532` upto we observed three different `extra.facilitatorAddress` values on three calls (`0x97AcCe…`, `0xa32cCda…`, `0x67B9CE…`). All five eip155 signers from CDP's `signers["eip155:*"]` array eventually rotate through. + +This isn't broken behavior β€” each signed payload binds to whichever address the API received from `/supported` at the moment of signing, and CDP can route the verify/settle to the matching signer. But it did initially make us suspect the API's memoize cache was stale. Worth being aware of for anyone debugging similar issues. + +--- + +## Bug found in apify-core PR #27039: unconditional `log.error` + +In `src/packages/agentic-payments/src/x402/x402_client.ts` (added in commit `38b3e028`): + +```ts +async getAllPaymentRequirements(): Promise { + const supportedKinds = await this.memoizedGetSupportedKinds(); + const paymentRequirements: X402PaymentRequirements[] = []; + + try { + await Promise.all(this.accepts.map(async (acceptedKind) => { + const supportedKind = supportedKinds.find(...); + if (supportedKind) { + paymentRequirements.push(this.buildRequirements({ ... })); + } + + log.error('Configured accept is not supported by the facilitator; skipping.', { ... }); + // ↑ Always runs β€” should be inside an else or guarded + })); + } + ... +} +``` + +The docstring says *"Configured payment kinds the facilitator can't handle are logged and skipped."* β€” which is the **intent**. The implementation just doesn't match: it logs unconditionally. + +**Real-world proof**: every 402 the local API generated logged this error twice (once per scheme), even though the 402 response correctly listed both schemes in `accepts[]`. + +**Severity**: +- 🟑 Functional impact: none (paymentRequirements still populated correctly) +- πŸ”΄ Operational impact: every 402 generates 1 false error log per configured accept; pollutes Sentry/error dashboards +- πŸ”΄ Misleading future devs investigating "facilitator not supported" issues + +**Fix**: +```ts +if (supportedKind) { + paymentRequirements.push(this.buildRequirements({ ... })); + return; +} +log.warning('Configured accept is not supported by the facilitator; skipping.', { ... }); +``` + +Also recommend changing `log.error` β†’ `log.warning` since skipping a single kind while others remain valid isn't an error β€” the request is still served. The "no kinds usable at all" case already has its own `log.error` at the bottom of the function. + +--- + +## mcpc changes (added during this investigation) + +Branch state: `feat/x402-upto-scheme` (local-only, not pushed). + +### `src/lib/x402/signer.ts` + +1. **Refactored** the original single `signPayment` into: + - `signExactPayment` β€” existing EIP-3009 logic + - `signUptoPayment` β€” new Permit2 `permitWitnessTransferFrom` logic + - `signPayment` β€” delegates by `accept.scheme` + +2. **Added** `selectAcceptEntry(accepts, preference)` β€” picks a valid accept entry from a 402 response based on preference (`auto` prefers upto then exact, `upto`/`exact` force one). + +3. **Added** Permit2 allowance auto-approval in `signUptoPayment`: + - Reads `USDC.allowance(wallet, PERMIT2)` via JSON-RPC + - If allowance < required amount, sends a one-time `USDC.approve(PERMIT2, MAX_UINT256)` transaction and waits for receipt + - Skipped via `SignPaymentInput.skipPermit2Approval = true` + - Network is automatically derived from `accept.network` β€” works on Base Mainnet (`eip155:8453`) and Base Sepolia (`eip155:84532`) without user intervention + +### `src/cli/commands/x402.ts` + +- Added `--scheme ` flag on `mcpc x402 sign` (default: `auto`) +- Added `--no-approve` flag to skip Permit2 allowance auto-approval (advanced/testing) +- Help text updated + +### `test/unit/lib/x402/signer.test.ts` + +- 30 unit tests, all passing: + - 10 for `selectAcceptEntry` and `parsePaymentRequired` scheme preference + - 15 for `signPayment` exact + upto payload shape, amount/expiry overrides, error cases + - 5 for the new Permit2 allowance auto-approval flow (skip when sufficient, approve when short, throw on revert, skip via `skipPermit2Approval`, exact scheme doesn't probe allowance) + +### Known gap (separate, easy fix) + +`mcpc x402 info` is hard-coded to Base Mainnet RPC, so it always shows mainnet balances regardless of which network you're testing on. We hit this when the wallet's mainnet ETH balance was reported but Sepolia was empty, causing the first auto-approve to fail with `gas required exceeds allowance (0)`. Funded the wallet via [Coinbase Sepolia faucet](https://portal.cdp.coinbase.com/products/faucet); next sign produced a successful approve tx and went on to settle. + +--- + +## Settings preconditions (for any future tester) + +To get an actor through the full agentic-payment pipeline locally, the actor needs: +1. `actorPermissionLevel: 'LIMITED_PERMISSIONS'` (not `FULL_PERMISSIONS`) +2. A `pricingInfos` array with at least one `pricingModel: 'PAY_PER_EVENT'` entry where `isPPEPlatformUsagePaidByUser` is not `true` + +Quick mongo patch: +```js +db.acts2.updateOne({_id: 'fkhQSv5AmEAgVtMjy'}, { + $set: { + actorPermissionLevel: 'LIMITED_PERMISSIONS', + pricingInfos: [{ + createdAt: new Date(), + startedAt: new Date('2025-01-01'), + apifyMarginPercentage: 0.2, + pricingModel: 'PAY_PER_EVENT', + pricingPerEvent: { + actorChargeEvents: { + 'test-event': { + eventTitle: 'Test event', + eventDescription: 'Test event for agentic payments', + eventPriceUsd: 0.01, + isPrimaryEvent: true, + }, + }, + }, + minimalMaxTotalChargeUsd: 1, + }], + }, +}); +``` + +The agentic-payments check is enforced in `src/packages/agentic-payments/src/utils.ts` β€” `ensureActorIsAllowedForAgenticPayments`. + +--- + +## Recommendations for PR #27039 + +In rough order of priority: + +1. **Fix the `log.error` bug** (small, separate commit). It's spamming logs per 402 response. +2. **Decide on the upto-with-CDP situation** β€” three options: + - **Ship as-is**, document the CDP limitation, monitor for upstream fix. The upto code is correct, it just won't authenticate end-to-end against CDP today. + - **Disable upto in prod settings** (`settings.prod.json` / `settings.staging.json`) until CDP fixes the schema. Keep upto in dev/test settings so testing continues. + - **Switch facilitator from CDP β†’ x402.org** (or run a self-hosted x402 facilitator). This unblocks upto end-to-end today, but adds operational surface area. +3. **Report the CDP issue** to Coinbase. Reproducer is in this doc; correlation IDs from our verify attempts are stored above. +4. **Earlier review notes** still relevant before merge: + - Cap `usageInAssetUnits` in `finalizeUptoPayment` against the original Permit2 authorized amount, to prevent on-chain revert from rounding drift. + - Add retry budget / terminal-state handling for upto settlement failures, otherwise the daemon retries forever and grows the `transactions[]` array unboundedly. + - Verify the `X402_WALLET_PRIVATE_KEY` in each environment derives to the previously-hardcoded `apifyWalletAddress` β€” that explicit address check is gone in the PR. + +--- + +## Direct-verify helper script (kept under `/tmp` β€” not in the repo) + +For future debugging of CDP rejections: + +- `/tmp/verify-upto.sh` β€” bash wrapper that prompts for the four inputs (PAYMENT-REQUIRED, PAYMENT-SIGNATURE, CDP key id, CDP key secret) using `read -s` +- `/tmp/verify-upto.mjs` β€” Node ESM script that mirrors `CDPFacilitator`'s JWT auth (`jose` Ed25519) and posts to `/verify` and `/supported`, printing the full response (including `invalidReason`) + +The Node script imports `jose` by absolute path from apify-core's `node_modules` so it runs without installing anything in `/tmp`. diff --git a/docs/examples/sign-x402.ts b/docs/examples/sign-x402.ts new file mode 100644 index 00000000..5e4b7c67 --- /dev/null +++ b/docs/examples/sign-x402.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env -S npx tsx +/** + * Minimal x402 payment signer β€” drop-in alternative to `mcpc x402 sign`. + * + * Signs the EIP-3009 `exact` scheme on Base / Base Sepolia and prints the + * base64 PAYMENT-SIGNATURE header value. Does NOT handle the `upto` scheme + * (Permit2 approval flow) β€” use `mcpc x402 sign` or the official x402 SDK + * for that. + * + * Usage: + * npm install viem tsx + * PRIVATE_KEY=0x... npx tsx sign-x402.ts + */ + +import { privateKeyToAccount } from 'viem/accounts'; +import { createWalletClient, http, type Hex } from 'viem'; +import { base, baseSepolia, type Chain } from 'viem/chains'; + +interface Accept { + scheme: string; + network: string; + amount: string; + asset: Hex; + payTo: Hex; + maxTimeoutSeconds?: number; + extra?: { name?: string; version?: string }; +} + +interface PaymentRequired { + resource?: { url?: string; mimeType?: string; description?: string }; + accepts: Accept[]; +} + +const NETWORKS: Record = { + [`eip155:${base.id}`]: { chain: base, rpc: 'https://mainnet.base.org' }, + [`eip155:${baseSepolia.id}`]: { chain: baseSepolia, rpc: 'https://sepolia.base.org' }, +}; + +const TRANSFER_WITH_AUTHORIZATION = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +} as const; + +const [, , paymentRequiredB64] = process.argv; +const privateKey = process.env.PRIVATE_KEY as Hex | undefined; +if (!privateKey || !paymentRequiredB64) { + console.error('Usage: PRIVATE_KEY=0x... npx tsx sign-x402.ts '); + process.exit(1); +} + +// 1. Decode PAYMENT-REQUIRED and pick the first `exact`-scheme accept entry. +const header: PaymentRequired = JSON.parse( + Buffer.from(paymentRequiredB64, 'base64').toString('utf8') +); +const accept = header.accepts?.find((a) => a.scheme === 'exact'); +if (!accept) throw new Error('No `exact` scheme entry in PAYMENT-REQUIRED.accepts'); + +const network = NETWORKS[accept.network]; +if (!network) throw new Error(`Unsupported network: ${accept.network}`); + +// 2. Build the authorization. `value` is already in atomic units (USDC has 6 decimals). +// `validAfter: 0` means "valid immediately"; `validBefore` is now + timeout. +const account = privateKeyToAccount(privateKey); +const value = BigInt(accept.amount); +const validBefore = BigInt(Math.floor(Date.now() / 1000) + (accept.maxTimeoutSeconds ?? 3600)); +const nonce = ('0x' + + [...crypto.getRandomValues(new Uint8Array(32))] + .map((b) => b.toString(16).padStart(2, '0')) + .join('')) as Hex; + +// 3. Sign EIP-712 typed data. The domain's verifyingContract is the USDC token +// contract itself β€” EIP-3009 is implemented by the token, not by a separate proxy. +const client = createWalletClient({ account, chain: network.chain, transport: http(network.rpc) }); +const signature = await client.signTypedData({ + domain: { + name: accept.extra?.name ?? 'USDC', + version: accept.extra?.version ?? '2', + chainId: network.chain.id, + verifyingContract: accept.asset, + }, + types: TRANSFER_WITH_AUTHORIZATION, + primaryType: 'TransferWithAuthorization', + message: { from: account.address, to: accept.payTo, value, validAfter: 0n, validBefore, nonce }, +}); + +// 4. Wrap into the x402 payment payload and base64-encode for the PAYMENT-SIGNATURE header. +const payload = { + x402Version: 2, + resource: header.resource ?? { url: 'https://example.com', mimeType: 'application/json' }, + payload: { + signature, + authorization: { + from: account.address, + to: accept.payTo, + value: value.toString(), + validAfter: '0', + validBefore: validBefore.toString(), + nonce, + }, + }, + accepted: { + scheme: 'exact', + network: accept.network, + asset: accept.asset, + amount: value.toString(), + payTo: accept.payTo, + maxTimeoutSeconds: accept.maxTimeoutSeconds ?? 3600, + extra: { name: accept.extra?.name ?? 'USDC', version: accept.extra?.version ?? '2' }, + }, +}; + +console.log(Buffer.from(JSON.stringify(payload)).toString('base64')); diff --git a/src/bridge/index.ts b/src/bridge/index.ts index fd556da6..1642ab85 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,18 @@ async function main(): Promise { mcpSessionId = args[mcpSessionIdIndex + 1]; } - // Parse --x402 flag (for x402 payment signing) - const x402 = args.includes('--x402'); + // Parse `--x402 `. The CLI always spawns the bridge with an explicit + // scheme value; if a bare `--x402` slips through (no value, or invalid value) + // we default to `auto` to keep the spawn surface tolerant. + let x402: X402SchemePreference | undefined; + const x402Index = args.indexOf('--x402'); + if (x402Index !== -1) { + const value = args[x402Index + 1]; + x402 = + value !== undefined && (X402_SCHEME_PREFERENCES as readonly string[]).includes(value) + ? (value as X402SchemePreference) + : 'auto'; + } // Parse --insecure flag (skip TLS certificate verification) const insecure = args.includes('--insecure'); @@ -1674,7 +1686,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 58f25af3..2cf8586c 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 8f89a714..b64afeb1 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 a32c99d9..c3dc24aa 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,7 +27,8 @@ 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, getVerboseFromEnv, @@ -63,7 +64,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 +100,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 +112,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 +141,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).` ); } @@ -440,7 +458,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. Use --x402= when followed by positional args.' + ) .addHelpText( 'after', ` @@ -496,7 +517,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 +546,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 +570,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 +579,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 8cac5b7a..d34b3baf 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1306,9 +1306,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/lib/bridge-manager.ts b/src/lib/bridge-manager.ts index f8c4cc3c..cd4c5821 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 { return { sessions: {} }; } + for (const session of Object.values(storage.sessions)) { + normaliseLegacyX402(session); + } + return storage; } catch (error) { logger.warn(`Failed to load sessions: ${(error as Error).message}`); @@ -86,6 +91,36 @@ async function saveSessionsInternal(storage: SessionsStorage): Promise { const SESSIONS_DEFAULT_CONTENT = JSON.stringify({ sessions: {} }, null, 2); +/** + * Normalise the legacy two-field x402 shape into the consolidated single field. + * + * Legacy (pre-consolidation): `{ x402: boolean, x402Scheme?: 'auto'|'upto'|'exact' }`. + * Current: `{ x402?: 'auto'|'upto'|'exact' }` β€” presence enables, value is the preference. + * + * Mutates the session in place; the next `saveSession`/`updateSession` writes the + * normalised shape back to disk, so the migration cost is one read. + */ +export function normaliseLegacyX402( + session: SessionData & { x402Scheme?: X402SchemePreference } +): void { + const rawX402: unknown = session.x402; + const legacyScheme = session.x402Scheme; + + delete session.x402Scheme; + delete (session as { x402?: unknown }).x402; + + if (typeof rawX402 === 'boolean') { + if (rawX402) session.x402 = legacyScheme ?? 'auto'; + return; + } + if ( + typeof rawX402 === 'string' && + (X402_SCHEME_PREFERENCES as readonly string[]).includes(rawX402) + ) { + session.x402 = rawX402 as X402SchemePreference; + } +} + /** * Load sessions from storage (with locking) */ diff --git a/src/lib/types.ts b/src/lib/types.ts index a37627e8..10148b6f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -79,6 +79,10 @@ export const KEEPALIVE_INTERVAL_MS = 30_000; /** Threshold for considering a session disconnected (bridge alive but server unreachable) */ export const DISCONNECTED_THRESHOLD_MS = 2 * KEEPALIVE_INTERVAL_MS + 5000; // ~2 missed pings + 5s buffer +/** Valid x402 scheme preferences. Canonical source for CLI validation and type-narrowing. */ +export const X402_SCHEME_PREFERENCES = ['auto', 'upto', 'exact'] as const; +export type X402SchemePreference = (typeof X402_SCHEME_PREFERENCES)[number]; + /** * Configuration for a connection to MCP server * Used both for config file format and internal representation @@ -142,7 +146,16 @@ export interface SessionData { name: string; server: ServerConfig; // Transport configuration (header values redacted to "") 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. + * + * Legacy shape (pre-consolidation): `x402: boolean` + a sibling `x402Scheme`. The + * session loader normalises both shapes on read β€” see `normaliseLegacyX402` in + * `src/lib/sessions.ts`. + */ + 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 0c326a88..0330f697 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -21,9 +21,11 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { signPayment, parsePaymentRequired, + selectAcceptEntry, type SignerWallet, type PaymentRequiredAccept, type PaymentRequiredHeader, + type SchemePreference, } from './signer.js'; import { createLogger } from '../logger.js'; @@ -32,16 +34,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 +91,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 +108,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 +131,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 +147,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 +171,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 +209,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 +240,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 +257,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 +272,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 +322,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 || 3600, + ...(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 +377,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 +435,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 42170336..472ac3b2 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,45 @@ import { ClientError } from '../errors.js'; export const X402_VERSION = 2; const USDC_DECIMALS = 6; +/** 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 +78,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 +134,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 +143,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 @@ -87,6 +159,14 @@ export interface SignPaymentInput { amountOverride?: bigint; /** Override expiry in seconds (default: from accept.maxTimeoutSeconds or 3600) */ 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 +196,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 +284,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 +307,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 @@ -263,3 +421,239 @@ 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) || 3600; + + // 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/lib/sessions.normaliseLegacyX402.test.ts b/test/unit/lib/sessions.normaliseLegacyX402.test.ts new file mode 100644 index 00000000..f3659369 --- /dev/null +++ b/test/unit/lib/sessions.normaliseLegacyX402.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for the on-read migration that consolidates the legacy + * `{ x402: boolean, x402Scheme?: 'auto'|'upto'|'exact' }` shape into the + * current `{ x402?: 'auto'|'upto'|'exact' }` shape. + * + * The migration runs on every session load, so it must be idempotent on + * already-migrated records and defensive against bogus values that snuck in + * via hand-edited sessions.json files. + */ +import { describe, expect, it } from 'vitest'; + +import { normaliseLegacyX402 } from '../../../src/lib/sessions.js'; +import type { SessionData, X402SchemePreference } from '../../../src/lib/types.js'; + +function baseSession(): SessionData & { x402Scheme?: X402SchemePreference } { + return { + name: '@test', + server: { url: 'https://example.test' }, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('normaliseLegacyX402()', () => { + it('migrates legacy `x402: true` (no scheme) to `x402: "auto"`', () => { + const session = { ...baseSession(), x402: true as unknown as X402SchemePreference }; + normaliseLegacyX402(session); + expect(session.x402).toBe('auto'); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('migrates legacy `x402: true` + `x402Scheme: "exact"` to `x402: "exact"`', () => { + const session = { + ...baseSession(), + x402: true as unknown as X402SchemePreference, + x402Scheme: 'exact' as const, + }; + normaliseLegacyX402(session); + expect(session.x402).toBe('exact'); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('clears legacy `x402: false` regardless of `x402Scheme`', () => { + const session = { + ...baseSession(), + x402: false as unknown as X402SchemePreference, + x402Scheme: 'upto' as const, + }; + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('is idempotent on already-migrated `x402: "upto"`', () => { + const session = { ...baseSession(), x402: 'upto' as const }; + normaliseLegacyX402(session); + expect(session.x402).toBe('upto'); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('drops invalid string values defensively (hand-edited sessions.json)', () => { + const session = { ...baseSession(), x402: 'bogus' as unknown as X402SchemePreference }; + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + }); + + it('leaves sessions without x402 untouched', () => { + const session = baseSession(); + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + expect(session.x402Scheme).toBeUndefined(); + }); + + it('strips `x402Scheme` even when `x402` is unset (clean stale sidecar)', () => { + const session = { ...baseSession(), x402Scheme: 'exact' as const }; + normaliseLegacyX402(session); + expect(session.x402).toBeUndefined(); + expect(session.x402Scheme).toBeUndefined(); + }); +}); 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 00000000..f2f2342e --- /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-scheme 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 00000000..569e5bf0 --- /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(); + }); + }); +});