From 29f0cc52ed69be1caa50996780fcdd2d73416c93 Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 22 Oct 2026 16:46:18 +0200 Subject: [PATCH 1/5] feat(x402): sign experimental ERC-8409 quotes in x402 challenges --- docs/contract-surface.txt | 1 + docs/contracts.md | 6 + src/core/domain/payment.ts | 6 + src/core/execution/pipeline/admission.ts | 1 + src/payments/x402/payload.ts | 6 +- src/payments/x402/provider/index.ts | 13 + src/payments/x402/provider/quote.ts | 265 +++++++++++++ src/payments/x402/provider/requirement.ts | 9 + src/payments/x402/provider/types.ts | 9 + tests/unit/core/execution/pipeline.test.ts | 40 ++ tests/unit/payments-x402/quote.test.ts | 413 +++++++++++++++++++++ 11 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 src/payments/x402/provider/quote.ts create mode 100644 tests/unit/payments-x402/quote.test.ts diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index 418f031..d330f0c 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -302,6 +302,7 @@ interface PaymentChallenge { interface PaymentContext { readonly amount: string; readonly currency: string; + readonly input?: unknown; readonly metadata?: Readonly>; readonly requestId: string; readonly requestedAt: string; diff --git a/docs/contracts.md b/docs/contracts.md index 21b3e5b..ca07bbb 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -175,6 +175,10 @@ persistence must not fail the commerce flow. envelope fields are deprecated: read individual offers in `accepts`. With `strictNullChecks`, callers passing either `destination` as a required `string` must now handle `undefined`. +- Optional `PaymentContext.input` carries validated resource input to + `createRequirement`, allowing a rail to bind its challenge to the request. + x402's ERC-8409 quotes hash this input with the resource ID. A rail must not + send the input to a third party. Providers that ignore the field are unaffected. ### Protocols and gateway @@ -434,6 +438,8 @@ export interface X402ProviderOptions extends X402OfferOptions { readonly customNetworks?: Readonly< Record >; + /** Experimental: signs ERC-8409 quotes for EVM offers with a dedicated key when request data can be canonicalized */ + readonly quote?: { readonly signerPrivateKey: string }; readonly logger?: Logger; readonly clock?: Clock; readonly ids?: IdGenerator; diff --git a/src/core/domain/payment.ts b/src/core/domain/payment.ts index ef6ca38..26bd56d 100644 --- a/src/core/domain/payment.ts +++ b/src/core/domain/payment.ts @@ -105,6 +105,12 @@ export interface PaymentContext { readonly amount: DecimalAmount; readonly currency: string; readonly requestedAt: IsoTimestamp; + /** + * Validated resource input used to build the backend request. The pipeline + * sets this field on every createRequirement call. A rail may bind its + * challenge to the input but must not send it to a third party. + */ + readonly input?: unknown; readonly metadata?: Readonly>; } diff --git a/src/core/execution/pipeline/admission.ts b/src/core/execution/pipeline/admission.ts index 67bfe2e..f88bc98 100644 --- a/src/core/execution/pipeline/admission.ts +++ b/src/core/execution/pipeline/admission.ts @@ -64,6 +64,7 @@ export function createPaymentAdmission( amount: pricing.amount, currency: pricing.currency, requestedAt: options.clock.nowIso(), + input: validInput, // Transport facts a rail may bind into its challenge, such as a body digest ...(request.metadata !== undefined ? { metadata: request.metadata } : {}), }); diff --git a/src/payments/x402/payload.ts b/src/payments/x402/payload.ts index dc0ea0c..eda7373 100644 --- a/src/payments/x402/payload.ts +++ b/src/payments/x402/payload.ts @@ -31,9 +31,9 @@ export function declaredX402Version(payloadBase64: string): unknown { * The payload as sent to a facilitator. `resource` is optional in v2 and * verify() has already matched it against the offer; hosted facilitators have * settled payloads without it, and none has been shown to accept this - * gateway's `resource://` URL. `extensions` is dropped because the gateway - * advertises none, and the SDK facilitator would act on a gas-sponsoring - * extension in a Permit2 payload. + * gateway's `resource://` URL. `extensions` is dropped because quote echoes + * are not used to verify payment, and a gas-sponsoring extension in a Permit2 + * payload would activate unsupported SDK facilitator behavior. */ export function forFacilitator( payload: T, diff --git a/src/payments/x402/provider/index.ts b/src/payments/x402/provider/index.ts index c0a54e3..0bd5b5c 100644 --- a/src/payments/x402/provider/index.ts +++ b/src/payments/x402/provider/index.ts @@ -17,6 +17,7 @@ import { DEFAULT_IDS, DEFAULT_MAX_TIMEOUT_SECONDS, X402_VERSION } from './consta import { createSvmFeePayerSource } from './fee-payer'; import { createHealthCheck } from './health'; import { resolveOffers } from './offers'; +import { createQuoteIssuer } from './quote'; import { createRequirementBuilders } from './requirement'; import { createSettle } from './settle'; import type { @@ -63,6 +64,16 @@ export function createX402PaymentProvider( const logger = options.logger ?? NOOP_LOGGER; const maxTimeoutSeconds = options.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS; + const quotes = options.quote + ? createQuoteIssuer({ + signerPrivateKey: options.quote.signerPrivateKey, + offers, + deploymentMode, + clock, + maxTimeoutSeconds, + }) + : undefined; + const evmOffers = offers.filter((offer): offer is EvmOffer => offer.family === 'evm'); const hasSvm = offers.some((offer) => offer.family === 'svm'); const hasNear = offers.some((offer) => offer.family === 'near'); @@ -87,6 +98,7 @@ export function createX402PaymentProvider( 'authorization-flow', 'upfront-flow', ...(severalOffers ? ['multiple-offers'] : []), + ...(quotes ? ['erc-8409-quotes'] : []), ], status: 'stable', unsupported: [ @@ -126,6 +138,7 @@ export function createX402PaymentProvider( ids, logger, maxTimeoutSeconds, + quotes, svm, near, }; diff --git a/src/payments/x402/provider/quote.ts b/src/payments/x402/provider/quote.ts new file mode 100644 index 0000000..5398c7b --- /dev/null +++ b/src/payments/x402/provider/quote.ts @@ -0,0 +1,265 @@ +/** + * Issues experimental ERC-8409 quotes when the provider enables `quote`. + * + * For requests that can be canonicalized, `extensions["erc-8409"]` carries + * one quote per EVM offer: chain, token, payee and amount in base units. + * A dedicated issuer key signs the terms and request hash with EIP-712. + * Quotes are informational; verify() and settle() use the offer without + * reading the quotes. + */ +import { randomBytes } from 'node:crypto'; +import { type Hex, keccak256, stringToBytes } from 'viem'; +import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'; +import { type Clock, CommerceError } from '../../../core'; +import { isWellKnownDevKey } from '../dev-key-guard'; +import type { DeploymentMode } from '../networks'; +import type { EvmOffer, Offer } from './types'; + +/** Key for quotes in `PaymentRequired.extensions` */ +export const QUOTE_EXTENSION = 'erc-8409'; + +/** + * Request scheme for Keccak-256 hashes of the RFC 8785 (JCS) serialization of + * `{ "input": , "resource": }`. The input is the validated + * resource input used to build the backend request. The resource ID identifies + * the operation and also names the MCP tool and A2A skill. + */ +export const QUOTE_REQUEST_SCHEME = 'agent-commerce/request/v1'; + +/** The ERC's EIP-712 domain, with the chain ID added per quote */ +export const QUOTE_DOMAIN = { name: 'Signed Service Payment Quotes', version: '1' } as const; + +/** The ERC's EIP-712 type; field order is part of the type hash */ +export const QUOTE_TYPES = { + ServicePaymentQuote: [ + { name: 'issuer', type: 'address' }, + { name: 'payer', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'asset', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'requestScheme', type: 'string' }, + { name: 'requestHash', type: 'bytes32' }, + { name: 'quoteId', type: 'bytes32' }, + { name: 'validAfter', type: 'uint64' }, + { name: 'validUntil', type: 'uint64' }, + ], +} as const; + +// A zero payer makes a bearer quote; the payment signature identifies the payer +const BEARER = '0x0000000000000000000000000000000000000000'; + +// The JSON Schema x402 v2 requires beside each extension's `info` +const QUOTE_INFO_SCHEMA = { + type: 'object', + required: ['quotes'], + properties: { + quotes: { + type: 'array', + items: { + type: 'object', + required: ['chainId', 'quote', 'signature'], + properties: { + chainId: { type: 'string' }, + // Every field is a string in the ERC's JSON encoding + quote: { + type: 'object', + required: QUOTE_TYPES.ServicePaymentQuote.map((field) => field.name), + properties: Object.fromEntries( + QUOTE_TYPES.ServicePaymentQuote.map((field) => [field.name, { type: 'string' }]), + ), + }, + signature: { type: 'string' }, + }, + }, + }, + }, +} as const; + +// Bound cache memory by evicting the oldest quote when the limit is reached +const QUOTE_CACHE_LIMIT = 1024; + +/** A quote in the ERC's JSON format, with integers as decimal strings */ +export interface SignedQuote { + readonly chainId: string; + readonly quote: { + readonly issuer: Hex; + readonly payer: Hex; + readonly recipient: Hex; + readonly asset: Hex; + readonly amount: string; + readonly requestScheme: string; + readonly requestHash: Hex; + readonly quoteId: Hex; + readonly validAfter: string; + readonly validUntil: string; + }; + readonly signature: Hex; +} + +/** An EVM challenge offer and its `accepts` amount in base units */ +export interface QuotedOffer { + readonly offer: EvmOffer; + readonly amount: string; +} + +export interface QuoteIssuer { + readonly issuer: Hex; + /** + * Challenge extensions, or undefined when there are no EVM offers, input is + * missing, or canonicalJson rejects the request + */ + readonly extensionsFor: ( + resourceId: string, + input: unknown, + offers: readonly QuotedOffer[], + ) => Promise | undefined>; +} + +/** + * Creates the issuer during provider construction. Rejects an invalid key, + * a well-known Anvil key outside the local chain, a key matching an EVM + * `payTo`, or a configuration without an EVM offer. + */ +export function createQuoteIssuer(options: { + readonly signerPrivateKey: string; + readonly offers: readonly Offer[]; + readonly deploymentMode: DeploymentMode; + readonly clock: Clock; + readonly maxTimeoutSeconds: number; +}): QuoteIssuer { + const { signerPrivateKey, offers, deploymentMode, clock, maxTimeoutSeconds } = options; + let account: PrivateKeyAccount; + try { + account = privateKeyToAccount(signerPrivateKey as Hex); + } catch (cause) { + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: quote.signerPrivateKey is not a valid private key (expected "0x" followed by 64 hex characters)', + { cause }, + ); + } + if (deploymentMode !== 'local' && isWellKnownDevKey(signerPrivateKey)) { + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: quote.signerPrivateKey is a well-known Anvil development key, so anyone could sign quotes as this gateway. Use a dedicated key outside the local chain.', + ); + } + const evmOffers = offers.filter((offer): offer is EvmOffer => offer.family === 'evm'); + if (evmOffers.length === 0) { + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: quote needs an EVM offer; ERC-8409 quotes cover EVM networks only', + ); + } + const issuer = account.address; + if (evmOffers.some((offer) => offer.payTo.toLowerCase() === issuer.toLowerCase())) { + throw new CommerceError( + 'CONFIG_INVALID', + 'x402 provider: quote.signerPrivateKey controls a payTo address. Use a dedicated key, not the merchant wallet.', + ); + } + + const cache = new Map(); + + async function quoteFor(offer: EvmOffer, amount: string, requestHash: Hex): Promise { + const now = Math.floor(clock.now().getTime() / 1000); + const { chainId } = offer.profile; + const key = [ + chainId, + offer.payTo.toLowerCase(), + offer.asset.toLowerCase(), + amount, + requestHash, + ].join('|'); + const cached = cache.get(key); + // Reuse a cached quote while a full payment window remains to avoid + // signing retries; a cache miss requires a new signature + if (cached !== undefined && Number(cached.quote.validUntil) - now >= maxTimeoutSeconds) { + return cached; + } + const validUntil = now + 2 * maxTimeoutSeconds; + const quote = { + issuer, + payer: BEARER, + recipient: offer.payTo, + asset: offer.asset, + amount, + requestScheme: QUOTE_REQUEST_SCHEME, + requestHash, + // Use a new ID for each signed body, as the ERC requires + quoteId: `0x${randomBytes(32).toString('hex')}`, + validAfter: String(now), + validUntil: String(validUntil), + } as const; + const signature = await account.signTypedData({ + domain: { ...QUOTE_DOMAIN, chainId }, + types: QUOTE_TYPES, + primaryType: 'ServicePaymentQuote', + message: { + ...quote, + amount: BigInt(amount), + validAfter: BigInt(now), + validUntil: BigInt(validUntil), + }, + }); + const signed: SignedQuote = { chainId: String(chainId), quote, signature }; + cache.delete(key); + if (cache.size >= QUOTE_CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, signed); + return signed; + } + + return { + issuer, + async extensionsFor(resourceId, input, quoted) { + if (quoted.length === 0) return undefined; + // Missing or non-canonicalizable input still gets a payment challenge + // without quotes + const requestHash = quoteRequestHash(resourceId, input); + if (requestHash === undefined) return undefined; + const quotes: SignedQuote[] = []; + for (const { offer, amount } of quoted) { + quotes.push(await quoteFor(offer, amount, requestHash)); + } + return { [QUOTE_EXTENSION]: { info: { quotes }, schema: QUOTE_INFO_SCHEMA } }; + }, + }; +} + +// With the `u` flag, a valid surrogate pair is one code point, so this pattern +// matches only lone surrogates +const LONE_SURROGATE = /\p{Cs}/u; + +/** Hash under {@link QUOTE_REQUEST_SCHEME}, or undefined when canonicalJson rejects the request */ +export function quoteRequestHash(resourceId: string, input: unknown): Hex | undefined { + const canonical = canonicalJson({ input, resource: resourceId }); + return canonical === undefined ? undefined : keccak256(stringToBytes(canonical)); +} + +/** + * Serializes JSON data using RFC 8785 (JCS) rules. Non-finite numbers, lone + * surrogates and unsupported primitive values return undefined. + * JSON.stringify supplies number formatting and string escaping; the default + * sort orders keys by UTF-16 code units. + */ +export function canonicalJson(value: unknown): string | undefined { + if (typeof value === 'string') + return LONE_SURROGATE.test(value) ? undefined : JSON.stringify(value); + if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : undefined; + if (value === null || typeof value === 'boolean') return JSON.stringify(value); + if (Array.isArray(value)) { + const items = value.map(canonicalJson); + return items.includes(undefined) ? undefined : `[${items.join(',')}]`; + } + if (typeof value !== 'object') return undefined; + const members: string[] = []; + for (const key of Object.keys(value).sort()) { + const member = canonicalJson((value as Record)[key]); + if (member === undefined || LONE_SURROGATE.test(key)) return undefined; + members.push(`${JSON.stringify(key)}:${member}`); + } + return `{${members.join(',')}}`; +} diff --git a/src/payments/x402/provider/requirement.ts b/src/payments/x402/provider/requirement.ts index 20c5bf1..1f38382 100644 --- a/src/payments/x402/provider/requirement.ts +++ b/src/payments/x402/provider/requirement.ts @@ -99,6 +99,14 @@ export function createRequirementBuilders( })), ); const single = offers.length === 1 ? offers[0] : undefined; + const extensions = await ctx.quotes?.extensionsFor( + context.resource.id, + context.input, + offers.flatMap((offer, index) => { + const accepted = accepts[index]; + return offer.family === 'evm' && accepted ? [{ offer, amount: accepted.amount }] : []; + }), + ); // The v2 challenge document as sent on the wire. The HTTP adapter // base64-encodes it into `PAYMENT-REQUIRED` and MCP passes it through, so @@ -120,6 +128,7 @@ export function createRequirementBuilders( mimeType: options.resourceMimeTypes?.[context.resource.id] ?? DEFAULT_MIME_TYPE, }, accepts, + ...(extensions !== undefined ? { extensions } : {}), }; const expiresAt = new Date(clock.now().getTime() + maxTimeoutSeconds * 1000).toISOString(); diff --git a/src/payments/x402/provider/types.ts b/src/payments/x402/provider/types.ts index 7990f7f..5be21da 100644 --- a/src/payments/x402/provider/types.ts +++ b/src/payments/x402/provider/types.ts @@ -7,6 +7,7 @@ import type { X402FacilitatorConfig } from '../guardrails'; import type { CustomNetworks, DeploymentMode, X402Network } from '../networks'; import type { X402AssetTransferMethod } from '../options'; import type { X402PaymentFlow } from '../payment-flow'; +import type { QuoteIssuer } from './quote'; /** A single offer and the settings every offer shares */ export interface X402ProviderOptions extends X402OfferOptions { @@ -45,6 +46,12 @@ export interface X402ProviderOptions extends X402OfferOptions { * its `kind` determines mainnet guardrails */ readonly customNetworks?: CustomNetworks; + /** + * Experimental. Signs an ERC-8409 quote for each EVM offer when the request + * data can be canonicalized. Use a dedicated key that holds no funds. + * Never use a merchant's or buyer's wallet key. + */ + readonly quote?: { readonly signerPrivateKey: string }; readonly logger?: Logger; readonly clock?: Clock; readonly ids?: IdGenerator; @@ -141,6 +148,8 @@ export interface ProviderContext { readonly ids: IdGenerator; readonly logger: Logger; readonly maxTimeoutSeconds: number; + /** Present when the provider issues ERC-8409 quotes */ + readonly quotes: QuoteIssuer | undefined; /** The Solana inspection module; rejects when no Solana offer is configured */ readonly svm: () => Promise; /** The NEAR inspection module; rejects when no NEAR offer is configured */ diff --git a/tests/unit/core/execution/pipeline.test.ts b/tests/unit/core/execution/pipeline.test.ts index 7553f01..4624f2b 100644 --- a/tests/unit/core/execution/pipeline.test.ts +++ b/tests/unit/core/execution/pipeline.test.ts @@ -199,6 +199,46 @@ describe('createExecutionPipeline', () => { expect(createRequirementCalled).toBe(false); }); + it('passes the validated input to createRequirement, so a rail can bind its challenge to it', async () => { + const resource = makeResource({ + id: 'res-1', + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + paymentMethods: ['x402'], + inputSchema: { type: 'object', properties: { city: { type: 'string' } } }, + }); + const inputs: unknown[] = []; + const provider = createFakePaymentProvider({ + createRequirement: async (ctx) => { + inputs.push(ctx.input); + return { + id: 'r', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + challenge: { provider: 'x402', version: '1', accepts: [] }, + }; + }, + }); + const store = createFakeStore(); + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([resource]), + paymentProviders: [provider], + store, + backend: createFakeBackendExecutor(), + events: store, + logger: createCapturingLogger(), + clock: createFakeClock(), + ids: createFakeIdGenerator(), + }); + + const outcome = await pipeline.execute(makeRequest({ input: { city: 'Paris' } })); + + expect(outcome.kind).toBe('payment-required'); + expect(inputs).toEqual([{ city: 'Paris' }]); + }); + it('throws INPUT_INVALID for input that fails the resource schema', async () => { const resource = makeResource({ id: 'res-1', diff --git a/tests/unit/payments-x402/quote.test.ts b/tests/unit/payments-x402/quote.test.ts new file mode 100644 index 0000000..84b129d --- /dev/null +++ b/tests/unit/payments-x402/quote.test.ts @@ -0,0 +1,413 @@ +// Checks the ERC-8409 test vector, RFC 8785 request serialization, challenge +// quotes, cache reuse and issuer key validation +import Ajv2020 from 'ajv/dist/2020.js'; +import canonicalize from 'canonicalize'; +import { type Hex, hashTypedData, keccak256, recoverTypedDataAddress, stringToBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Clock, CommerceResource, PaymentContext } from '../../../src/core'; +import { ANVIL_WELL_KNOWN_ACCOUNTS } from '../../../src/payments/x402/local-chain/accounts'; +import { + createX402PaymentProvider, + type X402MultiOfferProviderOptions, + type X402OfferOptions, +} from '../../../src/payments/x402/provider'; +import { + canonicalJson, + QUOTE_DOMAIN, + QUOTE_TYPES, + quoteRequestHash, +} from '../../../src/payments/x402/provider/quote'; + +// Test-only key, never funded +const QUOTE_KEY = `0x${'33'.repeat(32)}` as const; +const ISSUER = privateKeyToAccount(QUOTE_KEY).address; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; + +const PAY_TO = `0x${'ab'.repeat(20)}`; +const BASE_SEPOLIA: X402OfferOptions = { + network: 'eip155:84532', + rpcUrl: 'https://base-sepolia.example', + asset: '0x036cbd53842c5426634e7929541ec2318f3dcf7e', + assetName: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: PAY_TO, +}; +const ARBITRUM_SEPOLIA: X402OfferOptions = { + ...BASE_SEPOLIA, + network: 'eip155:421614', + rpcUrl: 'https://arbitrum-sepolia.example', + asset: '0x75faf114eafb1bdbe2f0316df893fd58ce46aa4d', + assetName: 'USD Coin', +}; +const NEAR_TESTNET: X402OfferOptions = { + network: 'near:testnet', + rpcUrl: 'https://near-testnet.example', + asset: '3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af', + assetDecimals: 6, + payTo: 'merchant.testnet', +}; + +const T0 = Date.parse('2026-10-18T12:00:00.000Z') / 1000; +let nowSeconds = T0; +const clock: Clock = { + now: () => new Date(nowSeconds * 1000), + nowIso: () => new Date(nowSeconds * 1000).toISOString() as ReturnType, + monotonicMs: () => nowSeconds * 1000, +}; +beforeEach(() => { + nowSeconds = T0; +}); + +function makeProvider(overrides: Partial = {}) { + return createX402PaymentProvider({ + offers: [BASE_SEPOLIA, ARBITRUM_SEPOLIA, NEAR_TESTNET], + facilitator: { mode: 'remote', url: 'https://facilitator.example', auth: { type: 'none' } }, + quote: { signerPrivateKey: QUOTE_KEY }, + clock, + ...overrides, + }); +} + +const RESOURCE: CommerceResource = { + id: 'demo.report', + name: 'Demo report', + handler: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USD' }, + exposedVia: ['http'], + paymentMethods: ['x402'], +}; + +function paymentContext(overrides: Partial = {}): PaymentContext { + return { + requestId: 'req-1', + resource: RESOURCE, + amount: '0.01', + currency: 'USD', + requestedAt: new Date(nowSeconds * 1000).toISOString(), + input: { city: 'Paris' }, + ...overrides, + }; +} + +interface WireQuote { + readonly chainId: string; + readonly quote: Record; + readonly signature: Hex; +} + +async function challengeOf(provider: ReturnType, context = paymentContext()) { + const requirement = await provider.createRequirement(context); + return requirement.challenge.envelope as Record & { + readonly accepts: readonly unknown[]; + readonly extensions?: { readonly 'erc-8409': { readonly info: { quotes: WireQuote[] } } }; + }; +} + +async function quotesOf(provider: ReturnType, context = paymentContext()) { + const quotes = (await challengeOf(provider, context)).extensions?.['erc-8409'].info.quotes; + if (quotes === undefined) throw new Error('the challenge lists no quotes'); + return quotes; +} + +function recoverIssuer({ chainId, quote, signature }: WireQuote) { + return recoverTypedDataAddress({ + domain: { ...QUOTE_DOMAIN, chainId: Number(chainId) }, + types: QUOTE_TYPES, + primaryType: 'ServicePaymentQuote', + message: { + ...quote, + amount: BigInt(quote['amount'] as string), + validAfter: BigInt(quote['validAfter'] as string), + validUntil: BigInt(quote['validUntil'] as string), + } as never, + signature, + }); +} + +describe('ERC-8409 typed data', () => { + // The canonical vector of ERCS/erc-8409.md at the pinned commit + const VECTOR = { + domain: { ...QUOTE_DOMAIN, chainId: 8453 }, + types: QUOTE_TYPES, + primaryType: 'ServicePaymentQuote', + message: { + issuer: '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', + payer: '0x2222222222222222222222222222222222222222', + recipient: '0x3333333333333333333333333333333333333333', + asset: '0x4444444444444444444444444444444444444444', + amount: 1000000n, + requestScheme: 'com.example.service/1', + requestHash: `0x${'a'.repeat(64)}`, + quoteId: `0x${'b'.repeat(64)}`, + validAfter: 1788372000n, + validUntil: 1788372300n, + }, + } as const; + + it('reproduces the vector digest', () => { + expect(hashTypedData(VECTOR)).toBe( + '0xc80f6a3da4ad8d3636a7e4de909f46180ef8c3af67cf46f55736632c4a93fc65', + ); + }); + + it('reproduces the vector signature with its public test key', async () => { + const account = privateKeyToAccount(`0x${'0'.repeat(63)}1`); + expect(await account.signTypedData(VECTOR)).toBe( + '0x2051fd043967b356866060fb9bf95e11b735f03cab03d0f7f6c736abbdf55a42311f85b1f7f34e4f1008a494fb10aed2965589f96fca3f99c2bb72994ab345e41c', + ); + }); +}); + +describe('canonicalJson', () => { + it('orders keys by UTF-16 code units, as the RFC 8785 sorting example does', () => { + const input = { + '€': 'Euro Sign', + '\r': 'Carriage Return', + דּ: 'Hebrew Letter Dalet With Dagesh', + '1': 'One', + '😀': 'Emoji: Grinning Face', + '\u0080': 'Control', + ö: 'Latin Small Letter O With Diaeresis', + }; + expect(canonicalJson(input)).toBe( + '{"\\r":"Carriage Return","1":"One","\u0080":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😀":"Emoji: Grinning Face","דּ":"Hebrew Letter Dalet With Dagesh"}', + ); + }); + + it('formats numbers as RFC 8785 appendix B does', () => { + const numbers = [0, -0, 5e-324, 1.7976931348623157e308, 9007199254740992, 1e21, 1e-6, 1e-7]; + expect(canonicalJson(numbers)).toBe( + '[0,0,5e-324,1.7976931348623157e+308,9007199254740992,1e+21,0.000001,1e-7]', + ); + }); + + it.each([ + ['nested containers', { b: [1, { d: null, c: true }], a: {}, e: [] }], + ['escapes and non-ASCII', { s: '\u0000\b\f\n\r\t"\\/\u001f
é😀' }], + ['numbers', { n: [0.1, 0.30000000000000004, -1.5e-10, 123456789012345680000, 1e300] }], + ['a scalar', 'plain'], + ])('matches the canonicalize reference for %s', (_name, value) => { + expect(canonicalJson(value)).toBe(canonicalize(value)); + }); + + it.each([ + ['a lone surrogate in a string', { s: 'a\ud800b' }], + ['a lone surrogate in a key', { '\udc00': 1 }], + ['a lone surrogate in an array', ['ok', '\ud800']], + ['an infinite number', { n: Number.POSITIVE_INFINITY }], + ['NaN', [Number.NaN]], + ])('returns undefined for %s, outside what RFC 8785 serializes', (_name, value) => { + expect(() => canonicalize(value)).toThrow(); + expect(canonicalJson(value)).toBeUndefined(); + }); +}); + +describe('quoteRequestHash', () => { + it('hashes the canonical {input, resource} object with Keccak-256', () => { + expect(quoteRequestHash('demo.report', { city: 'Paris', days: [1, 2] })).toBe( + keccak256(stringToBytes('{"input":{"city":"Paris","days":[1,2]},"resource":"demo.report"}')), + ); + }); +}); + +describe('challenge quotes', () => { + const PARIS_HASH = keccak256( + stringToBytes('{"input":{"city":"Paris"},"resource":"demo.report"}'), + ); + + it("lists one quote per EVM offer, carrying that offer's terms", async () => { + const quotes = await quotesOf(makeProvider()); + + const terms = (chainId: string, asset: string) => ({ + chainId, + quote: { + issuer: ISSUER, + payer: ZERO_ADDRESS, + recipient: PAY_TO, + asset, + amount: '10000', + requestScheme: 'agent-commerce/request/v1', + requestHash: PARIS_HASH, + quoteId: expect.stringMatching(/^0x[0-9a-f]{64}$/), + validAfter: String(T0), + validUntil: String(T0 + 120), + }, + signature: expect.any(String), + }); + // NEAR offers get no quotes because the ERC covers EVM chains only + expect(quotes).toEqual([ + terms('84532', BASE_SEPOLIA.asset), + terms('421614', ARBITRUM_SEPOLIA.asset), + ]); + }); + + it("signs each quote with the issuer key over the ERC's typed data", async () => { + for (const quote of await quotesOf(makeProvider())) { + expect(await recoverIssuer(quote)).toBe(ISSUER); + } + }); + + it('encodes signatures as the ERC requires: 65 bytes, low s, v of 27 or 28', async () => { + for (const { signature } of await quotesOf(makeProvider())) { + expect(signature).toMatch(/^0x[0-9a-f]{130}$/); + const s = BigInt(`0x${signature.slice(66, 130)}`); + expect(s <= SECP256K1_N / 2n).toBe(true); + expect(['1b', '1c']).toContain(signature.slice(130)); + } + }); + + it('binds quotes to the input, with a distinct quote id per offer and input', async () => { + const provider = makeProvider(); + const paris = await quotesOf(provider); + const rome = await quotesOf(provider, paymentContext({ input: { city: 'Rome' } })); + + expect(rome[0]?.quote['requestHash']).toBe( + keccak256(stringToBytes('{"input":{"city":"Rome"},"resource":"demo.report"}')), + ); + const ids = [...paris, ...rome].map(({ quote }) => quote['quoteId']); + expect(new Set(ids).size).toBe(4); + }); + + it('declares a JSON Schema beside the info, as x402 v2 requires, and the info satisfies it', async () => { + const extension = ( + (await challengeOf(makeProvider())).extensions as unknown as Record< + string, + Record + > + )['erc-8409']; + expect(Object.keys(extension ?? {}).sort()).toEqual(['info', 'schema']); + const validate = new Ajv2020({ strict: true }).compile(extension?.['schema'] as object); + expect(validate(extension?.['info'])).toBe(true); + expect(validate({ quotes: [{ chainId: '84532', quote: {}, signature: '0x' }] })).toBe(false); + }); + + it('names the capability in the descriptor', () => { + expect(makeProvider().descriptor.capabilities).toContain('erc-8409-quotes'); + }); + + it('leaves the challenge without extensions when quote is not configured', async () => { + const provider = createX402PaymentProvider({ + offers: [BASE_SEPOLIA], + facilitator: { mode: 'remote', url: 'https://facilitator.example', auth: { type: 'none' } }, + clock, + }); + + const envelope = (await provider.createRequirement(paymentContext())).challenge.envelope; + expect(Object.keys(envelope ?? {})).toEqual(['x402Version', 'resource', 'accepts']); + expect(provider.descriptor.capabilities).not.toContain('erc-8409-quotes'); + }); + + it('lists no quotes for a context without input', async () => { + const { input: _input, ...withoutInput } = paymentContext(); + const envelope = await challengeOf(makeProvider(), withoutInput); + expect(envelope).not.toHaveProperty('extensions'); + expect(envelope.accepts).toHaveLength(3); + }); + + it('still offers payment, without quotes, for input RFC 8785 cannot represent', async () => { + const envelope = await challengeOf( + makeProvider(), + paymentContext({ input: { city: 'a\ud800b' } }), + ); + expect(envelope).not.toHaveProperty('extensions'); + expect(envelope.accepts).toHaveLength(3); + }); + + it('lists no quotes for a resource whose offers are all NEAR', async () => { + const provider = makeProvider({ resourceOffers: { 'near.only': [NEAR_TESTNET] } }); + const envelope = await challengeOf( + provider, + paymentContext({ resource: { ...RESOURCE, id: 'near.only' } }), + ); + expect(envelope).not.toHaveProperty('extensions'); + expect(envelope.accepts).toHaveLength(1); + }); +}); + +describe('quote reuse', () => { + // A 30-second payment window: quotes are valid for 60 seconds + const provider = () => makeProvider({ maxTimeoutSeconds: 30 }); + + it('serves the same quote while a full payment window remains', async () => { + const issuing = provider(); + const first = await quotesOf(issuing); + nowSeconds = T0 + 30; + expect(await quotesOf(issuing)).toEqual(first); + expect(first[0]?.quote['validUntil']).toBe(String(T0 + 60)); + }); + + it('signs a new quote once less than a payment window remains', async () => { + const issuing = provider(); + const first = await quotesOf(issuing); + nowSeconds = T0 + 31; + const second = await quotesOf(issuing); + + expect(second[0]?.quote['quoteId']).not.toBe(first[0]?.quote['quoteId']); + expect(second[0]?.quote['validAfter']).toBe(String(T0 + 31)); + expect(second[0]?.quote['validUntil']).toBe(String(T0 + 91)); + }); + + it('stops reusing the oldest quote after 1024 newer inputs', async () => { + const issuing = makeProvider({ offers: [BASE_SEPOLIA] }); + const forInput = (n: number) => quotesOf(issuing, paymentContext({ input: { n } })); + const oldest = await forInput(0); + for (let n = 1; n <= 1024; n += 1) await forInput(n); + const newest = await forInput(1024); + + expect((await forInput(0))[0]?.quote['quoteId']).not.toBe(oldest[0]?.quote['quoteId']); + expect(await forInput(1024)).toEqual(newest); + }); +}); + +describe('quote key checks at construction', () => { + it.each([ + ['too short', '0x1234'], + ['not hex', `0x${'zz'.repeat(32)}`], + ['missing its 0x prefix', '33'.repeat(32)], + ['zero', `0x${'00'.repeat(32)}`], + ])('refuses a key that is %s, without echoing it', (_name, key) => { + let thrown: unknown; + try { + makeProvider({ quote: { signerPrivateKey: key } }); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' }); + expect((thrown as Error).message).not.toContain(key); + }); + + it('refuses a well-known Anvil key on a testnet', () => { + const anvil = ANVIL_WELL_KNOWN_ACCOUNTS[0]; + expect(() => makeProvider({ quote: { signerPrivateKey: anvil.privateKey } })).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }), + ); + }); + + it('accepts a well-known Anvil key on the local chain', async () => { + const anvil = ANVIL_WELL_KNOWN_ACCOUNTS[0]; + const provider = makeProvider({ + offers: [{ ...BASE_SEPOLIA, network: 'eip155:31337', rpcUrl: 'http://127.0.0.1:8545' }], + facilitator: { mode: 'local', signerPrivateKey: ANVIL_WELL_KNOWN_ACCOUNTS[1].privateKey }, + quote: { signerPrivateKey: anvil.privateKey }, + }); + const [quote] = await quotesOf(provider); + expect(quote?.chainId).toBe('31337'); + expect(quote?.quote['issuer']).toBe(anvil.address); + }); + + it("refuses a key that controls a resource offer's payTo, whatever the case", () => { + const merchant = { ...ARBITRUM_SEPOLIA, payTo: ISSUER.toLowerCase() }; + expect(() => makeProvider({ resourceOffers: { 'demo.other': [merchant] } })).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }), + ); + }); + + it('refuses quotes for a provider without an EVM offer', () => { + expect(() => makeProvider({ offers: [NEAR_TESTNET] })).toThrow( + expect.objectContaining({ code: 'CONFIG_INVALID' }), + ); + }); +}); From 0b8553867f07500c66ce2e96c69076a2ac3f608d Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 22 Oct 2026 20:51:16 +0200 Subject: [PATCH 2/5] feat(x402): add quote configuration and issuer discovery --- src/config/schema/raw.ts | 8 ++++ src/config/schema/types.ts | 5 ++ src/config/schema/x402.ts | 14 ++++++ src/core/canonical-json.ts | 30 ++++++++++++ src/gateway/payment-providers.ts | 3 ++ src/gateway/well-known.ts | 27 +++++++++++ src/payments/x402/provider/index.ts | 23 +++++++-- src/payments/x402/provider/quote.ts | 30 +----------- src/payments/x402/provider/types.ts | 16 ++++++- tests/unit/config/x402-options.test.ts | 42 ++++++++++++++++ tests/unit/core/canonical-json.test.ts | 49 +++++++++++++++++++ tests/unit/gateway/payment-providers.test.ts | 35 ++++++++++++++ tests/unit/gateway/server.test.ts | 30 ++++++++++++ tests/unit/payments-x402/quote.test.ts | 50 +------------------- 14 files changed, 280 insertions(+), 82 deletions(-) create mode 100644 src/core/canonical-json.ts create mode 100644 tests/unit/core/canonical-json.test.ts diff --git a/src/config/schema/raw.ts b/src/config/schema/raw.ts index 768c7ef..5eaf88b 100644 --- a/src/config/schema/raw.ts +++ b/src/config/schema/raw.ts @@ -336,6 +336,14 @@ const X402Schema = z allowMainnet: BooleanOrString.optional(), // Accepts credential-free facilitator access on mainnet. Never defaulted allowUnauthenticatedFacilitator: BooleanOrString.optional(), + // Experimental ERC-8409 quotes; signing a quote transfers no funds + quote: z + .object({ + enabled: BooleanOrString, + signerPrivateKey: z.string().min(1).optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts index 959856d..7b05faf 100644 --- a/src/config/schema/types.ts +++ b/src/config/schema/types.ts @@ -104,6 +104,11 @@ export interface GatewayConfig { * Absent when no resource overrides its offers. */ readonly resourceOptions?: Readonly>; + /** + * Experimental ERC-8409 quotes. Config loading includes this field only + * when `quote.enabled` is true. The signing key must not hold funds. + */ + readonly quote?: { readonly signerPrivateKey: string }; }; readonly mpp?: { readonly enabled: boolean; diff --git a/src/config/schema/x402.ts b/src/config/schema/x402.ts index 2703b64..1923a14 100644 --- a/src/config/schema/x402.ts +++ b/src/config/schema/x402.ts @@ -251,6 +251,19 @@ export function normalizeX402(raw: RawX402, resources: RawConfig['resources']): Object.entries(resourceTerms).map(([id, terms]) => [id, terms.options]), ); + // The provider validates the private key and derives the issuer at construction, + // keeping viem out of config loading + let quote: X402Config['quote']; + if (raw.quote !== undefined && toBoolean(raw.quote.enabled, `${at}.quote.enabled`)) { + if (raw.quote.signerPrivateKey === undefined) { + throw x402Invalid( + `${at}.quote.signerPrivateKey is required when quotes are enabled`, + `${at}.quote.signerPrivateKey`, + ); + } + quote = { signerPrivateKey: raw.quote.signerPrivateKey }; + } + const config: X402Config = { enabled: toBoolean(raw.enabled, `${at}.enabled`), ...first, @@ -262,6 +275,7 @@ export function normalizeX402(raw: RawX402, resources: RawConfig['resources']): ...(networks !== undefined ? { networks } : {}), options, ...(Object.keys(resourceOptions).length > 0 ? { resourceOptions } : {}), + ...(quote !== undefined ? { quote } : {}), }; return { config, ...rail, resources: resourceTerms }; diff --git a/src/core/canonical-json.ts b/src/core/canonical-json.ts new file mode 100644 index 0000000..efe05b6 --- /dev/null +++ b/src/core/canonical-json.ts @@ -0,0 +1,30 @@ +// RFC 8785 (JCS) serialization for request hashes + +// With the `u` flag, a valid surrogate pair is one code point, so this pattern +// matches only lone surrogates +const LONE_SURROGATE = /\p{Cs}/u; + +/** + * Serializes JSON data using RFC 8785 (JCS) rules. Non-finite numbers, lone + * surrogates and unsupported primitive values return undefined. + * JSON.stringify supplies number formatting and string escaping; the default + * sort orders keys by UTF-16 code units. + */ +export function canonicalJson(value: unknown): string | undefined { + if (typeof value === 'string') + return LONE_SURROGATE.test(value) ? undefined : JSON.stringify(value); + if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : undefined; + if (value === null || typeof value === 'boolean') return JSON.stringify(value); + if (Array.isArray(value)) { + const items = value.map(canonicalJson); + return items.includes(undefined) ? undefined : `[${items.join(',')}]`; + } + if (typeof value !== 'object') return undefined; + const members: string[] = []; + for (const key of Object.keys(value).sort()) { + const member = canonicalJson((value as Record)[key]); + if (member === undefined || LONE_SURROGATE.test(key)) return undefined; + members.push(`${JSON.stringify(key)}:${member}`); + } + return `{${members.join(',')}}`; +} diff --git a/src/gateway/payment-providers.ts b/src/gateway/payment-providers.ts index be93a22..e90b4a7 100644 --- a/src/gateway/payment-providers.ts +++ b/src/gateway/payment-providers.ts @@ -64,6 +64,9 @@ export async function createConfiguredPaymentProviders( ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } : {}), ...(x402.networks !== undefined ? { customNetworks: x402.networks } : {}), + // Pass quote settings only to x402; MPP builds a separate settlement + // provider without quotes + ...(x402.quote !== undefined ? { quote: x402.quote } : {}), logger, }), ); diff --git a/src/gateway/well-known.ts b/src/gateway/well-known.ts index 8324ba1..965c1e8 100644 --- a/src/gateway/well-known.ts +++ b/src/gateway/well-known.ts @@ -28,6 +28,7 @@ import { } from '../payments/x402/networks'; import { x402OfferName } from '../payments/x402/options'; import { X402_DEFAULT_PAYMENT_FLOW, type X402PaymentFlow } from '../payments/x402/payment-flow'; +import type { X402PaymentProvider } from '../payments/x402/provider/types'; import { PACKAGE_VERSION } from '../version'; import type { ProbedAdapter, ProbedProvider } from './readiness'; @@ -88,6 +89,8 @@ export interface WellKnownDocument { readonly options?: readonly WellKnownX402Offer[]; /** Resource offer overrides by id, replacing the rail's offers */ readonly resourceOptions?: Readonly>; + /** Absent unless the x402 provider signs ERC-8409 quotes */ + readonly quote?: WellKnownX402Quote; }; /** Public settlement fields; excludes the challenge secret and facilitator credentials */ readonly mpp?: { @@ -121,6 +124,18 @@ export interface WellKnownX402Offer { readonly status?: AdapterHealth['status']; } +/** + * Public metadata for authenticating this provider's quotes. + * Verify each quote's signature against the advertised `issuer`. + */ +export interface WellKnownX402Quote { + readonly issuer: string; + readonly requestScheme: string; + /** The key in `PaymentRequired.extensions` that carries the quotes */ + readonly extension: string; + readonly status: 'experimental'; +} + export interface BuildWellKnownOptions { readonly config: GatewayConfig; readonly paymentProviders: readonly PaymentProvider[]; @@ -182,6 +197,16 @@ function flowOverrides(flows: Readonly> | undefi : {}; } +// Use the provider's quote metadata so discovery advertises its signing issuer +function publicQuote(providers: readonly PaymentProvider[]): WellKnownX402Quote | undefined { + const x402 = providers.find((provider) => provider.name === 'x402') as + | X402PaymentProvider + | undefined; + if (x402?.quote === undefined) return undefined; + const { issuer, requestScheme, extension } = x402.quote; + return { issuer, requestScheme, extension, status: 'experimental' }; +} + export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnownDocument { const adapters = options.adapters.map(({ descriptor, health }) => ({ ...descriptor, @@ -193,6 +218,7 @@ export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnow const offerStatuses = new Map( (x402Health?.components ?? []).map((component) => [component.name, component.status]), ); + const quote = publicQuote(options.paymentProviders); return { gateway: { @@ -241,6 +267,7 @@ export function buildWellKnownDocument(options: BuildWellKnownOptions): WellKnow ), } : {}), + ...(quote !== undefined ? { quote } : {}), }, } : {}), diff --git a/src/payments/x402/provider/index.ts b/src/payments/x402/provider/index.ts index 0bd5b5c..3aa01fd 100644 --- a/src/payments/x402/provider/index.ts +++ b/src/payments/x402/provider/index.ts @@ -10,14 +10,14 @@ * `../facilitator.ts`): the SDK's own `x402Facilitator` in this process, or a * remote HTTP facilitator. */ -import type { AdapterDescriptor, PaymentProvider } from '../../../core'; +import type { AdapterDescriptor } from '../../../core'; import { CommerceError, NOOP_LOGGER, systemClock } from '../../../core'; import { PACKAGE_VERSION } from '../../../version'; import { DEFAULT_IDS, DEFAULT_MAX_TIMEOUT_SECONDS, X402_VERSION } from './constants'; import { createSvmFeePayerSource } from './fee-payer'; import { createHealthCheck } from './health'; import { resolveOffers } from './offers'; -import { createQuoteIssuer } from './quote'; +import { createQuoteIssuer, QUOTE_EXTENSION, QUOTE_REQUEST_SCHEME } from './quote'; import { createRequirementBuilders } from './requirement'; import { createSettle } from './settle'; import type { @@ -25,16 +25,22 @@ import type { ProviderContext, X402MultiOfferProviderOptions, X402OfferOptions, + X402PaymentProvider, X402ProviderOptions, } from './types'; import { createVerify } from './verify'; export { DEFAULT_IDS } from './constants'; -export type { X402MultiOfferProviderOptions, X402OfferOptions, X402ProviderOptions } from './types'; +export type { + X402MultiOfferProviderOptions, + X402OfferOptions, + X402PaymentProvider, + X402ProviderOptions, +} from './types'; export function createX402PaymentProvider( options: X402ProviderOptions | X402MultiOfferProviderOptions, -): PaymentProvider { +): X402PaymentProvider { const inputs: readonly X402OfferOptions[] = 'offers' in options ? options.offers : [options]; if (inputs.length === 0) { throw new CommerceError('CONFIG_INVALID', 'x402 provider: "offers" is empty'); @@ -159,6 +165,15 @@ export function createX402PaymentProvider( verify, settle, health, + ...(quotes + ? { + quote: { + issuer: quotes.issuer, + requestScheme: QUOTE_REQUEST_SCHEME, + extension: QUOTE_EXTENSION, + }, + } + : {}), }; } diff --git a/src/payments/x402/provider/quote.ts b/src/payments/x402/provider/quote.ts index 5398c7b..026d262 100644 --- a/src/payments/x402/provider/quote.ts +++ b/src/payments/x402/provider/quote.ts @@ -11,6 +11,7 @@ import { randomBytes } from 'node:crypto'; import { type Hex, keccak256, stringToBytes } from 'viem'; import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'; import { type Clock, CommerceError } from '../../../core'; +import { canonicalJson } from '../../../core/canonical-json'; import { isWellKnownDevKey } from '../dev-key-guard'; import type { DeploymentMode } from '../networks'; import type { EvmOffer, Offer } from './types'; @@ -229,37 +230,8 @@ export function createQuoteIssuer(options: { }; } -// With the `u` flag, a valid surrogate pair is one code point, so this pattern -// matches only lone surrogates -const LONE_SURROGATE = /\p{Cs}/u; - /** Hash under {@link QUOTE_REQUEST_SCHEME}, or undefined when canonicalJson rejects the request */ export function quoteRequestHash(resourceId: string, input: unknown): Hex | undefined { const canonical = canonicalJson({ input, resource: resourceId }); return canonical === undefined ? undefined : keccak256(stringToBytes(canonical)); } - -/** - * Serializes JSON data using RFC 8785 (JCS) rules. Non-finite numbers, lone - * surrogates and unsupported primitive values return undefined. - * JSON.stringify supplies number formatting and string escaping; the default - * sort orders keys by UTF-16 code units. - */ -export function canonicalJson(value: unknown): string | undefined { - if (typeof value === 'string') - return LONE_SURROGATE.test(value) ? undefined : JSON.stringify(value); - if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : undefined; - if (value === null || typeof value === 'boolean') return JSON.stringify(value); - if (Array.isArray(value)) { - const items = value.map(canonicalJson); - return items.includes(undefined) ? undefined : `[${items.join(',')}]`; - } - if (typeof value !== 'object') return undefined; - const members: string[] = []; - for (const key of Object.keys(value).sort()) { - const member = canonicalJson((value as Record)[key]); - if (member === undefined || LONE_SURROGATE.test(key)) return undefined; - members.push(`${JSON.stringify(key)}:${member}`); - } - return `{${members.join(',')}}`; -} diff --git a/src/payments/x402/provider/types.ts b/src/payments/x402/provider/types.ts index 5be21da..02bf5ef 100644 --- a/src/payments/x402/provider/types.ts +++ b/src/payments/x402/provider/types.ts @@ -1,6 +1,6 @@ // Provider options, the offers resolved from them, and the state the modules share import type { Network } from '@x402/core/types'; -import type { Clock, IdGenerator, Logger } from '../../../core'; +import type { Clock, IdGenerator, Logger, PaymentProvider } from '../../../core'; import type { createLocalPublicClient } from '../chain'; import type { FacilitatorBinding } from '../facilitator'; import type { X402FacilitatorConfig } from '../guardrails'; @@ -57,6 +57,20 @@ export interface X402ProviderOptions extends X402OfferOptions { readonly ids?: IdGenerator; } +/** x402 payment provider with optional public quote metadata */ +export interface X402PaymentProvider extends PaymentProvider { + /** + * Experimental. Present when ERC-8409 quote signing is enabled. + * Publishes the issuer address for signature checks, the request scheme and + * the key under `PaymentRequired.extensions`. + */ + readonly quote?: { + readonly issuer: `0x${string}`; + readonly requestScheme: string; + readonly extension: string; + }; +} + /** Settlement network, token and merchant payee for one offer */ export interface X402OfferOptions { /** diff --git a/tests/unit/config/x402-options.test.ts b/tests/unit/config/x402-options.test.ts index 345390e..7a7e685 100644 --- a/tests/unit/config/x402-options.test.ts +++ b/tests/unit/config/x402-options.test.ts @@ -660,3 +660,45 @@ describe('resource offers (x402.accepts)', () => { ); }); }); + +describe('payments.x402.quote', () => { + // Test-only key, never funded + const QUOTE_KEY = `0x${'33'.repeat(32)}`; + const quoteOf = (quote: Record) => + parseConfig(withX402({ ...validX402(), quote }), {}).payments.x402?.quote; + + it('passes the key on when quotes are enabled, including through an env string', () => { + expect(quoteOf({ enabled: true, signerPrivateKey: QUOTE_KEY })).toEqual({ + signerPrivateKey: QUOTE_KEY, + }); + expect(quoteOf({ enabled: 'true', signerPrivateKey: QUOTE_KEY })).toEqual({ + signerPrivateKey: QUOTE_KEY, + }); + }); + + it('drops a disabled block, which needs no key', () => { + expect(quoteOf({ enabled: false, signerPrivateKey: QUOTE_KEY })).toBeUndefined(); + expect(quoteOf({ enabled: false })).toBeUndefined(); + expect(parseConfig(withX402(validX402()), {}).payments.x402).not.toHaveProperty('quote'); + }); + + it('refuses enabled quotes without a key, naming the path', () => { + const error = refusal(withX402({ ...validX402(), quote: { enabled: true } })); + expect(pathOf(error)).toBe('payments.x402.quote.signerPrivateKey'); + }); + + it('refuses an unknown field, without echoing the key', () => { + const error = refusal( + withX402({ ...validX402(), quote: { enabled: true, signerPrivateKey: QUOTE_KEY, ttl: 60 } }), + ); + expect(error.message).toContain('ttl'); + expect(JSON.stringify({ message: error.message, details: error.details })).not.toContain( + QUOTE_KEY, + ); + }); +}); + +// Complete x402 block from the config fixture +function validX402(): Record { + return { ...(validRawConfig()['payments'] as { x402: Record }).x402 }; +} diff --git a/tests/unit/core/canonical-json.test.ts b/tests/unit/core/canonical-json.test.ts new file mode 100644 index 0000000..a5f5461 --- /dev/null +++ b/tests/unit/core/canonical-json.test.ts @@ -0,0 +1,49 @@ +// Checks RFC 8785 serialization against the spec's examples and the +// canonicalize reference implementation +import canonicalize from 'canonicalize'; +import { describe, expect, it } from 'vitest'; +import { canonicalJson } from '../../../src/core/canonical-json'; + +describe('canonicalJson', () => { + it('orders keys by UTF-16 code units, as the RFC 8785 sorting example does', () => { + const input = { + '€': 'Euro Sign', + '\r': 'Carriage Return', + דּ: 'Hebrew Letter Dalet With Dagesh', + '1': 'One', + '😀': 'Emoji: Grinning Face', + '\u0080': 'Control', + ö: 'Latin Small Letter O With Diaeresis', + }; + expect(canonicalJson(input)).toBe( + '{"\\r":"Carriage Return","1":"One","\u0080":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😀":"Emoji: Grinning Face","דּ":"Hebrew Letter Dalet With Dagesh"}', + ); + }); + + it('formats numbers as RFC 8785 appendix B does', () => { + const numbers = [0, -0, 5e-324, 1.7976931348623157e308, 9007199254740992, 1e21, 1e-6, 1e-7]; + expect(canonicalJson(numbers)).toBe( + '[0,0,5e-324,1.7976931348623157e+308,9007199254740992,1e+21,0.000001,1e-7]', + ); + }); + + it.each([ + ['nested containers', { b: [1, { d: null, c: true }], a: {}, e: [] }], + ['escapes and non-ASCII', { s: '\u0000\b\f\n\r\t"\\/\u001f
é😀' }], + ['numbers', { n: [0.1, 0.30000000000000004, -1.5e-10, 123456789012345680000, 1e300] }], + ['a scalar', 'plain'], + ])('matches the canonicalize reference for %s', (_name, value) => { + expect(canonicalJson(value)).toBe(canonicalize(value)); + }); + + it.each([ + ['a lone surrogate in a string', { s: 'a\ud800b' }], + ['a lone surrogate in a key', { '\udc00': 1 }], + ['a lone surrogate in an array', ['ok', '\ud800']], + ['an infinite number', { n: Number.POSITIVE_INFINITY }], + ['NaN', [Number.NaN]], + ])('returns undefined for %s, outside what RFC 8785 serializes', (_name, value) => { + expect(() => canonicalize(value)).toThrow(); + expect(canonicalJson(value)).toBeUndefined(); + }); +}); diff --git a/tests/unit/gateway/payment-providers.test.ts b/tests/unit/gateway/payment-providers.test.ts index 53ffa6f..edec474 100644 --- a/tests/unit/gateway/payment-providers.test.ts +++ b/tests/unit/gateway/payment-providers.test.ts @@ -1,7 +1,9 @@ +import { privateKeyToAccount } from 'viem/accounts'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GatewayConfig } from '../../../src/config'; import { NOOP_LOGGER } from '../../../src/core'; import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import type { X402PaymentProvider } from '../../../src/payments/x402/provider'; import { makeGatewayConfig } from './helpers'; type Payments = GatewayConfig['payments']; @@ -168,4 +170,37 @@ describe('createConfiguredPaymentProviders', () => { const envelope = requirement.challenge.envelope as { resource: Record }; expect(envelope.resource['mimeType']).toBe('text/csv'); }); + + it('passes quote to the x402 provider, which publishes its issuer and signs challenges with it', async () => { + const resource = makeGatewayConfig().resources.find((r) => r.id === 'market_report'); + if (resource === undefined) throw new Error('fixture missing'); + // Test-only key, never funded + const key = `0x${'33'.repeat(32)}` as const; + const issuer = privateKeyToAccount(key).address; + const [provider] = await createConfiguredPaymentProviders( + { x402: { ...MAINNET_X402, quote: { signerPrivateKey: key } } }, + NOOP_LOGGER, + ); + if (provider === undefined) throw new Error('no provider'); + const requirement = await provider.createRequirement({ + requestId: 'req-1', + resource, + amount: '0.01', + currency: 'USDC', + requestedAt: '2026-01-01T00:00:00.000Z', + input: {}, + }); + + expect((provider as X402PaymentProvider).quote).toEqual({ + issuer, + requestScheme: 'agent-commerce/request/v1', + extension: 'erc-8409', + }); + const envelope = requirement.challenge.envelope as { + extensions: Record; + }; + expect(envelope.extensions['erc-8409']?.info.quotes).toEqual([ + expect.objectContaining({ quote: expect.objectContaining({ issuer }) }), + ]); + }); }); diff --git a/tests/unit/gateway/server.test.ts b/tests/unit/gateway/server.test.ts index 9fb2723..6663d12 100644 --- a/tests/unit/gateway/server.test.ts +++ b/tests/unit/gateway/server.test.ts @@ -20,6 +20,7 @@ import { } from '../../../src/core'; import { MOUNT_BODY_LIMIT_BYTES } from '../../../src/gateway/adapters'; import { createGateway, type GatewayInstance } from '../../../src/gateway/server'; +import type { X402PaymentProvider } from '../../../src/payments/x402/provider'; import { PACKAGE_VERSION } from '../../../src/version'; import { createFakeHttpAdapter, @@ -359,6 +360,35 @@ describe('createGateway HTTP surface', () => { expect(res.json().payments.x402.payTo).toBeDefined(); }); + it('publishes the quote issuer of the x402 provider, and nothing when it signs no quotes', async () => { + const quote = { + issuer: '0x5555555555555555555555555555555555555555', + requestScheme: 'agent-commerce/request/v1', + extension: 'erc-8409', + } as const; + // The key is in config but never published; the issuer comes from the provider + const base = makeGatewayConfig(); + const x402 = base.payments.x402 as NonNullable; + const config = makeGatewayConfig({ + payments: { x402: { ...x402, quote: { signerPrivateKey: '0xQUOTE_SECRET_KEY' } } }, + }); + const wellKnown = async (provider: X402PaymentProvider) => { + const gateway = await buildGateway({ config, paymentProviders: [provider] }); + return ( + await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json(); + }; + + const signing = await wellKnown({ ...createFakePaymentProvider(), quote }); + expect(signing.payments.x402.quote).toEqual({ ...quote, status: 'experimental' }); + // Quote metadata from another rail must not appear under x402 + const other = await wellKnown({ ...createFakePaymentProvider({ name: 'mpp' }), quote }); + expect(other.payments.x402).not.toHaveProperty('quote'); + const plain = await wellKnown(createFakePaymentProvider()); + expect(plain.payments.x402).not.toHaveProperty('quote'); + expect(JSON.stringify([signing, other, plain])).not.toContain('QUOTE_SECRET_KEY'); + }); + it("publishes each option's offer terms on /.well-known, never its RPC URL", async () => { const base = makeGatewayConfig(); const x402 = base.payments.x402 as NonNullable; diff --git a/tests/unit/payments-x402/quote.test.ts b/tests/unit/payments-x402/quote.test.ts index 84b129d..3d91094 100644 --- a/tests/unit/payments-x402/quote.test.ts +++ b/tests/unit/payments-x402/quote.test.ts @@ -1,7 +1,6 @@ -// Checks the ERC-8409 test vector, RFC 8785 request serialization, challenge -// quotes, cache reuse and issuer key validation +// Checks the ERC-8409 test vector, the request hash, challenge quotes, cache +// reuse and issuer key validation import Ajv2020 from 'ajv/dist/2020.js'; -import canonicalize from 'canonicalize'; import { type Hex, hashTypedData, keccak256, recoverTypedDataAddress, stringToBytes } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -13,7 +12,6 @@ import { type X402OfferOptions, } from '../../../src/payments/x402/provider'; import { - canonicalJson, QUOTE_DOMAIN, QUOTE_TYPES, quoteRequestHash, @@ -161,50 +159,6 @@ describe('ERC-8409 typed data', () => { }); }); -describe('canonicalJson', () => { - it('orders keys by UTF-16 code units, as the RFC 8785 sorting example does', () => { - const input = { - '€': 'Euro Sign', - '\r': 'Carriage Return', - דּ: 'Hebrew Letter Dalet With Dagesh', - '1': 'One', - '😀': 'Emoji: Grinning Face', - '\u0080': 'Control', - ö: 'Latin Small Letter O With Diaeresis', - }; - expect(canonicalJson(input)).toBe( - '{"\\r":"Carriage Return","1":"One","\u0080":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😀":"Emoji: Grinning Face","דּ":"Hebrew Letter Dalet With Dagesh"}', - ); - }); - - it('formats numbers as RFC 8785 appendix B does', () => { - const numbers = [0, -0, 5e-324, 1.7976931348623157e308, 9007199254740992, 1e21, 1e-6, 1e-7]; - expect(canonicalJson(numbers)).toBe( - '[0,0,5e-324,1.7976931348623157e+308,9007199254740992,1e+21,0.000001,1e-7]', - ); - }); - - it.each([ - ['nested containers', { b: [1, { d: null, c: true }], a: {}, e: [] }], - ['escapes and non-ASCII', { s: '\u0000\b\f\n\r\t"\\/\u001f
é😀' }], - ['numbers', { n: [0.1, 0.30000000000000004, -1.5e-10, 123456789012345680000, 1e300] }], - ['a scalar', 'plain'], - ])('matches the canonicalize reference for %s', (_name, value) => { - expect(canonicalJson(value)).toBe(canonicalize(value)); - }); - - it.each([ - ['a lone surrogate in a string', { s: 'a\ud800b' }], - ['a lone surrogate in a key', { '\udc00': 1 }], - ['a lone surrogate in an array', ['ok', '\ud800']], - ['an infinite number', { n: Number.POSITIVE_INFINITY }], - ['NaN', [Number.NaN]], - ])('returns undefined for %s, outside what RFC 8785 serializes', (_name, value) => { - expect(() => canonicalize(value)).toThrow(); - expect(canonicalJson(value)).toBeUndefined(); - }); -}); - describe('quoteRequestHash', () => { it('hashes the canonical {input, resource} object with Keccak-256', () => { expect(quoteRequestHash('demo.report', { city: 'Paris', days: [1, 2] })).toBe( From 21acad32c9ef1f7565b0314c8be84392d7d580d2 Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 22 Oct 2026 22:48:43 +0200 Subject: [PATCH 3/5] feat(cli): check the gateway's quote issuer against the local key --- src/cli/commands/doctor/index.ts | 4 +- src/cli/commands/doctor/payments.ts | 128 +++++++++++++++++++++++++- src/cli/commands/doctor/types.ts | 2 + tests/unit/cli/doctor.test.ts | 134 ++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index a4775f6..cd88f74 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -149,7 +149,9 @@ export async function runDoctor( // 5-7. Protocols, AP2, payments and storage checks.push(...protocolChecks(config, wellKnown)); checks.push(...ap2Checks(config)); - checks.push(...paymentChecks(config, wellKnown, gatewayUrl, chainDeploy)); + checks.push( + ...(await paymentChecks(config, wellKnown, gatewayUrl, chainDeploy, deps.loadViemAccounts)), + ); checks.push(...(await storageChecks(config, createStore))); // 8. Protocol versions diff --git a/src/cli/commands/doctor/payments.ts b/src/cli/commands/doctor/payments.ts index 075961f..d14a455 100644 --- a/src/cli/commands/doctor/payments.ts +++ b/src/cli/commands/doctor/payments.ts @@ -322,13 +322,126 @@ function offerHealthCheck( }; } +// What the running gateway publishes about its ERC-8409 quotes +interface LiveQuote { + readonly issuer: string; + readonly requestScheme: string; + readonly extension: string; +} + +// `undefined` for a missing block; `unreadable` for an invalid shape or field type +function extractLiveQuote( + body: Record | undefined, +): LiveQuote | 'unreadable' | undefined { + const payments = body?.['payments']; + const x402 = isRecord(payments) ? payments['x402'] : undefined; + const quote = isRecord(x402) ? x402['quote'] : undefined; + if (quote === undefined) return undefined; + if (!isRecord(quote)) return 'unreadable'; + const { issuer, requestScheme, extension } = quote; + return typeof issuer === 'string' && + typeof requestScheme === 'string' && + typeof extension === 'string' + ? { issuer, requestScheme, extension } + : 'unreadable'; +} + +/** Loads the viem account module used to derive the quote issuer */ +export type LoadViemAccounts = () => Promise< + Pick +>; + +// Derive the local issuer; return `invalid` if viem rejects the key, or +// `undefined` if its account module cannot be loaded +async function localQuoteIssuer( + signerPrivateKey: string, + loadAccounts: LoadViemAccounts, +): Promise { + let accounts: Awaited>; + try { + accounts = await loadAccounts(); + } catch { + return undefined; + } + try { + return accounts.privateKeyToAccount(signerPrivateKey as `0x${string}`).address; + } catch { + return 'invalid'; + } +} + +// Compare quote enablement, then issuer addresses when local derivation succeeds +function quoteCheck( + enabled: boolean, + local: string | 'invalid' | undefined, + live: LiveRailResult, + body: Record | undefined, + gatewayUrl: string, + stale: string, +): DoctorCheck | undefined { + const name = 'Payments (quotes)'; + if (enabled && local === 'invalid') { + // The message never echoes the key + return { + name, + status: 'FAIL', + detail: + 'quote.signerPrivateKey is not a valid private key (expected "0x" followed by 64 hex characters), so start refuses it', + }; + } + const localIssuer = local === undefined ? undefined : maskMiddle(local); + // Read quote metadata only after discovery yields an enabled x402 rail + const quote = live === undefined || live === 'disabled' ? 'unreadable' : extractLiveQuote(body); + if (quote === 'unreadable') { + return enabled + ? { + name, + status: 'INFO', + detail: `ERC-8409 quotes enabled (experimental)${localIssuer !== undefined ? `, issuer=${localIssuer} from the local key` : ''}; the gateway's issuer could not be read`, + } + : undefined; + } + if (quote === undefined) { + return enabled + ? { + name, + status: 'FAIL', + detail: `Local config enables ERC-8409 quotes, but the gateway at ${gatewayUrl} publishes no quote issuer${stale}`, + } + : undefined; + } + const issuer = maskMiddle(quote.issuer); + if (!enabled) { + return { + name, + status: 'FAIL', + detail: `The gateway at ${gatewayUrl} signs ERC-8409 quotes as ${issuer}, but local config does not enable them${stale}`, + }; + } + // A key rotated without a restart leaves the gateway signing with the old one + if (local !== undefined && local.toLowerCase() !== quote.issuer.toLowerCase()) { + return { + name, + status: 'FAIL', + detail: `The gateway at ${gatewayUrl} signs ERC-8409 quotes as ${issuer}, but quote.signerPrivateKey belongs to ${localIssuer}${stale}`, + }; + } + return { + name, + status: 'PASS', + detail: `ERC-8409 quotes (experimental) - issuer=${issuer}${local === undefined ? ' as the gateway publishes it (viem could not be loaded to check it against the local key)' : ''}, requestScheme=${quote.requestScheme}, extension=${quote.extension}`, + }; +} + // x402 and MPP, each compared with what the running gateway reports -export function paymentChecks( +export async function paymentChecks( config: GatewayConfig | undefined, wellKnown: WellKnownResult | undefined, gatewayUrl: string, chainDeploy = false, -): DoctorCheck[] { + // Load this optional peer only when local x402 quote signing is enabled + loadAccounts: LoadViemAccounts = () => import('viem/accounts'), +): Promise { const checks: DoctorCheck[] = []; // Suggest the repository deployment script only when the caller found it const stale = `; the gateway may be running against an older deployment - restart it${chainDeploy ? ' or re-run chain:deploy' : ''}`; @@ -407,6 +520,17 @@ export function paymentChecks( const offerHealth = offerHealthCheck(live, named); if (offerHealth !== undefined) checks.push(offerHealth); } + const quotes = quoteCheck( + x402.quote !== undefined, + x402.quote !== undefined + ? await localQuoteIssuer(x402.quote.signerPrivateKey, loadAccounts) + : undefined, + live, + wellKnown?.ok ? wellKnown.body : undefined, + gatewayUrl, + stale, + ); + if (quotes !== undefined) checks.push(quotes); if (mode === 'mainnet') { checks.push( mainnetSafetyCheck('Mainnet safety', 'payTo', x402.facilitator, [ diff --git a/src/cli/commands/doctor/types.ts b/src/cli/commands/doctor/types.ts index 425fd46..bc2b2d2 100644 --- a/src/cli/commands/doctor/types.ts +++ b/src/cli/commands/doctor/types.ts @@ -3,6 +3,7 @@ import type { ReceiptStore } from '../../../core'; import type { ConfigLoader } from '../../lib/config-client'; import type { FetchJsonResult, FetchLike } from '../../lib/http'; import type { ManifestEnvFill } from '../../lib/manifest-env'; +import type { LoadViemAccounts } from './payments'; export type CheckStatus = 'PASS' | 'WARN' | 'FAIL' | 'INFO'; @@ -29,6 +30,7 @@ export interface DoctorDeps { readonly createStore?: (path: string) => ReceiptStore; readonly fillEnvFromManifest?: (env: NodeJS.ProcessEnv) => ManifestEnvFill; readonly hasChainDeployScript?: () => boolean; + readonly loadViemAccounts?: LoadViemAccounts; } export type WellKnownResult = FetchJsonResult>; diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 10f3a08..3853b75 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -945,6 +945,140 @@ describe('runDoctor: several x402 options', () => { }); }); +describe('runDoctor: ERC-8409 quotes', () => { + // Test-only key, never funded; ISSUER was checked with Foundry's `cast wallet address` + const QUOTE_KEY = `0x${'33'.repeat(32)}`; + const ISSUER = '0x5CbDd86a2FA8Dc4bDdd8a8f69dBa48572EeC07FB'; + const QUOTE = { + issuer: ISSUER, + requestScheme: 'agent-commerce/request/v1', + extension: 'erc-8409', + status: 'experimental', + }; + const withQuote = (quote: unknown) => { + const body = wellKnownBody(); + return { ...body, payments: { x402: { ...body.payments.x402, quote } } }; + }; + const doctor = async ( + key: string | undefined, + body?: unknown, + loadViemAccounts?: () => Promise, + ) => + runDoctor( + { gatewayUrl: GATEWAY }, + { + // No body: the gateway is unreachable + fetchImpl: + body === undefined + ? createFakeFetch({}) + : healthyFetch({ [`${GATEWAY}/.well-known/agent-commerce`]: () => jsonResponse(body) }), + loadConfig: async () => + makeGatewayConfig({ + payments: { + x402: { + ...X402_CONFIG, + ...(key !== undefined ? { quote: { signerPrivateKey: key } } : {}), + }, + }, + }), + createStore: () => makeFakeReceiptStore(), + ...(loadViemAccounts !== undefined ? { loadViemAccounts } : {}), + }, + ); + const quoteCheck = async (...args: Parameters) => + (await doctor(...args)).checks.find((c) => c.name === 'Payments (quotes)'); + + it("passes when the gateway's issuer is the local key's address, naming it masked", async () => { + const report = await doctor(QUOTE_KEY, withQuote(QUOTE)); + + expect(report.checks.find((c) => c.name === 'Payments (quotes)')).toEqual({ + name: 'Payments (quotes)', + status: 'PASS', + detail: + 'ERC-8409 quotes (experimental) - issuer=0x5CbD…07FB, requestScheme=agent-commerce/request/v1, extension=erc-8409', + }); + expect(JSON.stringify(report)).not.toContain(QUOTE_KEY.slice(2)); + }); + + it('compares the issuers without regard to case', async () => { + const check = await quoteCheck( + QUOTE_KEY, + withQuote({ ...QUOTE, issuer: ISSUER.toLowerCase() }), + ); + expect(check?.status).toBe('PASS'); + }); + + it('fails when the gateway signs with another key, as after a rotation without a restart', async () => { + const other = '0x5555555555555555555555555555555555555555'; + const check = await quoteCheck(QUOTE_KEY, withQuote({ ...QUOTE, issuer: other })); + expect(check?.status).toBe('FAIL'); + expect(check?.detail).toContain( + 'signs ERC-8409 quotes as 0x5555…5555, but quote.signerPrivateKey belongs to 0x5CbD…07FB', + ); + expect(check?.detail).toContain('restart it'); + }); + + it.each([ + ['the gateway is unreachable', undefined], + ['the gateway publishes an issuer', withQuote(QUOTE)], + ])('fails on a key start would refuse, without echoing it, when %s', async (_label, body) => { + const key = '0xnot-a-private-key'; + const report = await doctor(key, body); + expect(report.checks.find((c) => c.name === 'Payments (quotes)')).toEqual({ + name: 'Payments (quotes)', + status: 'FAIL', + detail: + 'quote.signerPrivateKey is not a valid private key (expected "0x" followed by 64 hex characters), so start refuses it', + }); + expect(JSON.stringify(report)).not.toContain('not-a-private-key'); + }); + + it("passes on the gateway's issuer alone, and says so, when viem cannot be loaded", async () => { + const missing = async () => { + throw new Error("Cannot find package 'viem'"); + }; + const check = await quoteCheck(QUOTE_KEY, withQuote(QUOTE), missing); + expect(check).toEqual({ + name: 'Payments (quotes)', + status: 'PASS', + detail: + 'ERC-8409 quotes (experimental) - issuer=0x5CbD…07FB as the gateway publishes it (viem could not be loaded to check it against the local key), requestScheme=agent-commerce/request/v1, extension=erc-8409', + }); + }); + + it('fails when local config enables quotes and the gateway publishes no issuer', async () => { + const check = await quoteCheck(QUOTE_KEY, wellKnownBody()); + expect(check?.status).toBe('FAIL'); + expect(check?.detail).toContain(`the gateway at ${GATEWAY} publishes no quote issuer`); + expect(check?.detail).toContain('restart it'); + }); + + it('fails when the gateway signs quotes that local config does not enable', async () => { + const check = await quoteCheck(undefined, withQuote(QUOTE)); + expect(check?.status).toBe('FAIL'); + expect(check?.detail).toContain('signs ERC-8409 quotes as 0x5CbD…07FB'); + }); + + it('adds no check while quotes are off on both sides', async () => { + expect(await quoteCheck(undefined, wellKnownBody())).toBeUndefined(); + expect(await quoteCheck(undefined)).toBeUndefined(); + }); + + it.each([ + ['the gateway is unreachable', undefined], + ['the gateway reports x402 disabled', wellKnownBody({ enabled: false })], + ['the quote block is not an object', withQuote('erc-8409')], + ['the issuer is missing', withQuote({ ...QUOTE, issuer: undefined })], + ])("reports INFO with the local key's issuer when %s", async (_label, body) => { + expect(await quoteCheck(QUOTE_KEY, body)).toEqual({ + name: 'Payments (quotes)', + status: 'INFO', + detail: + "ERC-8409 quotes enabled (experimental), issuer=0x5CbD…07FB from the local key; the gateway's issuer could not be read", + }); + }); +}); + describe('printDoctorReport', () => { it('emits machine-readable JSON with --json', async () => { const report = await runDoctor( From e159231bc60f8020c5227253b320eca9c1ad26db Mon Sep 17 00:00:00 2001 From: Revinand Date: Thu, 22 Oct 2026 23:14:53 +0200 Subject: [PATCH 4/5] test(x402): check ERC-8409 quotes over the gateway and on a local chain --- tests/e2e/payment/x402-quotes.e2e.test.ts | 216 +++++++++++++++ tests/integration/x402-quotes.test.ts | 307 ++++++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 tests/e2e/payment/x402-quotes.e2e.test.ts create mode 100644 tests/integration/x402-quotes.test.ts diff --git a/tests/e2e/payment/x402-quotes.e2e.test.ts b/tests/e2e/payment/x402-quotes.e2e.test.ts new file mode 100644 index 0000000..27dcb0b --- /dev/null +++ b/tests/e2e/payment/x402-quotes.e2e.test.ts @@ -0,0 +1,216 @@ +/** + * A buyer checks ERC-8409 quotes before paying through the local facilitator. + * The test starts and stops Anvil, deploys MockUSDC, and checks settlement + * against on-chain balances and the transaction receipt. It uses no public + * RPC endpoints or chains. + */ + +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import canonicalize from 'canonicalize'; +import { type Hex, keccak256, recoverTypedDataAddress, stringToBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../src/config'; +import { NOOP_LOGGER, type ReceiptStore } from '../../../src/core'; +import { createGateway, type GatewayInstance } from '../../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../../src/gateway/payment-providers'; +import type { SignedQuote } from '../../../src/payments/x402/provider/quote'; +import { type AnvilHandle, deployLocalChain, startAnvil } from '../../../src/payments/x402/testing'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts'; +import { expectRealSettlement, readBalances } from '../../fixtures/x402/settlement'; + +const PORT = 18795; +const RESOURCE_ID = 'quoted_report'; +const INPUT = { symbol: 'ETH' }; +const MAX_TIMEOUT_SECONDS = 120; +// Test-only quote signing key, never funded +const QUOTE_KEY = `0x${'33'.repeat(32)}` as const; + +let anvil: AnvilHandle; +let deployment: Awaited>; +let gateway: GatewayInstance; +let store: ReceiptStore; + +beforeAll(async () => { + anvil = await startAnvil({ port: PORT, silent: true }); + deployment = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '100.00' }); + const config = parseConfig( + { + version: 1, + merchant: { id: 'quote-e2e', name: 'Quote E2E', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Quoted report', + input: { + type: 'object', + properties: { symbol: { type: 'string' } }, + required: ['symbol'], + }, + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '1.00', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + network: 'eip155:31337', + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + assetName: deployment.assetName, + assetVersion: deployment.assetVersion, + assetDecimals: deployment.assetDecimals, + payTo: deployment.merchant.address, + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey }, + quote: { enabled: true, signerPrivateKey: QUOTE_KEY }, + }, + }, + }, + {}, + ); + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + gateway = await createGateway({ + config, + store, + paymentProviders: await createConfiguredPaymentProviders(config.payments, NOOP_LOGGER), + protocolAdapters: [], + backend: { + async call() { + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, + }, + }); +}, 120_000); + +afterAll(async () => { + await gateway?.close().catch(() => {}); + await store?.close().catch(() => {}); + await anvil?.stop(); +}); + +function invoke(headers: Record = {}) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: JSON.stringify(INPUT), + }); +} + +// ERC-8409 type fields, declared independently of the provider +const QUOTE_TYPES = { + ServicePaymentQuote: [ + { name: 'issuer', type: 'address' }, + { name: 'payer', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'asset', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'requestScheme', type: 'string' }, + { name: 'requestHash', type: 'bytes32' }, + { name: 'quoteId', type: 'bytes32' }, + { name: 'validAfter', type: 'uint64' }, + { name: 'validUntil', type: 'uint64' }, + ], +} as const; + +function quoteSigner({ chainId, quote, signature }: SignedQuote): Promise { + return recoverTypedDataAddress({ + domain: { name: 'Signed Service Payment Quotes', version: '1', chainId: Number(chainId) }, + types: QUOTE_TYPES, + primaryType: 'ServicePaymentQuote', + message: { + ...quote, + amount: BigInt(quote.amount), + validAfter: BigInt(quote.validAfter), + validUntil: BigInt(quote.validUntil), + }, + signature, + }); +} + +describe('x402 with ERC-8409 quotes - real local chain', () => { + it('a buyer checks the quote against the well-known issuer, pays with the SDK client, and the payment settles', async () => { + // 1. Read the issuer from the gateway's discovery document + const wellKnown = ( + await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json(); + const issuer = wellKnown.payments.x402.quote.issuer as string; + expect(issuer).toBe(privateKeyToAccount(QUOTE_KEY).address); + + // 2. The challenge carries one quote for the one EVM offer + const challenged = await invoke(); + expect(challenged.statusCode).toBe(402); + const buyer = privateKeyToAccount(deployment.buyer.privateKey); + const payments = new x402Client(); + registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); + // MockUSDC is not in the SDK's spend-control asset list + payments.setSpendControls(false); + const http = new x402HTTPClient(payments); + const required = http.getPaymentRequiredResponse( + (name) => challenged.headers[name.toLowerCase()] as string | undefined, + ); + const extension = required.extensions?.['erc-8409'] as { info: { quotes: SignedQuote[] } }; + const [signed] = extension.info.quotes; + const [offer] = required.accepts; + if (signed === undefined || offer === undefined) throw new Error('no quote or offer'); + + // 3. Check the issuer, request hash, offer terms, and validity before paying + const now = Math.floor(Date.now() / 1000); + expect(await quoteSigner(signed)).toBe(issuer); + expect(signed.quote).toMatchObject({ + issuer, + payer: '0x0000000000000000000000000000000000000000', + requestScheme: 'agent-commerce/request/v1', + requestHash: keccak256( + stringToBytes(canonicalize({ input: INPUT, resource: RESOURCE_ID }) as string), + ), + amount: offer.amount, + }); + expect(signed.chainId).toBe('31337'); + expect(signed.quote.recipient.toLowerCase()).toBe(offer.payTo.toLowerCase()); + expect(signed.quote.asset.toLowerCase()).toBe(offer.asset.toLowerCase()); + expect(Number(signed.quote.validAfter)).toBeLessThanOrEqual(now); + expect(Number(signed.quote.validUntil) - now).toBeGreaterThanOrEqual(MAX_TIMEOUT_SECONDS); + + // 4. The SDK echoes the extension; the gateway settles using the offer's terms + const payload = await http.createPaymentPayload(required); + expect(payload.extensions?.['erc-8409']).toEqual(extension); + const before = await readBalances({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + }); + + const paid = await invoke(http.encodePaymentSignatureHeader(payload)); + + expect(paid.statusCode).toBe(200); + expect(paid.json()).toMatchObject({ report: 'ok' }); + const [receipt] = await store.listReceipts({ limit: 1 }); + expect(receipt?.payment).toMatchObject({ provider: 'x402', status: 'settled' }); + await expectRealSettlement({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + before, + after: await readBalances({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + }), + // The quoted amount, 1.00 at 6 decimals + amountBaseUnits: BigInt(signed.quote.amount), + txHash: receipt?.payment?.externalReference as string, + }); + expect(signed.quote.amount).toBe('1000000'); + }); +}); diff --git a/tests/integration/x402-quotes.test.ts b/tests/integration/x402-quotes.test.ts new file mode 100644 index 0000000..7d74fb7 --- /dev/null +++ b/tests/integration/x402-quotes.test.ts @@ -0,0 +1,307 @@ +/** + * HTTP, MCP, and A2A carry the same ERC-8409 quotes through a gateway built + * from parsed config. A buyer can recompute the request hash, recover the + * published issuer, and submit an SDK payment that echoes the quote extension. + * Settlement uses a mocked facilitator HTTP client; the receipt store is a + * fake, and the backend is a stub. + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentPayload, PaymentRequired } from '@x402/core/types'; +import { registerExactEvmScheme } from '@x402/evm/exact/client'; +import canonicalize from 'canonicalize'; +import { type Hex, keccak256, recoverTypedDataAddress, stringToBytes } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseConfig } from '../../src/config'; +import { NOOP_LOGGER, PAYMENT_REQUIRED_HEADER } from '../../src/core'; +import { createGateway, type GatewayInstance } from '../../src/gateway'; +import { createConfiguredPaymentProviders } from '../../src/gateway/payment-providers'; +import type { SignedQuote } from '../../src/payments/x402/provider/quote'; +import { createA2aAdapter } from '../../src/protocols/a2a'; +import type { A2aTask } from '../../src/protocols/a2a/types'; +import { A2A_X402_EXTENSION_URI } from '../../src/protocols/a2a/x402-extension'; +import { createMcpAdapter } from '../../src/protocols/mcp'; +import { createFakeStore } from '../unit/gateway/helpers'; + +process.env['NODE_ENV'] = 'test'; + +const facilitator = vi.hoisted(() => ({ verify: vi.fn(), settle: vi.fn() })); +vi.mock('@x402/core/http', async (importOriginal) => ({ + ...(await importOriginal()), + HTTPFacilitatorClient: class { + verify(...args: unknown[]) { + return facilitator.verify(...args); + } + settle(...args: unknown[]) { + return facilitator.settle(...args); + } + }, +})); + +// Test-only key, never funded +const QUOTE_KEY = `0x${'33'.repeat(32)}`; +const PAY_TO = '0x1111111111111111111111111111111111111111'; +const ASSET = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; +const RESOURCE_ID = 'quoted_report'; +const INPUT = { symbol: 'ETH' }; +const TX = `0x${'ef'.repeat(32)}`; +const buyer = privateKeyToAccount(generatePrivateKey()); + +let gateway: GatewayInstance; +let url = ''; +const clients: Client[] = []; + +beforeEach(async () => { + facilitator.verify.mockReset().mockResolvedValue({ isValid: true, payer: buyer.address }); + facilitator.settle.mockReset().mockResolvedValue({ + success: true, + transaction: TX, + network: 'eip155:31337', + payer: buyer.address, + }); + const config = parseConfig( + { + version: 1, + merchant: { id: 'quote-test', name: 'Quote test', publicBaseUrl: 'http://localhost:8080' }, + server: { port: 0, host: '127.0.0.1' }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, + }, + resources: { + [RESOURCE_ID]: { + name: 'Quoted report', + input: { + type: 'object', + properties: { symbol: { type: 'string' } }, + required: ['symbol'], + additionalProperties: false, + }, + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + expose: ['http', 'mcp', 'a2a'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + network: 'eip155:31337', + // No RPC service is started; settlement uses the mocked facilitator + rpcUrl: 'http://127.0.0.1:19321', + asset: ASSET, + assetName: 'USDC', + assetVersion: '2', + assetDecimals: 6, + payTo: PAY_TO, + maxTimeoutSeconds: 120, + facilitator: { + mode: 'remote', + url: 'https://facilitator.example.com', + auth: { type: 'none' }, + }, + quote: { enabled: true, signerPrivateKey: QUOTE_KEY }, + }, + }, + }, + {}, + ); + gateway = await createGateway({ + config, + store: createFakeStore(), + paymentProviders: await createConfiguredPaymentProviders(config.payments, NOOP_LOGGER), + protocolAdapters: [createMcpAdapter(), createA2aAdapter()], + backend: { + async call() { + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, + }, + }); + ({ url } = await gateway.listen()); +}); + +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.close().catch(() => {}))); + await gateway.close().catch(() => {}); +}); + +function invokeHttp(headers: Record = {}) { + return gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: JSON.stringify(INPUT), + }); +} + +async function callMcp(): Promise { + const client = new Client({ name: 'quote-test-buyer', version: '0.0.0-test' }); + await client.connect(new StreamableHTTPClientTransport(new URL(`${url}/mcp`)) as Transport); + clients.push(client); + return (await client.callTool({ name: RESOURCE_ID, arguments: INPUT })) as CallToolResult; +} + +async function sendA2a(extension: boolean): Promise { + const res = await gateway.server.inject({ + method: 'POST', + url: '/a2a', + headers: { + 'content-type': 'application/json', + 'a2a-version': '1.0', + ...(extension ? { 'a2a-extensions': A2A_X402_EXTENSION_URI } : {}), + }, + payload: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'SendMessage', + params: { + message: { + role: 'ROLE_USER', + messageId: 'msg-buy', + parts: [{ data: { resource: RESOURCE_ID, input: INPUT }, mediaType: 'application/json' }], + }, + }, + }), + }); + return (res.json() as { result: { task: A2aTask } }).result.task; +} + +function decodeHeader(value: unknown): PaymentRequired { + return JSON.parse(Buffer.from(String(value), 'base64').toString('utf8')) as PaymentRequired; +} + +function quotesOf(required: { extensions?: Record } | undefined): SignedQuote[] { + const extension = required?.extensions?.['erc-8409'] as + | { info: { quotes: SignedQuote[] } } + | undefined; + if (extension === undefined) throw new Error('the challenge carries no erc-8409 extension'); + return extension.info.quotes; +} + +// Compute the buyer's agent-commerce/request/v1 hash with the reference +// RFC 8785 implementation, independently of the gateway's hash function +const REQUEST_HASH = keccak256( + stringToBytes(canonicalize({ input: INPUT, resource: RESOURCE_ID }) as string), +); + +describe('ERC-8409 quotes over the gateway', () => { + it('carries the same signed quotes on HTTP, MCP and both A2A carriers for the same input', async () => { + const http = await invokeHttp(); + expect(http.statusCode).toBe(402); + const header = quotesOf(decodeHeader(http.headers[PAYMENT_REQUIRED_HEADER])); + const body = quotesOf(http.json().payment.envelope); + + const mcp = await callMcp(); + expect(mcp.isError).toBe(true); + const mcpStructured = quotesOf(mcp.structuredContent as PaymentRequired); + const mcpMeta = quotesOf(mcp._meta?.['x402/error'] as PaymentRequired); + + const a2aPlain = await sendA2a(false); + // Without the A2A x402 extension, the gateway envelope is in the task's data part + const a2aData = a2aPlain.artifacts?.[0]?.parts[0]?.data as { + payment: { envelope: PaymentRequired }; + }; + const a2aBase = quotesOf(a2aData.payment.envelope); + const a2aExtension = await sendA2a(true); + const a2aMetadata = quotesOf( + a2aExtension.status.message?.metadata?.['x402.payment.required'] as PaymentRequired, + ); + + expect(header).toHaveLength(1); + expect(header[0]?.quote.requestHash).toBe(REQUEST_HASH); + // Each transport returns the cached quote within its reuse window + for (const carried of [body, mcpStructured, mcpMeta, a2aBase, a2aMetadata]) { + expect(carried).toEqual(header); + } + }); + + it("signs quotes that recover to the well-known issuer and match the offer's terms", async () => { + const wellKnown = ( + await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json(); + expect(wellKnown.payments.x402.quote).toEqual({ + issuer: privateKeyToAccount(QUOTE_KEY as Hex).address, + requestScheme: 'agent-commerce/request/v1', + extension: 'erc-8409', + status: 'experimental', + }); + + const required = decodeHeader((await invokeHttp()).headers[PAYMENT_REQUIRED_HEADER]); + const [signed] = quotesOf(required); + const [offer] = required.accepts; + if (signed === undefined || offer === undefined) throw new Error('no quote or offer'); + + // Recover the signer using an independently declared ERC-8409 domain and type + const { quote } = signed; + const signer = await recoverTypedDataAddress({ + domain: { name: 'Signed Service Payment Quotes', version: '1', chainId: 31337 }, + types: { + ServicePaymentQuote: [ + { name: 'issuer', type: 'address' }, + { name: 'payer', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'asset', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'requestScheme', type: 'string' }, + { name: 'requestHash', type: 'bytes32' }, + { name: 'quoteId', type: 'bytes32' }, + { name: 'validAfter', type: 'uint64' }, + { name: 'validUntil', type: 'uint64' }, + ], + }, + primaryType: 'ServicePaymentQuote', + message: { + ...quote, + amount: BigInt(quote.amount), + validAfter: BigInt(quote.validAfter), + validUntil: BigInt(quote.validUntil), + }, + signature: signed.signature, + }); + expect(signer).toBe(wellKnown.payments.x402.quote.issuer); + expect(quote.issuer).toBe(signer); + expect({ + chainId: signed.chainId, + recipient: quote.recipient.toLowerCase(), + asset: quote.asset.toLowerCase(), + amount: quote.amount, + }).toEqual({ + chainId: '31337', + recipient: offer.payTo.toLowerCase(), + asset: offer.asset.toLowerCase(), + amount: offer.amount, + }); + expect(offer.amount).toBe('10000'); + }); + + it('settles an SDK payment that echoes the extension, and keeps it from the facilitator', async () => { + const challenged = await invokeHttp(); + const payments = new x402Client(); + registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); + // The test token is not in the SDK's spend-control asset list + payments.setSpendControls(false); + const httpClient = new x402HTTPClient(payments); + const required = httpClient.getPaymentRequiredResponse( + (name) => challenged.headers[name.toLowerCase()] as string | undefined, + ); + const payload = await httpClient.createPaymentPayload(required); + expect(quotesOf(payload)).toEqual(quotesOf(required)); + + const paid = await invokeHttp(httpClient.encodePaymentSignatureHeader(payload)); + + expect(paid.statusCode).toBe(200); + expect(paid.json()).toMatchObject({ report: 'ok' }); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + for (const mock of [facilitator.verify, facilitator.settle]) { + const sent = mock.mock.calls[0]?.[0] as PaymentPayload; + expect(sent.payload).toEqual(payload.payload); + expect(sent).not.toHaveProperty('extensions'); + } + }); +}); From 6123574fbbc75c7257348dca27e9b18fbd03e28b Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 23 Oct 2026 00:09:10 +0200 Subject: [PATCH 5/5] feat(x402): document ERC-8409 quotes and export X402PaymentProvider --- README.md | 30 ++++--- SECURITY.md | 5 +- config.example.yaml | 10 ++- docs/configuration.md | 44 ++++++++++ docs/contracts.md | 23 ++++- docs/payment-flow.md | 2 + docs/protocols.md | 21 ++--- docs/protocols/x402.md | 116 +++++++++++++++++++++++++- docs/security.md | 15 +++- src/payments/x402/index.ts | 1 + src/x402.ts | 1 + tests/integration/x402-quotes.test.ts | 45 +++++++--- 12 files changed, 271 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0d2df8d..2bc0964 100644 --- a/README.md +++ b/README.md @@ -279,16 +279,17 @@ See [docs/configuration.md](docs/configuration.md). ## Protocol support -| Protocol | Status | Pinned revision | -| -------- | ------------ | ------------------------------------------------------------------------------------------------------------ | -| **HTTP** | Supported | native routes | -| **MCP** | Supported | `2026-07-28` and `2025-11-25`, `@modelcontextprotocol/server@2.3.0` | -| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`, `@x402/svm`, `@x402/near`), scheme `exact`, EVM and Solana; NEAR partial | -| **MPP** | Experimental | `mppx@0.13.1`, `charge` + `evm` + EIP-3009, USDC on Base Sepolia and Base | -| **A2A** | Experimental | A2A v1.0.1, binding `JSONRPC`, method `SendMessage`, x402 extension | -| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | -| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | -| UCP | Planned | no implementation | +| Protocol | Status | Pinned revision | +| ------------ | ------------ | ------------------------------------------------------------------------------------------------------------ | +| **HTTP** | Supported | native routes | +| **MCP** | Supported | `2026-07-28` and `2025-11-25`, `@modelcontextprotocol/server@2.3.0` | +| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`, `@x402/svm`, `@x402/near`), scheme `exact`, EVM and Solana; NEAR partial | +| **MPP** | Experimental | `mppx@0.13.1`, `charge` + `evm` + EIP-3009, USDC on Base Sepolia and Base | +| **A2A** | Experimental | A2A v1.0.1, binding `JSONRPC`, method `SendMessage`, x402 extension | +| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | +| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | +| **ERC-8409** | Experimental | draft `SergeevDmitry/ERCs@0ee33230`: signed quotes on x402 EVM offers | +| UCP | Planned | no implementation | [AP2](docs/protocols/ap2.md) is an authorization method, not a transport. It verifies a mandate before a paid resource settles; the gateway does not issue the mandate @@ -296,7 +297,8 @@ or its Checkout Receipt. The experimental entries above ship off by default with documented subsets: [A2A](docs/protocols/a2a.md), [ACP](docs/protocols/acp.md), -[AP2](docs/protocols/ap2.md#not-implemented) and [MPP](docs/protocols/mpp.md). Supported +[AP2](docs/protocols/ap2.md#not-implemented), [MPP](docs/protocols/mpp.md) and +[ERC-8409 quotes](docs/protocols/x402.md#erc-8409-signed-quotes). Supported [MCP](docs/protocols/mcp.md) and [x402](docs/protocols/x402.md) are also scoped. ACP exposes only its five checkout operations; merchant `payment_data` is never converted into a gateway payment proof. @@ -308,7 +310,8 @@ prints the `unsupported` lists for A2A, ACP and AP2 when they are enabled. ### Versioning Minor releases may break experimental configurations, integrations or types. -This applies to MPP, A2A, ACP, AP2 and `agent-commerce import openapi`. +This applies to MPP, A2A, ACP, AP2, ERC-8409 quotes and +`agent-commerce import openapi`. Runtime adapters and providers report `status: experimental` in `GET /.well-known/agent-commerce`. Pin an exact version and test upgrades before deploying them. Since 2.1, some supported x402 behavior and types @@ -318,7 +321,8 @@ differ from 2.0; see [Upgrading from 2.0 to 2.x](docs/migration-2.1.md). - **Non-custodial.** Buyer payments go directly to the merchant address in `payTo` (`recipient` for MPP). The gateway does not require buyer or merchant - private keys. Local facilitator mode does hold a separate gas-paying signer. + private keys. Local facilitator mode uses a separate key to pay gas. + Experimental ERC-8409 quotes use a dedicated, unfunded signing key. - **Fail closed.** Missing, malformed, expired, replayed, wrong-amount, wrong-recipient, wrong-network and wrong-asset payments are rejected. - **Replay-safe twice over.** The EIP-3009 or Permit2 nonce stops a double diff --git a/SECURITY.md b/SECURITY.md index 2c90974..9e760db 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,8 +34,9 @@ Agent Commerce Gateway is **not** a payment processor, wallet, exchange or custodian. - The payment flow does not require a merchant or buyer private key or seed - phrase. Local facilitator mode does accept a configured gas-paying signer key; - do not reuse a merchant or buyer key for it. + phrase. Local facilitator mode uses a configured key to pay gas, and + experimental ERC-8409 quotes use a dedicated, unfunded signing key. Do not + reuse a merchant or buyer key for either purpose. - The merchant settlement destination is plain configuration (`payments.x402.payTo: ${MERCHANT_WALLET}`), an address the merchant controls. It is never a gateway-owned wallet. diff --git a/config.example.yaml b/config.example.yaml index d0db660..e889d52 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -125,6 +125,12 @@ payments: payTo: ${MERCHANT_WALLET} # Seconds a payment challenge stays valid maxTimeoutSeconds: 120 + # Experimental ERC-8409 quotes for EVM offers in x402 challenges. + # Use a dedicated, unfunded key, never the merchant's. See + # docs/configuration.md#erc-8409-quotes. + # quote: + # enabled: true + # signerPrivateKey: ${X402_QUOTE_SIGNER_KEY} # Settlement order: authorization (the default) calls the backend first # and settles only if it succeeds; upfront settles first. A paid resource # can set its own paymentFlow, which applies to the rail that serves it. @@ -136,8 +142,8 @@ payments: # on mainnet, and a well-known Anvil key is refused against # any RPC that is not local or private. # - # mode: remote an HTTP facilitator does the signing; this gateway holds no - # signing key. Requires https, except on a local or private + # mode: remote an HTTP facilitator handles settlement; the gateway holds no + # facilitator signing key. Requires https, except on a local or private # host off mainnet, and on mainnet a credential unless # allowUnauthenticatedFacilitator is set. facilitator: diff --git a/docs/configuration.md b/docs/configuration.md index 5a0d5b3..4f48767 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -530,6 +530,50 @@ own tokens and decimals. Config load rejects empty lists, repeated entries, and `x402.accepts` on free resources, without `x402` in `payments`, or without `payments.x402`. +### ERC-8409 quotes + +Experimental and disabled by default. Enable +[ERC-8409 quotes](protocols/x402.md#erc-8409-signed-quotes) to sign EVM offers +in x402 challenges. + +```yaml +payments: + x402: + quote: + enabled: true + signerPrivateKey: ${X402_QUOTE_SIGNER_KEY} # dedicated; holds no funds +``` + +Use a dedicated key that signs nothing else and holds no funds. Never reuse a +merchant's or buyer's key. Anyone with the key can sign quotes as the merchant; +signing a quote does not transfer funds. The gateway keeps the key in memory +and publishes the issuer address. + +When `enabled` is true, `signerPrivateKey` is required. `validate` checks that +it is a nonempty string; the provider checks the key during construction. +Construction fails with `CONFIG_INVALID` for a malformed key, a well-known +Anvil key outside the local chain, a key matching an EVM `payTo`, or a provider +with no EVM offer. Restart the gateway after rotating the key. `doctor` +compares the configured key's address with the published issuer to detect a +gateway still using the old key. + +Quotes increase header sizes. Each quoted offer adds about 0.9 KB of base64 +to `PAYMENT-REQUIRED`, plus about 1 KB for the extension's schema. A small +challenge grows from roughly 0.6 KB to 2.5 KB with one offer, or 4.9 KB with +three; sizes vary with the offer and resource metadata. + +Behind nginx, size +[`proxy_buffer_size`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size) +to fit the response headers. Its default is one 4 or 8 KB memory page; +oversized headers cause nginx to reject the response. SDK clients echo quotes +in `PAYMENT-SIGNATURE`, which can reach about 7.4 KB with six quoted offers. +This approaches nginx's default 8 KB limit for a single request header field, +set by +[`large_client_header_buffers`](https://nginx.org/en/docs/http/ngx_http_core_module.html#large_client_header_buffers). + +Each cache miss requires a signature per EVM offer. Rate-limit unpaid requests +at the edge; see [Denial of service](security.md#denial-of-service). + ### `payments.mpp` MPP accepts the `USDC` currency label on the local chain `eip155:31337`, Base diff --git a/docs/contracts.md b/docs/contracts.md index ca07bbb..6fa5cc6 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -262,6 +262,16 @@ persistence must not fail the commerce flow. ` ` with lowercase EVM addresses. Any failing offer fails the provider. `/.well-known` copies statuses into `options` and `resourceOptions`; `/ready` is unchanged. +- Experimental ERC-8409 quotes on x402 EVM offers. Without `quote`, challenges + are unchanged. + - `X402ProviderOptions.quote` enables signing. + `GatewayConfig.payments.x402.quote` is present only when + `payments.x402.quote.enabled` is true. + - `createX402PaymentProvider` returns `X402PaymentProvider`, a + `PaymentProvider` whose optional `quote` describes the issuer, request scheme + and extension key. The descriptor gains the capability `erc-8409-quotes`. + - `/.well-known` publishes `payments.x402.quote` (`WellKnownX402Quote`), read + from the signing provider. - `ProtocolName` gained `a2a` and `acp`; `PROTOCOL_NAMES` is the runtime list used by validation. It is derived from a record keyed by the union, so a @@ -460,9 +470,18 @@ export interface X402MultiOfferProviderOptions readonly offers: readonly X402OfferOptions[]; } +/** Experimental `quote`: present when the provider signs ERC-8409 quotes */ +export interface X402PaymentProvider extends PaymentProvider { + readonly quote?: { + readonly issuer: `0x${string}`; + readonly requestScheme: string; // 'agent-commerce/request/v1' + readonly extension: string; // 'erc-8409', the PaymentRequired.extensions key + }; +} + export function createX402PaymentProvider( options: X402ProviderOptions | X402MultiOfferProviderOptions, -): PaymentProvider; +): X402PaymentProvider; export type DeploymentMode = 'local' | 'testnet' | 'mainnet'; export const SUPPORTED_NETWORK_IDS: readonly string[]; @@ -784,6 +803,8 @@ export interface GatewayConfig { readonly options?: readonly X402OptionConfig[]; // the fields above mirror the first // Each resource's own offers, replacing `options` for it readonly resourceOptions?: Readonly>; + // Experimental; present only when `payments.x402.quote.enabled` is true + readonly quote?: { readonly signerPrivateKey: string }; }; readonly mpp?: { readonly enabled: boolean; diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 4492093..a784964 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -67,6 +67,8 @@ the reserved `_payment` input field. MCP also accepts the selected rail's `_meta` carrier; see [Payment over MCP](protocols/mcp.md#payment-over-mcp). A2A also accepts the x402 extension's task metadata; see [x402 extension](protocols/a2a.md#x402-extension). +With [ERC-8409 quotes](protocols/x402.md#erc-8409-signed-quotes) enabled, the +challenge also carries a signed quote for each EVM offer. ### MPP diff --git a/docs/protocols.md b/docs/protocols.md index 9fdcb6e..58d9b2a 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -5,16 +5,17 @@ subset. Each protocol's page describes that subset in detail. ## Support matrix -| Protocol | Status | Revision | Implemented subset | -| -------- | ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| **HTTP** | Supported | native | resource routes and rail-specific payment headers | -| **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | -| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0`, `@x402/svm@2.25.0`, `@x402/near@2.25.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding | -| **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | -| **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | -| **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | -| **MPP** | Experimental | drafts at `tempoxyz/mpp-specs@806fdb8`; `mppx@0.13.1` | `charge`/`evm`/EIP-3009 over HTTP, MCP and A2A | -| UCP | Planned | - | no implementation | +| Protocol | Status | Revision | Implemented subset | +| ------------ | ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| **HTTP** | Supported | native | resource routes and rail-specific payment headers | +| **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | +| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0`, `@x402/svm@2.25.0`, `@x402/near@2.25.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding | +| **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | +| **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | +| **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | +| **MPP** | Experimental | drafts at `tempoxyz/mpp-specs@806fdb8`; `mppx@0.13.1` | `charge`/`evm`/EIP-3009 over HTTP, MCP and A2A | +| **ERC-8409** | Experimental | draft `SergeevDmitry/ERCs@0ee33230` | signed quotes on x402 EVM offers; issuer published in `/.well-known/agent-commerce` | +| UCP | Planned | - | no implementation | Experimental components are disabled unless configured and support only the subsets their pages describe. Planned means there is no adapter or endpoint. diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md index cea230e..2c21d95 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -67,8 +67,9 @@ must equal the challenge's when present. A mismatch is `invalid_payment_requirem refused before the facilitator is called. `maxTimeoutSeconds` and `extra` keys added by the client are not compared. The facilitator receives the payload without `resource`, since its `resource://` URL is the gateway's own identifier, -and without `extensions`: the gateway advertises none, and the SDK facilitator -would act on a Permit2 gas-sponsoring extension. +and without `extensions`: verification and settlement do not use echoed +[quotes](#erc-8409-signed-quotes), and a Permit2 gas-sponsoring extension would +activate unsupported SDK facilitator behavior. With [several options](../configuration.md#several-options), the challenge uses `resource://agent-commerce/resources/`. The comparison above selects the @@ -118,6 +119,117 @@ other types. Local mode requires Anvil on `eip155:31337`; all public networks require remote mode. Mainnet also requires the guardrails in [configuration.md](../configuration.md#mainnet-guardrails). +## ERC-8409 signed quotes + +Experimental and disabled by default. With +[`payments.x402.quote`](../configuration.md#erc-8409-quotes) enabled, +challenges whose request data can be canonicalized carry one +[ERC-8409](https://github.com/SergeevDmitry/ERCs/blob/0ee33230ca9f9f41a714382552a47cd860465ca7/ERCS/erc-8409.md) +quote per EVM offer, signed with EIP-712 by a dedicated issuer key. The ERC is a +draft; this implementation follows commit `0ee33230`. A buyer can check the +terms and authenticate the signer against the merchant's published issuer +before authorizing a payment. + +HTTP, MCP and A2A carry quotes in +`PaymentRequired.extensions["erc-8409"].info.quotes`. The extension's `schema` +describes `info`, as x402 requires. Each entry contains `chainId` and +`signature` strings, plus a `quote` object whose fields are strings: + +| Quote field | Value | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| `issuer` | `payments.x402.quote.issuer` in `/.well-known/agent-commerce` | +| `payer` | the zero address: a bearer quote issued before the payer is known | +| `recipient`, `asset`, `amount` | the offer's `payTo`, `asset` and `amount` in base units | +| `requestScheme` | `agent-commerce/request/v1` | +| `requestHash` | Keccak-256 of the RFC 8785 serialization of `{ "input": , "resource": }` | +| `quoteId` | 32 random bytes, new for each signed body | +| `validAfter`, `validUntil` | the signing time in Unix seconds, and twice `maxTimeoutSeconds` later | + +The hash uses the validated resource input: the HTTP JSON body, MCP tool +arguments or A2A data part's `input`, with `_payment`, `_authorization` and +`__proto__` removed. Omitted input becomes `{}`. The EIP-712 domain is +`{ name: "Signed Service Payment Quotes", version: "1", chainId }`, with no +`verifyingContract`. A buyer matches a quote to an offer by chain, asset, +recipient and amount, which identify a unique offer within the challenge, +then checks the signer: + +```ts +import { recoverTypedDataAddress } from "viem"; + +const { chainId, quote, signature } = + paymentRequired.extensions["erc-8409"].info.quotes[0]; +const signer = await recoverTypedDataAddress({ + domain: { + name: "Signed Service Payment Quotes", + version: "1", + chainId: Number(chainId), + }, + types: { + ServicePaymentQuote: [ + { name: "issuer", type: "address" }, + { name: "payer", type: "address" }, + { name: "recipient", type: "address" }, + { name: "asset", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "requestScheme", type: "string" }, + { name: "requestHash", type: "bytes32" }, + { name: "quoteId", type: "bytes32" }, + { name: "validAfter", type: "uint64" }, + { name: "validUntil", type: "uint64" }, + ], + }, + primaryType: "ServicePaymentQuote", + message: { + ...quote, + amount: BigInt(quote.amount), + validAfter: BigInt(quote.validAfter), + validUntil: BigInt(quote.validUntil), + }, + signature, +}); +// Accept only if signer is the published issuer, requestHash is the buyer's +// own, and the window is open +``` + +Each provider caches up to 1024 signed quotes and evicts the oldest when full. +Unchanged offer terms and input reuse a cached quote while at least +`maxTimeoutSeconds` remains in its validity window, including on the paid +retry. If the input cannot be represented under RFC 8785, such as a string +with a lone surrogate, the challenge omits quotes. Solana and NEAR offers have +no quotes, and MPP challenges carry none. + +Quotes are informational: verification and settlement use the selected offer +and payer authorization without reading echoed quotes. Altering an echoed +quote does not change payment processing. + +ERC-8409 requires transport bindings to define the following: + +1. Issuer authentication: read the issuer from `/.well-known/agent-commerce` + on the gateway origin that serves MCP and A2A. An operator may also publish + it elsewhere. +2. Request schemes: `agent-commerce/request/v1` only. +3. Carriage: the extension above over HTTP, MCP and A2A. +4. Bearer policy: every quote is a bearer quote and requires separate payer + authorization to move funds. The gateway adds no client nonce. For + non-idempotent requests, this leaves the ERC's nonce recommendation to the + client and application; another payer could race a copied quote. +5. Payer binding: verification checks the EIP-3009 `from` or Permit2 owner. +6. Submission event: the gateway calls the facilitator's `settle` once per + admitted payment attempt, before the backend under `upfront` or after it + succeeds under `authorization`. The replay key blocks resubmission of a + settled or unresolved payment; a confirmed unspent authorization may be + retried. +7. Execution deadline: EIP-3009 `validBefore` or Permit2 `deadline`, enforced + on chain. Clients must check quote validity before authorizing and before + a new submission. The gateway does not enforce the quote window or compare + the payment deadline with `validUntil`. Quote expiry does not cancel a + submitted payment. + +Not implemented: payer-bound quotes; contract issuers (ERC-1271, ERC-6492, +EIP-7702 delegates); quote consumption, single-use enforcement or revocation; +client nonces; quotes for MPP, Solana or NEAR; signed receipts and x402's +`offer-receipt` extension; remote signers and KMS; per-resource toggles. + ## Support matrix Local settlement runs in the offline E2E suite against real contracts. diff --git a/docs/security.md b/docs/security.md index b0c60bc..a889656 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,8 @@ page describes the gateway's trust boundaries, controls, and known limits. Agent input is untrusted. Configuration and environment variables are trusted operator input, so only they may select backend URLs. The payment flow needs no buyer or merchant key; local facilitator mode holds a separate key that pays -gas and broadcasts transactions. +gas and broadcasts transactions. ERC-8409 quotes require a separate, unfunded +signing key. Signing quotes does not transfer funds. ## Secret handling @@ -231,6 +232,9 @@ Available bounds include: is logged only when it matches `[A-Za-z0-9._:-]{1,64}` - an 8192-byte `Agent-Authorization` header cap, checked before decoding; MCP and A2A authorization carriers use the request body cap instead +- a cache of up to 1024 signed ERC-8409 quotes per provider, reused while a + full payment window remains; each cache miss requires a signature per EVM + offer Rate limiting, per-agent quotas, and adaptive backpressure are not implemented. Add them at the edge for public deployments. @@ -257,7 +261,9 @@ Shared x402 deployment checks reject a well-known Anvil recipient on a non-local deployment and reject the zero address. Config validation and x402 provider construction both run these checks. The separate well-known Anvil signer-key check runs only when a local x402 provider is constructed, where it -rejects that key against a non-local RPC. +rejects that key against a non-local RPC. For quote signing keys, the x402 +provider rejects well-known Anvil keys when the offers use non-local networks, +regardless of the RPC host. The MPP provider builds its x402 settlement provider from its own options, so configured and direct MPP construction both run these checks. @@ -319,6 +325,11 @@ real funds. | **a proof of the other transfer method, or one carrying both an EIP-3009 and a Permit2 authorization** | `unsupported_scheme` before the facilitator is called, which would settle the Permit2 one | `tests/unit/payments-x402/provider-remote-facilitator.test.ts`, `tests/unit/payments-x402/provider.test.ts` | | **an EIP-3009 authorization whose value, time bounds or nonce are not in canonical form** | `invalid_payload` before the facilitator is called: amounts and times must be decimal, the nonce 32 bytes of hex | `tests/unit/payments-x402/provider.test.ts` | | **a gas-sponsoring extension in a Permit2 payload** | not forwarded: the facilitator receives no `extensions` | `tests/unit/payments-x402/provider-remote-facilitator.test.ts` | +| **an ERC-8409 quote altered after signing, or signed by another key** | a buyer that verifies the signature against the published issuer rejects it | `tests/unit/payments-x402/quote.test.ts`, `tests/integration/x402-quotes.test.ts` | +| **an altered ERC-8409 quote echoed in a payment** | ignored: verification and settlement use the offer and payer authorization; the facilitator receives no `extensions` | `tests/integration/x402-quotes.test.ts` | +| **a leaked quote signing key** | its holder can sign quotes as the merchant. Keep the key unfunded. Rotation changes the published issuer after a restart; `doctor` detects a gateway still using the old key | `tests/integration/x402-quotes.test.ts`, `tests/unit/cli/doctor.test.ts` | +| **unpaid requests that vary the input to force quote signing** | each cache miss requires a signature per EVM offer; repeated input reuses a cached quote while a full payment window remains | `tests/unit/payments-x402/quote.test.ts` | +| **a quote signing key that controls an EVM `payTo`, or a well-known Anvil key outside the local chain** | `CONFIG_INVALID` during provider construction; the message omits the key | `tests/unit/payments-x402/quote.test.ts` | | **one Permit2 authorization on two requests at once** | settles once; the other gets 409 `PAYMENT_REPLAYED`. The key is chain, Permit2, owner and nonce | `tests/e2e/payment/x402-permit2.e2e.test.ts` | | **a Permit2 offer on a network without Permit2 or its x402 proxy** | health fails, naming the missing contract | `tests/e2e/payment/x402-permit2.e2e.test.ts` | | **a Solana transaction paying another wallet or a smaller amount, presented as the offer** | the facilitator refuses it: `invalid_exact_svm_payload_recipient_mismatch` or `invalid_exact_svm_payload_amount_mismatch`; balances unchanged | `tests/e2e/payment/x402-solana.e2e.test.ts` | diff --git a/src/payments/x402/index.ts b/src/payments/x402/index.ts index 380bc9a..5c972b2 100644 --- a/src/payments/x402/index.ts +++ b/src/payments/x402/index.ts @@ -16,5 +16,6 @@ export { createX402PaymentProvider, type X402MultiOfferProviderOptions, type X402OfferOptions, + type X402PaymentProvider, type X402ProviderOptions, } from './provider'; diff --git a/src/x402.ts b/src/x402.ts index 0973921..1624a5f 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -33,5 +33,6 @@ export { type X402MultiOfferProviderOptions, type X402OfferOptions, type X402PaymentFlow, + type X402PaymentProvider, type X402ProviderOptions, } from './payments/x402'; diff --git a/tests/integration/x402-quotes.test.ts b/tests/integration/x402-quotes.test.ts index 7d74fb7..38bd120 100644 --- a/tests/integration/x402-quotes.test.ts +++ b/tests/integration/x402-quotes.test.ts @@ -281,16 +281,7 @@ describe('ERC-8409 quotes over the gateway', () => { }); it('settles an SDK payment that echoes the extension, and keeps it from the facilitator', async () => { - const challenged = await invokeHttp(); - const payments = new x402Client(); - registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); - // The test token is not in the SDK's spend-control asset list - payments.setSpendControls(false); - const httpClient = new x402HTTPClient(payments); - const required = httpClient.getPaymentRequiredResponse( - (name) => challenged.headers[name.toLowerCase()] as string | undefined, - ); - const payload = await httpClient.createPaymentPayload(required); + const { httpClient, required, payload } = await sdkPayment(); expect(quotesOf(payload)).toEqual(quotesOf(required)); const paid = await invokeHttp(httpClient.encodePaymentSignatureHeader(payload)); @@ -304,4 +295,38 @@ describe('ERC-8409 quotes over the gateway', () => { expect(sent).not.toHaveProperty('extensions'); } }); + + it('settles on the offer alone when the echoed quotes were altered, since payment never reads them', async () => { + const { httpClient, payload } = await sdkPayment(); + const [echoed] = quotesOf(payload); + if (echoed === undefined) throw new Error('no echoed quote'); + const altered = { + ...payload, + extensions: { + 'erc-8409': { + info: { quotes: [{ ...echoed, quote: { ...echoed.quote, amount: '1' } }] }, + }, + }, + }; + + const paid = await invokeHttp(httpClient.encodePaymentSignatureHeader(altered)); + + expect(paid.statusCode).toBe(200); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect(facilitator.settle.mock.calls[0]?.[0]).toMatchObject({ payload: payload.payload }); + }); }); + +// Request an HTTP challenge and sign the payment with the x402 SDK client +async function sdkPayment() { + const challenged = await invokeHttp(); + const payments = new x402Client(); + registerExactEvmScheme(payments, { signer: buyer, networks: ['eip155:31337'] }); + // The test token is not in the SDK's spend-control asset list + payments.setSpendControls(false); + const httpClient = new x402HTTPClient(payments); + const required = httpClient.getPaymentRequiredResponse( + (name) => challenged.headers[name.toLowerCase()] as string | undefined, + ); + return { httpClient, required, payload: await httpClient.createPaymentPayload(required) }; +}