diff --git a/README.md b/README.md index 80e52b0..1d221e6 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,12 @@ Verbs: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`. `-d, --data` accepts a literal string, `-d @path` to read from a file, or `-d -` to read from stdin. JSON-shaped bodies default to `Content-Type: application/json` unless one is set with `-H`. -`--x402-threshold ` is in the asset's display units (e.g. `0.05` means 0.05 SBC, which is $0.05 since SBC is USD-pegged). When the offered fee is at or below the threshold, the request pays without prompting — designed for AI agents and other non-interactive use. With no threshold and no TTY, the command refuses (exit 2) rather than hang. The `exact` x402 scheme on EVM is supported (uses EIP-3009 `transferWithAuthorization`); the asset must implement it on the configured network. +`--x402-threshold ` is in the asset's display units (e.g. `0.05` means 0.05 SBC, which is $0.05 since SBC is USD-pegged). When the offered fee is at or below the threshold, the request pays without prompting — designed for AI agents and other non-interactive use. For the `upto` scheme the threshold is compared against the authorized maximum. With no threshold and no TTY, the command refuses (exit 2) rather than hang. + +Both x402 v1 and v2 are supported, selected automatically from the server's advertised `x402Version`: + +- **`exact`** (v1, EIP-3009 `transferWithAuthorization`) — a fixed price; the asset must implement EIP-3009 on the configured network. +- **`upto`** (v2, Uniswap Permit2 `permitWitnessTransferFrom` via the `x402UptoPermit2Proxy`) — the client signs a Permit2 authorization up to a maximum and the facilitator settles the actual usage (which may be less, or zero). `upto` needs a one-time ERC-20 `approve` for the canonical Permit2 contract; pass `--x402-approve-permit2` (or `-y`) to send it automatically, otherwise the CLI prompts. Body goes to stdout; payment confirmation and (optionally, with `--include`) headers go to stderr — pipeable. diff --git a/src/commands/walletX402.ts b/src/commands/walletX402.ts index 85ba4b4..aba54a9 100644 --- a/src/commands/walletX402.ts +++ b/src/commands/walletX402.ts @@ -1,9 +1,9 @@ import type { Command } from 'commander'; import { confirm } from '@inquirer/prompts'; -import { formatUnits, parseUnits, type Address } from 'viem'; +import { encodeFunctionData, formatUnits, maxUint256, parseUnits, type Address } from 'viem'; import { resolveConfig } from '../lib/config.js'; import { requireAccount } from '../lib/account.js'; -import { makePublicClient } from '../lib/client.js'; +import { makePublicClient, makeWalletClient } from '../lib/client.js'; import { jsonStringify } from '../lib/format.js'; import { decodeBodyAsUtf8, @@ -19,19 +19,22 @@ import { } from '../lib/x402/http.js'; import { decodePaymentResponse, - encodePaymentHeader, networkIdForChain, parseChallenge, - pickAccept, + V1_PAYMENT_RESPONSE_HEADER, + V2_PAYMENT_REQUIRED_HEADER, + V2_PAYMENT_RESPONSE_HEADER, type AcceptEntry, type PaymentResponseBody, } from '../lib/x402/protocol.js'; +import { readAssetInfo, readBalance } from '../lib/x402/eip3009.js'; import { - makeAuthorization, - readAssetInfo, - readBalance, - signTransferAuthorization, -} from '../lib/x402/eip3009.js'; + selectHandler, + readPermit2Allowance, + CANONICAL_PERMIT2_ADDRESS, + PERMIT2_ALLOWANCE_ABI, + type SchemeHandler, +} from '../lib/x402/handlers.js'; import type { GlobalOptions } from '../types.js'; interface SubOptions { @@ -40,10 +43,12 @@ interface SubOptions { x402Threshold?: string; yes?: boolean; include?: boolean; + x402ApprovePermit2?: boolean; } interface PaymentSummary { paid: boolean; + scheme: string; asset: Address; assetSymbol: string | null; amount: string; @@ -59,6 +64,7 @@ export function registerWalletX402(wallet: Command): void { .description( [ 'Make an HTTP request and pay an x402 challenge if the server responds with 402.', + 'Supports x402 v1 (exact / EIP-3009) and v2 (upto / Permit2).', '', ' radius-cli wallet x402 get https://example.com/resource', ' radius-cli wallet x402 post https://api.example.com/x -d \'{"a":1}\'', @@ -74,6 +80,10 @@ export function registerWalletX402(wallet: Command): void { "auto-pay if the offered fee ≤ this amount in the asset's display units", ) .option('-y, --yes', 'auto-confirm payment regardless of amount') + .option( + '--x402-approve-permit2', + 'auto-approve the one-time Permit2 ERC-20 allowance needed by the upto scheme', + ) .option('--include', 'write response status and headers to stderr') .action(async (verbArg: string, url: string, subOpts: SubOptions, cmd) => { const opts = cmd.optsWithGlobals() as GlobalOptions; @@ -108,8 +118,7 @@ async function runX402( const cfg = resolveConfig(opts); let challenge; try { - const text = decodeBodyAsUtf8(initial.body) ?? ''; - challenge = parseChallenge(JSON.parse(text)); + challenge = parseChallenge(readChallenge(initial)); } catch (e) { process.stderr.write( `x402: server returned 402 but the body is not a valid challenge: ${(e as Error).message}\n`, @@ -118,13 +127,23 @@ async function runX402( process.exit(2); } - const accept = pickAccept(challenge.accepts, cfg.chain.id); - if (!accept) { + let accept: AcceptEntry | undefined; + let handler: SchemeHandler | undefined; + for (const candidate of challenge.accepts) { + const h = selectHandler(candidate, cfg.chain.id); + if (h) { + accept = candidate; + handler = h; + break; + } + } + if (!accept || !handler) { const offered = challenge.accepts .map((a) => `${a.scheme}@${a.network} (${a.asset})`) .join(', '); process.stderr.write( - `x402: no compatible payment option. Wanted scheme=exact network=${networkIdForChain(cfg.chain.id)}; server offered: ${offered}\n`, + `x402: no compatible payment option for network=${networkIdForChain(cfg.chain.id)}. ` + + `Supported: exact@v1, upto@v2. Server offered: ${offered}\n`, ); process.exit(1); } @@ -146,22 +165,25 @@ async function runX402( const amountStr = formatUnits(accept.maxAmountRequired, asset.decimals); const balanceStr = formatUnits(balance, asset.decimals); const symbol = asset.symbol ?? accept.asset; + const isUpto = handler.scheme === 'upto'; if (balance < accept.maxAmountRequired) { process.stderr.write( - `x402: insufficient balance. Need ${amountStr} ${symbol}, have ${balanceStr} ${symbol}.\n`, + `x402: insufficient balance. Need ${isUpto ? 'up to ' : ''}${amountStr} ${symbol}, ` + + `have ${balanceStr} ${symbol}.\n`, ); process.exit(1); } const decided = await decideAutoPay(subOpts, accept, asset.decimals); if (decided === 'refuse-no-tty') { - writeChallengeSummary(accept, asset.decimals, asset.symbol, balanceStr); + writeChallengeSummary(accept, asset.decimals, asset.symbol, balanceStr, isUpto); process.exit(2); } if (decided === 'prompt') { + const verbText = isUpto ? `Authorize up to ${amountStr}` : `Pay ${amountStr}`; const proceed = await confirm({ - message: `Pay ${amountStr} ${symbol} to ${accept.payTo}? (balance: ${balanceStr} ${symbol})`, + message: `${verbText} ${symbol} to ${accept.payTo}? (balance: ${balanceStr} ${symbol})`, default: false, }); if (!proceed) { @@ -170,38 +192,38 @@ async function runX402( } } - const authorization = makeAuthorization({ - from: account.address, - to: accept.payTo, - value: accept.maxAmountRequired, - maxTimeoutSeconds: accept.maxTimeoutSeconds, - }); + // upto rides on Permit2: the payer must have a one-time ERC-20 approval for Permit2. + if (isUpto) { + const ok = await ensurePermit2Allowance( + client, + cfg, + account, + accept, + asset.decimals, + symbol, + subOpts, + ); + if (!ok) process.exit(1); + } - let signature; + let built; try { - signature = await signTransferAuthorization(account, { - asset: accept.asset, + built = await handler.buildPayload(accept, { + account, chainId: cfg.chain.id, - name: asset.name, - version: asset.version, - authorization, + x402Version: challenge.x402Version, + assetName: asset.name, + assetVersion: asset.version, + amount: accept.maxAmountRequired, + url, }); } catch (e) { - process.stderr.write( - `x402: failed to sign EIP-3009 authorization (asset may not support transferWithAuthorization): ${(e as Error).message}\n`, - ); + process.stderr.write(`x402: failed to build ${handler.scheme} payment: ${(e as Error).message}\n`); process.exit(1); } - const paymentHeader = encodePaymentHeader({ - x402Version: challenge.x402Version, - scheme: accept.scheme, - network: accept.network, - payload: { signature, authorization }, - }); - const retryHeaders = new Headers(reqHeaders); - retryHeaders.set('x-payment', paymentHeader); + retryHeaders.set(built.headerName, built.headerValueBase64); const retry = await runRequest(verb as HttpVerb, url, { headers: retryHeaders, @@ -213,26 +235,41 @@ async function runX402( const loc = retry.headers.get('location'); if (!loc || !sameOrigin(url, new URL(loc, url).toString())) { process.stderr.write( - 'x402: server redirected the paid request cross-origin; refusing to replay X-PAYMENT.\n', + 'x402: server redirected the paid request cross-origin; refusing to replay the payment header.\n', ); process.exit(1); } } + if (retry.status === 412) { + process.stderr.write( + 'x402: facilitator rejected the payment — Permit2 allowance required (412). ' + + 'Re-run with --x402-approve-permit2 to grant it.\n', + ); + process.stderr.write(safeBodyPreview(retry.body)); + process.exit(1); + } + let paymentResponse: PaymentResponseBody | null = null; - const xpr = retry.headers.get('x-payment-response'); + const xpr = + retry.headers.get(V2_PAYMENT_RESPONSE_HEADER) ?? retry.headers.get(V1_PAYMENT_RESPONSE_HEADER); if (xpr) { try { paymentResponse = decodePaymentResponse(xpr); } catch { /* ignore malformed */ } } + // For upto the facilitator reports the actual charged amount; fall back to the max. + const settledWei = paymentResponse?.amount ?? accept.maxAmountRequired.toString(); + const settledStr = formatUnits(BigInt(settledWei), asset.decimals); + const summary: PaymentSummary = { paid: retry.status >= 200 && retry.status < 300, + scheme: handler.scheme, asset: accept.asset, assetSymbol: asset.symbol, - amount: amountStr, - amountWei: accept.maxAmountRequired.toString(), + amount: settledStr, + amountWei: settledWei, payTo: accept.payTo, - txHash: paymentResponse?.transaction, + txHash: paymentResponse?.transaction || undefined, payer: paymentResponse?.payer ?? account.address, }; @@ -252,6 +289,90 @@ async function runX402( process.exit(retry.status >= 400 ? 1 : 0); } +/** Read the v2 PAYMENT-REQUIRED header (base64 JSON) if present, else the JSON body. */ +function readChallenge(res: HttpResponse): unknown { + const v2 = res.headers.get(V2_PAYMENT_REQUIRED_HEADER); + if (v2) { + return JSON.parse(Buffer.from(v2, 'base64').toString('utf8')); + } + const text = decodeBodyAsUtf8(res.body) ?? ''; + return JSON.parse(text); +} + +/** + * Ensure the payer has approved the canonical Permit2 contract for at least the + * authorized max. Sends an approve tx if missing (prompting unless -y / --x402-approve-permit2). + * Returns false when the user declines or there's no way to proceed. + */ +async function ensurePermit2Allowance( + client: ReturnType, + cfg: ReturnType, + account: Awaited>, + accept: AcceptEntry, + decimals: number, + symbol: string, + subOpts: SubOptions, +): Promise { + let allowance: bigint; + try { + allowance = await readPermit2Allowance( + client, + accept.asset, + account.address, + CANONICAL_PERMIT2_ADDRESS, + ); + } catch (e) { + process.stderr.write(`x402: failed to read Permit2 allowance: ${(e as Error).message}\n`); + return false; + } + if (allowance >= accept.maxAmountRequired) return true; + + const needStr = formatUnits(accept.maxAmountRequired, decimals); + const auto = subOpts.yes || subOpts.x402ApprovePermit2; + if (!auto) { + if (!process.stdin.isTTY) { + process.stderr.write( + `x402: upto requires a one-time Permit2 approval for ${symbol} (have ` + + `${formatUnits(allowance, decimals)}, need ${needStr}). ` + + 'Re-run with --x402-approve-permit2 (or -y) to grant it.\n', + ); + return false; + } + const proceed = await confirm({ + message: `Approve Permit2 (${CANONICAL_PERMIT2_ADDRESS}) to spend ${symbol}? (one-time, required by upto)`, + default: false, + }); + if (!proceed) { + process.stderr.write('x402: Permit2 approval declined.\n'); + return false; + } + } + + try { + const walletClient = makeWalletClient(cfg, account); + const data = encodeFunctionData({ + abi: PERMIT2_ALLOWANCE_ABI, + functionName: 'approve', + args: [CANONICAL_PERMIT2_ADDRESS, maxUint256], + }); + const gasPrice = await client.getGasPrice(); + const hash = await walletClient.sendTransaction({ + account, + to: accept.asset, + data, + gasPrice, + type: 'legacy', + chain: cfg.chain, + }); + process.stderr.write(`x402: sent Permit2 approval (tx ${hash}); waiting for confirmation…\n`); + await client.waitForTransactionReceipt({ hash }); + return true; + } catch (e) { + process.stderr.write(`x402: Permit2 approval failed: ${(e as Error).message}\n`); + return false; + } +} + type Decision = 'auto-pay' | 'prompt' | 'refuse-no-tty'; async function decideAutoPay( @@ -267,6 +388,7 @@ async function decideAutoPay( } catch { throw new Error(`--x402-threshold must be a decimal number, got: ${subOpts.x402Threshold}`); } + // For upto, maxAmountRequired is the authorized ceiling; compare against it. if (limit >= accept.maxAmountRequired) return 'auto-pay'; } if (process.stdin.isTTY) return 'prompt'; @@ -278,12 +400,16 @@ function writeChallengeSummary( decimals: number, symbol: string | null, balanceStr: string, + isUpto: boolean, ): void { const amount = formatUnits(accept.maxAmountRequired, decimals); const tag = symbol ?? accept.asset; + const lead = isUpto + ? `payment required (authorize up to ${amount} ${tag}, charged on use, to ${accept.payTo}).` + : `payment required (${amount} ${tag} to ${accept.payTo}).`; process.stderr.write( [ - `x402: payment required (${amount} ${tag} to ${accept.payTo}).`, + `x402: ${lead}`, ` balance: ${balanceStr} ${tag}`, ` pass --x402-threshold ${amount} (or higher) to auto-pay, or --yes to confirm.`, '', @@ -301,10 +427,10 @@ function emit(res: HttpResponse, payment: PaymentSummary | null, json: boolean, res.headers.forEach((v, k) => { process.stderr.write(`${k}: ${v}\n`); }); process.stderr.write('\n'); } - if (payment?.paid && payment.txHash) { - process.stderr.write(`x402: paid ${payment.amount} ${payment.assetSymbol ?? payment.asset} (tx ${payment.txHash})\n`); - } else if (payment?.paid) { - process.stderr.write(`x402: paid ${payment.amount} ${payment.assetSymbol ?? payment.asset}\n`); + if (payment?.paid) { + const tag = payment.assetSymbol ?? payment.asset; + const tx = payment.txHash ? ` (tx ${payment.txHash})` : ''; + process.stderr.write(`x402: paid ${payment.amount} ${tag}${tx}\n`); } process.stdout.write(res.body); } diff --git a/src/lib/x402/handlers.ts b/src/lib/x402/handlers.ts new file mode 100644 index 0000000..3f34bcf --- /dev/null +++ b/src/lib/x402/handlers.ts @@ -0,0 +1,184 @@ +import type { Address, Hex, PrivateKeyAccount, PublicClient } from 'viem'; +import { + encodeBase64Json, + encodePaymentHeader, + networkMatchesChain, + V1_PAYMENT_HEADER, + V2_PAYMENT_SIGNATURE_HEADER, + type AcceptEntry, +} from './protocol.js'; +import { makeAuthorization, signTransferAuthorization } from './eip3009.js'; +import { + makePermit2Authorization, + signPermit2Authorization, + PERMIT2_ALLOWANCE_ABI, + type Permit2Authorization, +} from './permit2.js'; + +export interface BuildContext { + account: PrivateKeyAccount; + chainId: number; + x402Version: number; + // Resolved asset metadata for the chosen accept entry. + assetName: string; + assetVersion: string; + // The atomic-unit amount to authorize (the max for `upto`). + amount: bigint; + // The original request URL (used for the v2 `resource`). + url: string; +} + +export interface BuiltPayload { + headerName: string; + headerValueBase64: string; +} + +export interface SchemeHandler { + version: number; + scheme: string; + /** True when this handler can satisfy the offered entry on the configured chain. */ + canHandle(accept: AcceptEntry, chainId: number): boolean; + buildPayload(accept: AcceptEntry, ctx: BuildContext): Promise; +} + +// -- exact@v1 (EIP-3009 transferWithAuthorization) -------------------------------- + +const exactV1Handler: SchemeHandler = { + version: 1, + scheme: 'exact', + canHandle(accept, chainId) { + return accept.scheme === 'exact' && networkMatchesChain(accept.network, chainId); + }, + async buildPayload(accept, ctx) { + const authorization = makeAuthorization({ + from: ctx.account.address, + to: accept.payTo, + value: ctx.amount, + maxTimeoutSeconds: accept.maxTimeoutSeconds, + }); + const signature = await signTransferAuthorization(ctx.account, { + asset: accept.asset, + chainId: ctx.chainId, + name: ctx.assetName, + version: ctx.assetVersion, + authorization, + }); + const headerValueBase64 = encodePaymentHeader({ + x402Version: 1, + scheme: accept.scheme, + network: accept.network, + payload: { signature, authorization }, + }); + return { headerName: V1_PAYMENT_HEADER, headerValueBase64 }; + }, +}; + +// -- upto@v2 (Permit2 permitWitnessTransferFrom via x402UptoPermit2Proxy) ---------- + +function readFacilitator(accept: AcceptEntry): Address { + const f = accept.extra?.facilitatorAddress ?? accept.extra?.facilitator; + if (typeof f !== 'string') { + throw new Error( + "upto: the 402 'extra' is missing 'facilitatorAddress'; cannot bind the Permit2 witness.", + ); + } + return f as Address; +} + +function serializePermit2Authorization(a: Permit2Authorization): Record { + return { + permitted: { token: a.permitted.token, amount: a.permitted.amount.toString() }, + from: a.from, + spender: a.spender, + nonce: a.nonce.toString(), + deadline: a.deadline.toString(), + witness: { + to: a.witness.to, + facilitator: a.witness.facilitator, + validAfter: a.witness.validAfter.toString(), + }, + }; +} + +const uptoV2Handler: SchemeHandler = { + version: 2, + scheme: 'upto', + canHandle(accept, chainId) { + return accept.scheme === 'upto' && networkMatchesChain(accept.network, chainId); + }, + async buildPayload(accept, ctx) { + const facilitator = readFacilitator(accept); + const authorization = makePermit2Authorization({ + from: ctx.account.address, + asset: accept.asset, + payTo: accept.payTo, + facilitator, + maxAmount: ctx.amount, + maxTimeoutSeconds: accept.maxTimeoutSeconds, + }); + const signature: Hex = await signPermit2Authorization(ctx.account, { + chainId: ctx.chainId, + authorization, + }); + + // v2 PaymentPayload: top-level x402Version + resource, the chosen requirements ride + // in `accepted` (extra reduced to {name,version}), the scheme payload in `payload`. + const payload = { + x402Version: 2, + resource: { url: ctx.url, description: accept.description, mimeType: accept.mimeType }, + accepted: { + scheme: accept.scheme, + network: accept.network, + amount: ctx.amount.toString(), + asset: accept.asset, + payTo: accept.payTo, + maxTimeoutSeconds: accept.maxTimeoutSeconds, + extra: { name: accept.extra?.name, version: accept.extra?.version }, + }, + payload: { + signature, + permit2Authorization: serializePermit2Authorization(authorization), + }, + }; + return { headerName: V2_PAYMENT_SIGNATURE_HEADER, headerValueBase64: encodeBase64Json(payload) }; + }, +}; + +// -- registry --------------------------------------------------------------------- + +// exact@v2 is intentionally not implemented yet: the v2 framing of the EIP-3009 exact +// payload isn't pinned down in the spec we have. upto@v2 is the v2 deliverable. See the +// PR's "Caveats / follow-ups" for the exact@v2 TODO. +export const HANDLERS: SchemeHandler[] = [exactV1Handler, uptoV2Handler]; + +/** Pick the first handler that can satisfy an offered entry on the configured chain. */ +export function selectHandler( + accept: AcceptEntry, + chainId: number, +): SchemeHandler | undefined { + return HANDLERS.find((h) => h.canHandle(accept, chainId)); +} + +export { readFacilitator, serializePermit2Authorization }; + +// Re-export allowance helpers so callers import preflight from one place. +export { + CANONICAL_PERMIT2_ADDRESS, + X402_UPTO_PERMIT2_PROXY, + PERMIT2_ALLOWANCE_ABI, +} from './permit2.js'; + +/** Read the payer's ERC-20 allowance granted to the canonical Permit2 contract. */ +export async function readPermit2Allowance( + client: PublicClient, + asset: Address, + owner: Address, + permit2: Address, +): Promise { + return (await client.readContract({ + address: asset, + abi: PERMIT2_ALLOWANCE_ABI, + functionName: 'allowance', + args: [owner, permit2], + })) as bigint; +} diff --git a/src/lib/x402/permit2.ts b/src/lib/x402/permit2.ts new file mode 100644 index 0000000..ffd7a4a --- /dev/null +++ b/src/lib/x402/permit2.ts @@ -0,0 +1,139 @@ +import { randomBytes } from 'node:crypto'; +import type { Address, Hex, PrivateKeyAccount } from 'viem'; + +// -- Single source of truth for the `upto` Permit2 constants ---------------------- + +// Canonical Uniswap Permit2, identical across EVM chains. Overridable for non-standard +// deployments via the optional permit2 argument to signPermit2Authorization. +// https://github.com/Uniswap/permit2 +export const CANONICAL_PERMIT2_ADDRESS: Address = + '0x000000000022D473030F116dDEE9F6B43aC78BA3'; + +// x402UptoPermit2Proxy — the authorized Permit2 spender for the `upto` scheme. +// Deterministic (CREATE2) on every EVM chain. +// Source: x402-foundation/x402 contracts/evm/src/x402UptoPermit2Proxy.sol +export const X402_UPTO_PERMIT2_PROXY: Address = + '0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002'; + +// EIP-712 type set for the `upto` permit. Confirmed against the deployed +// x402UptoPermit2Proxy: WITNESS_TYPEHASH = keccak256( +// "Witness(address to,address facilitator,uint256 validAfter)"). The Witness +// member order (to, facilitator, validAfter) is load-bearing — do not reorder. +// viem derives the encoded type string and sorts referenced structs alphabetically +// (TokenPermissions < Witness), which matches Permit2's WITNESS_TYPE_STRING. +export const PERMIT2_UPTO_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; + +export const PERMIT2_ALLOWANCE_ABI = [ + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + outputs: [{ type: 'uint256' }], + }, + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ type: 'bool' }], + }, +] as const; + +export interface Permit2Witness { + to: Address; + facilitator: Address; + validAfter: number; +} + +export interface Permit2Authorization { + permitted: { token: Address; amount: bigint }; + from: Address; + spender: Address; + nonce: bigint; + deadline: number; + witness: Permit2Witness; +} + +/** Random 256-bit Permit2 unordered nonce. */ +export function randomPermit2Nonce(): bigint { + return BigInt(`0x${randomBytes(32).toString('hex')}`); +} + +export function makePermit2Authorization(args: { + from: Address; + asset: Address; + payTo: Address; + facilitator: Address; + maxAmount: bigint; + maxTimeoutSeconds: number | undefined; +}): Permit2Authorization { + const window = Math.min(Math.max(args.maxTimeoutSeconds ?? 600, 1), 600); + const now = Math.floor(Date.now() / 1000); + return { + permitted: { token: args.asset, amount: args.maxAmount }, + from: args.from, + spender: X402_UPTO_PERMIT2_PROXY, + nonce: randomPermit2Nonce(), + deadline: now + window, + witness: { to: args.payTo, facilitator: args.facilitator, validAfter: now }, + }; +} + +/** + * Sign the Permit2 `PermitWitnessTransferFrom` EIP-712 digest. + * + * The Permit2 domain is name-only: { name: "Permit2", chainId, verifyingContract }. + * The canonical Permit2 contract has NO `version` field in its EIP712Domain, so we + * deliberately omit it — adding one would change the domain separator and yield a + * signature the facilitator/contract rejects. + */ +export async function signPermit2Authorization( + account: PrivateKeyAccount, + args: { chainId: number; authorization: Permit2Authorization; permit2?: Address }, +): Promise { + const a = args.authorization; + return await account.signTypedData({ + domain: { + name: 'Permit2', + chainId: args.chainId, + verifyingContract: args.permit2 ?? CANONICAL_PERMIT2_ADDRESS, + }, + types: PERMIT2_UPTO_TYPES, + primaryType: 'PermitWitnessTransferFrom', + message: { + permitted: { token: a.permitted.token, amount: a.permitted.amount }, + spender: a.spender, + nonce: a.nonce, + deadline: BigInt(a.deadline), + witness: { + to: a.witness.to, + facilitator: a.witness.facilitator, + validAfter: BigInt(a.witness.validAfter), + }, + }, + }); +} diff --git a/src/lib/x402/protocol.ts b/src/lib/x402/protocol.ts index b3f6dbb..f3610fa 100644 --- a/src/lib/x402/protocol.ts +++ b/src/lib/x402/protocol.ts @@ -1,19 +1,38 @@ import { isAddress, type Address, type Hex } from 'viem'; +// x402 protocol versions this client understands. export const X402_VERSION = 1; +export const X402_VERSION_V2 = 2; + +// v1 client→server / server→client headers. +export const V1_PAYMENT_HEADER = 'x-payment'; +export const V1_PAYMENT_RESPONSE_HEADER = 'x-payment-response'; + +// v2 renames the wire headers. The 402 challenge may also arrive in the body. +export const V2_PAYMENT_REQUIRED_HEADER = 'payment-required'; +export const V2_PAYMENT_SIGNATURE_HEADER = 'payment-signature'; +export const V2_PAYMENT_RESPONSE_HEADER = 'payment-response'; export interface AcceptEntry { scheme: string; network: string; asset: Address; payTo: Address; + // Authorized amount in atomic units. v1 calls this `maxAmountRequired`; v2 calls it `amount`. + // For `upto` this is the MAX the client authorizes; the facilitator settles ≤ this. maxAmountRequired: bigint; resource?: string; description?: string; mimeType?: string; outputSchema?: unknown; maxTimeoutSeconds?: number; - extra?: { name?: string; version?: string; [k: string]: unknown }; + extra?: { + name?: string; + version?: string; + facilitatorAddress?: string; + facilitator?: string; + [k: string]: unknown; + }; } export interface Challenge { @@ -22,6 +41,7 @@ export interface Challenge { error?: string; } +// EIP-3009 authorization (exact@v1 / exact payloads). export interface Authorization { from: Address; to: Address; @@ -31,18 +51,13 @@ export interface Authorization { nonce: Hex; } -export interface PaymentPayload { - x402Version: number; - scheme: string; - network: string; - payload: { signature: Hex; authorization: Authorization }; -} - export interface PaymentResponseBody { success: boolean; transaction?: string; network?: string; payer?: string; + // Actual amount charged (atomic-unit string). v2 `upto` may settle less than the max, or 0. + amount?: string; errorReason?: string | null; } @@ -72,6 +87,10 @@ function asBigInt(v: unknown, field: string): bigint { throw new Error(`x402 challenge: '${field}' must be a non-negative integer (got ${typeof v})`); } +/** + * Parse a v1 or v2 challenge. v1 carries the authorized amount in `maxAmountRequired`; + * v2 renames it to `amount`. Both are normalized onto `AcceptEntry.maxAmountRequired`. + */ export function parseChallenge(raw: unknown): Challenge { const obj = asObject(raw); const version = obj.x402Version; @@ -84,12 +103,13 @@ export function parseChallenge(raw: unknown): Challenge { } const parsedAccepts = accepts.map((entry, i) => { const e = asObject(entry); + const amountField = e.amount !== undefined ? 'amount' : 'maxAmountRequired'; return { scheme: asString(e.scheme, `accepts[${i}].scheme`), network: asString(e.network, `accepts[${i}].network`), asset: asAddress(e.asset, `accepts[${i}].asset`), payTo: asAddress(e.payTo, `accepts[${i}].payTo`), - maxAmountRequired: asBigInt(e.maxAmountRequired, `accepts[${i}].maxAmountRequired`), + maxAmountRequired: asBigInt(e[amountField], `accepts[${i}].${amountField}`), resource: typeof e.resource === 'string' ? e.resource : undefined, description: typeof e.description === 'string' ? e.description : undefined, mimeType: typeof e.mimeType === 'string' ? e.mimeType : undefined, @@ -110,16 +130,34 @@ export function parseChallenge(raw: unknown): Challenge { }; } +/** CAIP-2 network id for an EVM chain, e.g. `eip155:84532`. */ export function networkIdForChain(chainId: number): string { return `eip155:${chainId}`; } -export function pickAccept(accepts: AcceptEntry[], chainId: number): AcceptEntry | undefined { - const target = networkIdForChain(chainId); - return accepts.find((a) => a.scheme === 'exact' && a.network === target); +/** Parse a CAIP-2 `eip155:` network string to a chain id, or undefined if not EVM/parseable. */ +export function chainIdForNetwork(network: string): number | undefined { + const m = /^eip155:(\d+)$/.exec(network); + if (!m) return undefined; + const id = Number(m[1]); + return Number.isSafeInteger(id) ? id : undefined; +} + +/** True when the accept entry's network matches the configured chain id. */ +export function networkMatchesChain(network: string, chainId: number): boolean { + return chainIdForNetwork(network) === chainId; +} + +// -- v1 EIP-3009 header (exact@v1) ------------------------------------------------ + +export interface V1PaymentPayload { + x402Version: number; + scheme: string; + network: string; + payload: { signature: Hex; authorization: Authorization }; } -export function encodePaymentHeader(payload: PaymentPayload): string { +export function encodePaymentHeader(payload: V1PaymentPayload): string { const json = JSON.stringify({ x402Version: payload.x402Version, scheme: payload.scheme, @@ -139,6 +177,12 @@ export function encodePaymentHeader(payload: PaymentPayload): string { return Buffer.from(json, 'utf8').toString('base64'); } +/** Base64-encode an already-built JSON-serializable payload (used by v2 handlers). */ +export function encodeBase64Json(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); +} + +/** Decode a settlement response from either the v1 or v2 response header. */ export function decodePaymentResponse(headerValue: string): PaymentResponseBody { const json = Buffer.from(headerValue, 'base64').toString('utf8'); const obj = JSON.parse(json) as Record; @@ -147,6 +191,7 @@ export function decodePaymentResponse(headerValue: string): PaymentResponseBody transaction: typeof obj.transaction === 'string' ? obj.transaction : undefined, network: typeof obj.network === 'string' ? obj.network : undefined, payer: typeof obj.payer === 'string' ? obj.payer : undefined, + amount: typeof obj.amount === 'string' ? obj.amount : undefined, errorReason: typeof obj.errorReason === 'string' ? obj.errorReason : null, }; } diff --git a/tests/x402-protocol.test.ts b/tests/x402-protocol.test.ts index 8b1ba13..6648763 100644 --- a/tests/x402-protocol.test.ts +++ b/tests/x402-protocol.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from 'vitest'; import { + chainIdForNetwork, decodePaymentResponse, encodePaymentHeader, networkIdForChain, + networkMatchesChain, parseChallenge, - pickAccept, } from '../src/lib/x402/protocol.js'; const VALID_CHALLENGE = { @@ -26,8 +27,29 @@ const VALID_CHALLENGE = { error: 'X-PAYMENT required', }; +// v2 renames maxAmountRequired -> amount and carries CAIP-2 networks. +const V2_UPTO_CHALLENGE = { + x402Version: 2, + accepts: [ + { + scheme: 'upto', + network: 'eip155:84532', + asset: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', + payTo: '0x000000000000000000000000000000000000dEaD', + amount: '500000', + maxTimeoutSeconds: 120, + extra: { + name: 'USDC', + version: '2', + facilitatorAddress: '0x00000000000000000000000000000000fac11107', + }, + }, + ], + error: 'PAYMENT-SIGNATURE required', +}; + describe('parseChallenge', () => { - it('accepts a spec-shaped challenge', () => { + it('accepts a v1 (maxAmountRequired) challenge', () => { const c = parseChallenge(VALID_CHALLENGE); expect(c.x402Version).toBe(1); expect(c.accepts).toHaveLength(1); @@ -36,6 +58,16 @@ describe('parseChallenge', () => { expect(c.error).toBe('X-PAYMENT required'); }); + it('accepts a v2 (amount) challenge and normalizes the amount', () => { + const c = parseChallenge(V2_UPTO_CHALLENGE); + expect(c.x402Version).toBe(2); + expect(c.accepts[0].scheme).toBe('upto'); + expect(c.accepts[0].maxAmountRequired).toBe(500000n); + expect(c.accepts[0].extra?.facilitatorAddress).toBe( + '0x00000000000000000000000000000000fac11107', + ); + }); + it('rejects non-objects', () => { expect(() => parseChallenge(null)).toThrow(); expect(() => parseChallenge([])).toThrow(); @@ -63,41 +95,31 @@ describe('parseChallenge', () => { }); }); -describe('pickAccept', () => { - it('matches the configured chain id', () => { - const c = parseChallenge(VALID_CHALLENGE); - const a = pickAccept(c.accepts, 723487); - expect(a?.payTo).toBe('0x000000000000000000000000000000000000dEaD'); +describe('CAIP-2 network parsing', () => { + it('formats CAIP-2 ids', () => { + expect(networkIdForChain(723487)).toBe('eip155:723487'); + expect(networkIdForChain(84532)).toBe('eip155:84532'); }); - it('returns undefined on mismatch', () => { - const c = parseChallenge(VALID_CHALLENGE); - expect(pickAccept(c.accepts, 1)).toBeUndefined(); + it('parses eip155: back to a chain id', () => { + expect(chainIdForNetwork('eip155:84532')).toBe(84532); + expect(chainIdForNetwork('eip155:1')).toBe(1); }); - it('skips non-exact schemes', () => { - const challenge = { - x402Version: 1, - accepts: [ - { ...VALID_CHALLENGE.accepts[0], scheme: 'subscription' }, - { ...VALID_CHALLENGE.accepts[0] }, - ], - }; - const c = parseChallenge(challenge); - const a = pickAccept(c.accepts, 723487); - expect(a?.scheme).toBe('exact'); + it('returns undefined for non-eip155 / malformed networks', () => { + expect(chainIdForNetwork('solana:mainnet')).toBeUndefined(); + expect(chainIdForNetwork('eip155:')).toBeUndefined(); + expect(chainIdForNetwork('723487')).toBeUndefined(); }); -}); -describe('networkIdForChain', () => { - it('formats CAIP-2 ids', () => { - expect(networkIdForChain(723487)).toBe('eip155:723487'); - expect(networkIdForChain(1)).toBe('eip155:1'); + it('matches a network string against a configured chain id', () => { + expect(networkMatchesChain('eip155:84532', 84532)).toBe(true); + expect(networkMatchesChain('eip155:84532', 1)).toBe(false); }); }); describe('encodePaymentHeader / decodePaymentResponse', () => { - it('round-trips a payment header through base64 + JSON', () => { + it('round-trips a v1 payment header through base64 + JSON', () => { const header = encodePaymentHeader({ x402Version: 1, scheme: 'exact', @@ -120,7 +142,7 @@ describe('encodePaymentHeader / decodePaymentResponse', () => { expect(decoded.payload.authorization.validBefore).toBe(1234567890); }); - it('decodes a payment-response header', () => { + it('decodes a v1 payment-response header', () => { const body = { success: true, transaction: '0xabc', @@ -134,4 +156,19 @@ describe('encodePaymentHeader / decodePaymentResponse', () => { expect(decoded.transaction).toBe('0xabc'); expect(decoded.payer).toBe('0x0000000000000000000000000000000000000003'); }); + + it('decodes a v2 upto payment-response with the actual settled amount', () => { + const body = { + success: true, + transaction: '', + network: 'eip155:84532', + payer: '0x0000000000000000000000000000000000000003', + amount: '0', + }; + const header = Buffer.from(JSON.stringify(body), 'utf8').toString('base64'); + const decoded = decodePaymentResponse(header); + expect(decoded.success).toBe(true); + expect(decoded.amount).toBe('0'); + expect(decoded.transaction).toBe(''); + }); }); diff --git a/tests/x402-upto.test.ts b/tests/x402-upto.test.ts new file mode 100644 index 0000000..530ff06 --- /dev/null +++ b/tests/x402-upto.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest'; +import { recoverTypedDataAddress, type Address, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { parseChallenge } from '../src/lib/x402/protocol.js'; +import { selectHandler } from '../src/lib/x402/handlers.js'; +import { + CANONICAL_PERMIT2_ADDRESS, + X402_UPTO_PERMIT2_PROXY, + PERMIT2_UPTO_TYPES, + makePermit2Authorization, + signPermit2Authorization, +} from '../src/lib/x402/permit2.js'; + +const PK = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex; +const ASSET = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb' as Address; +const PAY_TO = '0x000000000000000000000000000000000000dEaD' as Address; +const FACILITATOR = '0x00000000000000000000000000000000fac11107' as Address; + +const V2_UPTO_CHALLENGE = { + x402Version: 2, + accepts: [ + { + scheme: 'upto', + network: 'eip155:84532', + asset: ASSET, + payTo: PAY_TO, + amount: '500000', + maxTimeoutSeconds: 120, + extra: { name: 'USDC', version: '2', facilitatorAddress: FACILITATOR }, + }, + ], +}; + +describe('selectHandler', () => { + it('selects exact@v1 for an exact entry on the configured chain', () => { + const c = parseChallenge({ + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: 'eip155:723487', + asset: ASSET, + payTo: PAY_TO, + maxAmountRequired: '13000', + }, + ], + }); + const h = selectHandler(c.accepts[0], 723487); + expect(h?.scheme).toBe('exact'); + expect(h?.version).toBe(1); + }); + + it('selects upto@v2 — upto is no longer filtered out', () => { + const c = parseChallenge(V2_UPTO_CHALLENGE); + const h = selectHandler(c.accepts[0], 84532); + expect(h?.scheme).toBe('upto'); + expect(h?.version).toBe(2); + }); + + it('returns undefined when no handler matches the chain', () => { + const c = parseChallenge(V2_UPTO_CHALLENGE); + expect(selectHandler(c.accepts[0], 1)).toBeUndefined(); + }); + + it('returns undefined for an unsupported scheme', () => { + const c = parseChallenge({ + x402Version: 1, + accepts: [ + { + scheme: 'subscription', + network: 'eip155:723487', + asset: ASSET, + payTo: PAY_TO, + maxAmountRequired: '1', + }, + ], + }); + expect(selectHandler(c.accepts[0], 723487)).toBeUndefined(); + }); +}); + +describe('Permit2 constants', () => { + it('uses the canonical Permit2 and the x402 upto proxy as spender', () => { + expect(CANONICAL_PERMIT2_ADDRESS).toBe('0x000000000022D473030F116dDEE9F6B43aC78BA3'); + expect(X402_UPTO_PERMIT2_PROXY).toBe('0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002'); + }); + + it('orders the Witness members as (to, facilitator, validAfter)', () => { + expect(PERMIT2_UPTO_TYPES.Witness.map((m) => m.name)).toEqual([ + 'to', + 'facilitator', + 'validAfter', + ]); + }); +}); + +describe('Permit2 typed-data signing', () => { + it('produces a signature recoverable to the signer', async () => { + const account = privateKeyToAccount(PK); + const auth = makePermit2Authorization({ + from: account.address, + asset: ASSET, + payTo: PAY_TO, + facilitator: FACILITATOR, + maxAmount: 500000n, + maxTimeoutSeconds: 120, + }); + expect(auth.spender).toBe(X402_UPTO_PERMIT2_PROXY); + expect(auth.permitted.amount).toBe(500000n); + + const signature = await signPermit2Authorization(account, { chainId: 84532, authorization: auth }); + + const recovered = await recoverTypedDataAddress({ + domain: { name: 'Permit2', chainId: 84532, verifyingContract: CANONICAL_PERMIT2_ADDRESS }, + types: PERMIT2_UPTO_TYPES, + primaryType: 'PermitWitnessTransferFrom', + message: { + permitted: { token: auth.permitted.token, amount: auth.permitted.amount }, + spender: auth.spender, + nonce: auth.nonce, + deadline: BigInt(auth.deadline), + witness: { + to: auth.witness.to, + facilitator: auth.witness.facilitator, + validAfter: BigInt(auth.witness.validAfter), + }, + }, + signature, + }); + expect(recovered.toLowerCase()).toBe(account.address.toLowerCase()); + }); +}); + +describe('upto payload encoding', () => { + it('emits PAYMENT-SIGNATURE with the spec-shaped permit2Authorization', async () => { + const account = privateKeyToAccount(PK); + const c = parseChallenge(V2_UPTO_CHALLENGE); + const handler = selectHandler(c.accepts[0], 84532)!; + const built = await handler.buildPayload(c.accepts[0], { + account, + chainId: 84532, + x402Version: 2, + assetName: 'USDC', + assetVersion: '2', + amount: 500000n, + url: 'https://api.example.com/r', + }); + + expect(built.headerName).toBe('payment-signature'); + const decoded = JSON.parse(Buffer.from(built.headerValueBase64, 'base64').toString('utf8')); + + expect(decoded.x402Version).toBe(2); + expect(decoded.resource.url).toBe('https://api.example.com/r'); + expect(decoded.accepted.scheme).toBe('upto'); + expect(decoded.accepted.amount).toBe('500000'); + // accepted.extra is reduced to {name, version}. + expect(decoded.accepted.extra).toEqual({ name: 'USDC', version: '2' }); + + const p = decoded.payload; + expect(p.signature).toMatch(/^0x[0-9a-f]+$/); + expect(p.permit2Authorization.permitted).toEqual({ token: ASSET, amount: '500000' }); + expect(p.permit2Authorization.spender).toBe(X402_UPTO_PERMIT2_PROXY); + expect(p.permit2Authorization.from).toBe(account.address); + expect(p.permit2Authorization.witness.to).toBe(PAY_TO); + expect(p.permit2Authorization.witness.facilitator).toBe(FACILITATOR); + // amounts/nonce/deadline/validAfter are JSON strings. + expect(typeof p.permit2Authorization.nonce).toBe('string'); + expect(typeof p.permit2Authorization.deadline).toBe('string'); + expect(typeof p.permit2Authorization.witness.validAfter).toBe('string'); + }); + + it('fails clearly when the 402 extra omits the facilitator address', async () => { + const account = privateKeyToAccount(PK); + const noFac = JSON.parse(JSON.stringify(V2_UPTO_CHALLENGE)); + delete noFac.accepts[0].extra.facilitatorAddress; + const c = parseChallenge(noFac); + const handler = selectHandler(c.accepts[0], 84532)!; + await expect( + handler.buildPayload(c.accepts[0], { + account, + chainId: 84532, + x402Version: 2, + assetName: 'USDC', + assetVersion: '2', + amount: 500000n, + url: 'https://api.example.com/r', + }), + ).rejects.toThrow(/facilitatorAddress/); + }); +});