From 85134ec51a3359da240843a950196b91f11e81c7 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 09:13:40 -0700 Subject: [PATCH] feat(payment): buildX402AcceptsFor402 helper + probe.ts contract-correct extra.name (1.3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the python-commerce 1.3.4 cleanup. Two parallel goals: 1. Lift up the boilerplate every Node merchant inlined for emitting x402 challenges (`x402Server.buildPaymentRequirements({...} as never)` + push the result) so the EIP-712-domain-mismatch trap can't recur. 2. Fix the discovery probe + tests + examples that hardcoded `extra: { name: 'USDC' }` regardless of network — wrong on Base mainnet (USDC contract returns `name() = "USD Coin"`), correct on sepolia (`name() = "USDC"`). New public API: buildX402AcceptsFor402(server, { network, price, payTo, scheme?, maxTimeoutSeconds?, extensions? }) Wraps `server.buildPaymentRequirements(...)`, returns the result as a list of plain wire-shape objects ready for the 402 body's `accepts[]`. The x402 scheme registered on the server fills in `extra` from contract metadata — sepolia → `name: 'USDC'`, mainnet → `name: 'USD Coin'`. Probe / examples / tests: - src/discovery/probe.ts: sample accept for `eip155:8453` now emits `extra: { name: 'USD Coin', version: '2' }`; sepolia stays `'USDC'`. - examples/multi-rail-merchant.ts: refactored to use `buildX402AcceptsFor402(x402Server, ...)` instead of the hand-rolled accept. - examples/api-provider.ts: keeps inline (smallest possible example) with a comment pointing at the helper as the production pattern. - tests/discovery/probe.test.ts: assertions updated. - tests/payment/x402_server.test.ts: 3 new tests for the helper. 669 tests pass. Lint + typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- README.md | 15 ++++++ examples/api-provider.ts | 5 ++ examples/multi-rail-merchant.ts | 22 ++++---- package.json | 2 +- src/discovery/probe.ts | 6 ++- src/payment/x402_server.ts | 42 +++++++++++++++ tests/discovery/probe.test.ts | 5 +- tests/payment/x402_server.test.ts | 88 ++++++++++++++++++++++++++++++- 9 files changed, 171 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 499bf39..be153ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Every helper is extracted from a real consumer, not speculated. |---|---| | `@agent-score/commerce/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) | | `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers — `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed` | -| `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, x402 server factory + scheme dual-register, MPP server factory, dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | +| `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `createX402Server` (peer-dep `@x402/core` + `@coinbase/x402` for the Coinbase facilitator), `buildX402AcceptsFor402` (one-call helper for the 402-emit path: builds the requirements via the registered scheme so `extra.name` matches the on-chain USDC contract per network), `createMppxServer` (peer-dep `mppx`), `processX402Settle` (verify+settle in one call), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | | `@agent-score/commerce/discovery` | Discovery probe middleware, Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware | | `@agent-score/commerce/challenge` | 402-body builders: accepted_methods, identity metadata, how_to_pay, agent_instructions, build402Body, `buildValidationError` (4xx body builder) | | `@agent-score/commerce/stripe-multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper | diff --git a/README.md b/README.md index 8a5e5fc..8e7ff9f 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,21 @@ await simulateDepositIfTestMode({ }); ``` +### Build the x402 accepts entry for the 402 challenge + +```typescript +import { buildX402AcceptsFor402 } from '@agent-score/commerce/payment'; + +const x402Accepts = await buildX402AcceptsFor402(x402Server, { + network: X402_BASE, + price: `$${totalUsd}`, + payTo: process.env.TREASURY_BASE_RECIPIENT!, + maxTimeoutSeconds: 300, +}); +``` + +Returns a list of plain objects ready for the 402 body's `accepts[]`. `extra.name` is derived from the registered scheme metadata so the EIP-712 domain matches the on-chain USDC contract. + ### Drop-in 402 + settle (x402) ```typescript diff --git a/examples/api-provider.ts b/examples/api-provider.ts index f725107..27affdc 100644 --- a/examples/api-provider.ts +++ b/examples/api-provider.ts @@ -115,6 +115,11 @@ app.post('/search', async (c) => { request: '', }), ]; + // The minimal-example shape: a hand-rolled accept entry. Production code should + // use `buildX402AcceptsFor402(x402Server, {...})` from `@agent-score/commerce/payment` + // — the helper builds the requirement via the registered x402 scheme so `extra` + // (incl. the EIP-712 `name`/`version` for EVM USDC) is derived from the on-chain + // contract metadata rather than guessed. See `examples/multi-rail-merchant.ts`. const accepts = [ { scheme: 'exact', diff --git a/examples/multi-rail-merchant.ts b/examples/multi-rail-merchant.ts index 2ead588..fd78511 100644 --- a/examples/multi-rail-merchant.ts +++ b/examples/multi-rail-merchant.ts @@ -44,10 +44,10 @@ import { import { agentscoreGate, getAgentScoreData } from '@agent-score/commerce/identity/hono'; import { createMppxServer, + buildX402AcceptsFor402, createX402Server, networks, processX402Settle, - USDC, validateX402NetworkConfig, verifyX402Request, } from '@agent-score/commerce/payment'; @@ -251,16 +251,16 @@ app.post('/purchase', async (c) => { }, x402: { x402Version: 2, - accepts: [ - { - scheme: 'exact', - network: X402_BASE_NETWORK, - amount: String(Math.round(Number(totalUsd) * 1_000_000)), - asset: USDC.base.mainnet.address, - payTo: depositAddresses.base, - maxTimeoutSeconds: 300, - }, - ], + // Build the accept via the registered x402 scheme — fills in `extra` (incl. + // the network-correct USDC `name`) so agents can sign EIP-712 against the + // right domain. Hardcoding `extra` is the trap that breaks every signature + // verify on base mainnet (USDC contract returns "USD Coin", not "USDC"). + accepts: await buildX402AcceptsFor402(x402Server, { + network: X402_BASE_NETWORK, + price: `$${totalUsd}`, + payTo: depositAddresses.base, + maxTimeoutSeconds: 300, + }), resource: { url: c.req.url, mimeType: 'application/json' }, }, }); diff --git a/package.json b/package.json index e86fddc..27e7bdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "1.3.1", + "version": "1.3.2", "description": "Agent commerce SDK — identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/discovery/probe.ts b/src/discovery/probe.ts index b210e91..9809bb4 100644 --- a/src/discovery/probe.ts +++ b/src/discovery/probe.ts @@ -32,7 +32,11 @@ export function sampleX402AcceptForNetwork( asset: USDC.base.mainnet.address, payTo: ZERO_EVM_PAYTO, maxTimeoutSeconds: 300, - extra: { name: 'USDC', version: '2' }, + // ``extra.name`` mirrors the on-chain USDC contract's ``name()`` because + // EIP-712 domain hashes include this string. Wrong name → every signed + // payload fails facilitator verify with ``invalid_exact_evm_payload_signature``. + // Base mainnet USDC returns "USD Coin"; base sepolia USDC returns "USDC". + extra: { name: 'USD Coin', version: '2' }, }; } if (caip2 === networks.base.sepolia.caip2) { diff --git a/src/payment/x402_server.ts b/src/payment/x402_server.ts index 26bd6ea..8fbb10b 100644 --- a/src/payment/x402_server.ts +++ b/src/payment/x402_server.ts @@ -157,6 +157,48 @@ export async function createX402Server(opts: CreateX402ServerOptions = {}): Prom return server; } +export interface BuildX402AcceptsForOptions { + network: string; + price: string; + payTo: string; + scheme?: string; + maxTimeoutSeconds?: number; + extensions?: string[]; +} + +/** + * Build x402 `accepts[]` entries for a 402 challenge body. + * + * Wraps `server.buildPaymentRequirements(...)` so merchants don't have to: + * + * 1. Construct the resource-config object themselves + * 2. Remember to serialize each Pydantic-equivalent requirement back to a + * plain object before stitching it into the 402 body + * 3. Hardcode `extra` (which differs by the actual on-chain contract — base + * mainnet USDC has `name: "USD Coin"`, base sepolia USDC has `name: "USDC"`; + * EIP-712 domain hashes differ, so getting this wrong silently breaks every + * signature verify at the facilitator) + * + * Returns a list of plain objects in the shape that x402 expects on the wire — + * drop them straight into the `accepts` field of the 402 challenge body. + */ +export async function buildX402AcceptsFor402( + server: X402Server, + opts: BuildX402AcceptsForOptions, +): Promise { + const requirements = await server.buildPaymentRequirements( + { + scheme: opts.scheme ?? 'exact', + network: opts.network, + price: opts.price, + payTo: opts.payTo, + maxTimeoutSeconds: opts.maxTimeoutSeconds ?? 300, + }, + opts.extensions, + ); + return Array.isArray(requirements) ? requirements : []; +} + async function dynamicImport(moduleName: string): Promise { try { return (await import(moduleName)) as T; diff --git a/tests/discovery/probe.test.ts b/tests/discovery/probe.test.ts index eab97a9..ea4da7a 100644 --- a/tests/discovery/probe.test.ts +++ b/tests/discovery/probe.test.ts @@ -103,7 +103,9 @@ describe('sampleX402AcceptForNetwork', () => { asset: USDC.base.mainnet.address, payTo: '0x0000000000000000000000000000000000000000', maxTimeoutSeconds: 300, - extra: { name: 'USDC', version: '2' }, + // Base mainnet USDC contract returns ``name() == "USD Coin"``; + // ``extra.name`` mirrors that for EIP-712 domain-hash parity. + extra: { name: 'USD Coin', version: '2' }, }); }); @@ -112,6 +114,7 @@ describe('sampleX402AcceptForNetwork', () => { expect(a?.network).toBe(networks.base.sepolia.caip2); expect(a?.asset).toBe(USDC.base.sepolia.address); expect(a?.amount).toBe('500000'); + // Base sepolia USDC returns ``name() == "USDC"`` (different from mainnet). expect(a?.extra).toEqual({ name: 'USDC', version: '2' }); }); diff --git a/tests/payment/x402_server.test.ts b/tests/payment/x402_server.test.ts index 17789cd..322ab87 100644 --- a/tests/payment/x402_server.test.ts +++ b/tests/payment/x402_server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createX402Server } from '../../src/payment/x402_server'; +import { buildX402AcceptsFor402, createX402Server } from '../../src/payment/x402_server'; describe('createX402Server', () => { it('returns an x402ResourceServer with HTTP facilitator by default', async () => { @@ -51,3 +51,89 @@ describe('createX402Server', () => { expect(server).toBeDefined(); }); }); + +describe('buildX402AcceptsFor402', () => { + it('passes the resource-config kwargs to server.buildPaymentRequirements and returns its array', async () => { + let captured: { config?: Record; extensions?: unknown } = {}; + const fakeServer = { + register: () => {}, + registerExtension: () => {}, + initialize: async () => {}, + buildPaymentRequirements: async (config: Record, extensions?: unknown) => { + captured = { config, extensions }; + return [ + { + scheme: 'exact', + network: config.network, + amount: '100000', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + payTo: config.payTo, + maxTimeoutSeconds: config.maxTimeoutSeconds ?? 300, + extra: { name: 'USD Coin', version: '2' }, + }, + ]; + }, + } as never; + + const accepts = await buildX402AcceptsFor402(fakeServer, { + network: 'eip155:8453', + price: '$0.10', + payTo: '0x000000000000000000000000000000000000dEaD', + }); + // Defaults applied: scheme=exact, maxTimeoutSeconds=300 + expect(captured.config).toEqual({ + scheme: 'exact', + network: 'eip155:8453', + price: '$0.10', + payTo: '0x000000000000000000000000000000000000dEaD', + maxTimeoutSeconds: 300, + }); + expect(captured.extensions).toBeUndefined(); + expect(Array.isArray(accepts)).toBe(true); + expect(accepts).toHaveLength(1); + const accept = accepts[0] as Record; + expect(accept.network).toBe('eip155:8453'); + expect(accept.payTo).toBe('0x000000000000000000000000000000000000dEaD'); + expect(accept.extra).toEqual({ name: 'USD Coin', version: '2' }); + }); + + it('honours scheme + maxTimeoutSeconds + extensions when supplied', async () => { + let captured: { config?: Record; extensions?: unknown } = {}; + const fakeServer = { + register: () => {}, + registerExtension: () => {}, + initialize: async () => {}, + buildPaymentRequirements: async (config: Record, extensions?: unknown) => { + captured = { config, extensions }; + return []; + }, + } as never; + + await buildX402AcceptsFor402(fakeServer, { + network: 'eip155:8453', + price: '$1.00', + payTo: '0xabc', + scheme: 'upto', + maxTimeoutSeconds: 600, + extensions: ['bazaar'], + }); + expect(captured.config?.scheme).toBe('upto'); + expect(captured.config?.maxTimeoutSeconds).toBe(600); + expect(captured.extensions).toEqual(['bazaar']); + }); + + it('returns an empty array when buildPaymentRequirements returns a non-array', async () => { + const fakeServer = { + register: () => {}, + registerExtension: () => {}, + initialize: async () => {}, + buildPaymentRequirements: async () => null, + } as never; + const accepts = await buildX402AcceptsFor402(fakeServer, { + network: 'eip155:8453', + price: '$0.10', + payTo: '0xabc', + }); + expect(accepts).toEqual([]); + }); +});