From e8d54a056065deb0b7990e85995db5b101119a7a Mon Sep 17 00:00:00 2001 From: Nexory Date: Tue, 16 Jun 2026 13:55:44 +0200 Subject: [PATCH] fix(x402): validate server payment requirements in pay command before signing The pay CLI signed the server-supplied 402 payment requirements directly via signX402Payment without validation. Apply the existing validatePaymentRequirements guard (now exported from x402-payment) on the CLI path before signing: reject burn/zero payTo addresses and, with the new --max-amount flag, reject amounts above the caller's cap. Adds focused tests for the guard. --- .../validate-payment-requirements.test.ts | 42 +++++++++++++++++++ src/cli/commands/pay.ts | 24 ++++++++++- src/lib/client/x402-payment.ts | 2 +- 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/validate-payment-requirements.test.ts diff --git a/src/__tests__/validate-payment-requirements.test.ts b/src/__tests__/validate-payment-requirements.test.ts new file mode 100644 index 0000000..353d6c8 --- /dev/null +++ b/src/__tests__/validate-payment-requirements.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" +import { + type PaymentRequirements, + validatePaymentRequirements, +} from "../lib/client/x402-payment.js" + +const base: PaymentRequirements = { + scheme: "exact", + network: "base", + maxAmountRequired: "1000000", + payTo: "0x1111111111111111111111111111111111111111", + // USDC on Base; validatePaymentRequirements rejects any other asset when + // allowedAssets is not provided. + asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", +} + +describe("validatePaymentRequirements", () => { + it("passes for a well-formed requirement", () => { + expect(() => validatePaymentRequirements(base, {})).not.toThrow() + }) + + it("rejects a burn/zero payTo address", () => { + expect(() => + validatePaymentRequirements( + { ...base, payTo: "0x0000000000000000000000000000000000000000" }, + {}, + ), + ).toThrow(/burn\/zero address/) + }) + + it("rejects when the server amount exceeds maxAmount", () => { + expect(() => + validatePaymentRequirements(base, { maxAmount: "999999" }), + ).toThrow(/maxAmount/) + }) + + it("passes when the server amount is within maxAmount", () => { + expect(() => + validatePaymentRequirements(base, { maxAmount: "1000000" }), + ).not.toThrow() + }) +}) diff --git a/src/cli/commands/pay.ts b/src/cli/commands/pay.ts index 8d222f5..ae419f6 100644 --- a/src/cli/commands/pay.ts +++ b/src/cli/commands/pay.ts @@ -1,7 +1,10 @@ import { Command } from "commander" import pc from "picocolors" import type { PaymentRequirements } from "../../lib/client/x402-payment.js" -import { signX402Payment } from "../../lib/client/x402-payment.js" +import { + signX402Payment, + validatePaymentRequirements, +} from "../../lib/client/x402-payment.js" import { createWalletForProvider, createWalletFromEnv, @@ -14,6 +17,7 @@ import { readInput } from "./read-input.js" interface PayOptions { body?: string walletProvider?: string + maxAmount?: string } export const payCommand = new Command("pay") @@ -26,6 +30,10 @@ export const payCommand = new Command("pay") "--wallet-provider ", `Wallet provider: ${WALLET_PROVIDERS.join(", ")}`, ) + .option( + "--max-amount ", + "Maximum payment amount in atomic units the CLI will sign; the server's 402 requirements are rejected if they exceed it", + ) .action(async (url: string, options: PayOptions) => { let adapter: WalletAdapter try { @@ -65,13 +73,14 @@ export const payCommand = new Command("pay") process.exit(1) } - await runPaymentOnly(url, inputBody, adapter) + await runPaymentOnly(url, inputBody, adapter, options.maxAmount) }) async function runPaymentOnly( url: string, inputBody: string, adapter: WalletAdapter, + maxAmount?: string, ): Promise { console.log(pc.cyan("Probing endpoint for payment requirements...")) @@ -125,6 +134,17 @@ async function runPaymentOnly( console.log(` Pay To: ${requirements.payTo}`) console.log(` Asset: ${requirements.asset}`) + try { + validatePaymentRequirements(requirements, { maxAmount }) + } catch (err) { + console.error( + pc.red( + `Refusing to sign: ${err instanceof Error ? err.message : String(err)}`, + ), + ) + process.exit(1) + } + console.log(pc.cyan("\nSigning EIP-3009 transferWithAuthorization...")) const xPayment = await signX402Payment({ diff --git a/src/lib/client/x402-payment.ts b/src/lib/client/x402-payment.ts index 033363a..d67b19f 100644 --- a/src/lib/client/x402-payment.ts +++ b/src/lib/client/x402-payment.ts @@ -233,7 +233,7 @@ export async function paidFetch( return paidRes } -function validatePaymentRequirements( +export function validatePaymentRequirements( reqs: PaymentRequirements, opts: { maxAmount?: string