diff --git a/.env.example b/.env.example index 64d5bb9..e2fee70 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # Tempo / MPP MPP_SECRET_KEY= -USDC_TEMPO= +USDC_TEMPO=0x20c0000000000000000000000000000000000000 MPP_RECIPIENT= MPP_REALM= -MPP_TESTNET=false +MPP_TESTNET=true # x402 X402_PAYEE_ADDRESS= @@ -12,7 +12,7 @@ X402_FACILITATOR_URL=https://x402.org/facilitator X402_ASSET= X402_FACILITATOR_TIMEOUT_MS= -# Optional for Base mainnet CDP facilitator +# Required when X402_FACILITATOR_URL is api.cdp.coinbase.com CDP_API_KEY_ID= CDP_API_KEY_SECRET= diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c84a896 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,274 @@ +# dual402 Agent Guide + +dual402 is an Express middleware that makes routes agent-payable through both +x402 (Base USDC) and MPP (Tempo USDC) at the same time. Use it when the user +wants paid API capabilities that clients can call without first choosing a 402 +protocol. + +This guide is the cold-start reference for a coding agent wiring dual402 into a +fresh Express service. Every section below is load-bearing — skipping any of +them produces a broken or insecure deployment. + +## Install + +```bash +npm install dual402 express@^5 +``` + +`express@^5` is a peer dependency. Node 22 or newer is required. + +## Environment + +All values shown below are example placeholders. Use them only when the host +app does not already have an equivalent configuration layer. + +```env +# MPP / Tempo +MPP_SECRET_KEY= +MPP_RECIPIENT= +MPP_REALM= +MPP_TESTNET=true +USDC_TEMPO=0x20c0000000000000000000000000000000000000 +# Tempo mainnet USDC: 0x20c000000000000000000000b9537d11c60e8b50 + +# x402 +X402_PAYEE_ADDRESS= +X402_NETWORK=eip155:84532 +X402_FACILITATOR_URL=https://x402.org/facilitator +# Optional — defaults to USDC for X402_NETWORK if unset. +X402_ASSET= + +# Required when X402_FACILITATOR_URL host is api.cdp.coinbase.com. +CDP_API_KEY_ID= +CDP_API_KEY_SECRET= + +# Public origin behind proxies. +BASE_URL= +``` + +For Base mainnet, switch every value at once — testnet/mainnet mixes will +fail at startup or at first payment: + +```env +MPP_TESTNET=false +USDC_TEMPO=0x20c000000000000000000000b9537d11c60e8b50 +X402_NETWORK=eip155:8453 +X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 +``` + +`USDC_TEMPO` must match `MPP_TESTNET`. CDP-hosted x402 facilitation requires +`CDP_API_KEY_ID` and `CDP_API_KEY_SECRET`. + +`CDP_API_KEY_SECRET` accepts the formats CDP issues: a PEM block (begins with +`-----BEGIN`), a 48-byte PKCS#8 DER blob (base64-encoded), or a raw Ed25519 +seed (32 or 64 bytes, base64-encoded). dual402 parses any of these via +`parseCdpPrivateKey()` at startup; an unrecognized format throws +`cdp_key_unrecognized: ...` with the byte length in the message. + +## Integration Pattern + +Use `paidRoute()` whenever possible — it ties the Express middleware and the +OpenAPI metadata together so they cannot drift. + +```js +import express from "express"; +import { createDual402, dualDiscovery, paidRoute } from "dual402"; + +const app = express(); +app.use(express.json()); + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +const x402FacilitatorUrl = requiredEnv("X402_FACILITATOR_URL"); +const cdpAuth = + new URL(x402FacilitatorUrl).host === "api.cdp.coinbase.com" + ? { + apiKeyId: requiredEnv("CDP_API_KEY_ID"), + apiKeySecret: requiredEnv("CDP_API_KEY_SECRET"), + } + : undefined; + +const dual = createDual402({ + mpp: { + currency: requiredEnv("USDC_TEMPO"), + recipient: requiredEnv("MPP_RECIPIENT"), + secretKey: requiredEnv("MPP_SECRET_KEY"), + realm: process.env.MPP_REALM, + testnet: process.env.MPP_TESTNET === "true", + }, + x402: { + payTo: requiredEnv("X402_PAYEE_ADDRESS"), + network: requiredEnv("X402_NETWORK"), + facilitatorUrl: x402FacilitatorUrl, + ...(process.env.X402_ASSET && { asset: process.env.X402_ASSET }), + ...(cdpAuth && { cdpAuth }), + }, +}); + +const quote = paidRoute(dual, { + method: "get", + path: "/quote", + amount: "0.02", + // Optional. Defaults to `summary`. Used as the human-readable label inside + // the WWW-Authenticate header — must be printable ASCII (see Guardrails). + paymentDescription: "Quote lookup", + operationId: "getQuote", + summary: "Get a quote", + parameters: [ + { name: "symbol", in: "query", required: true, schema: { type: "string" } }, + ], + responseSchema: { + type: "object", + properties: { + symbol: { type: "string" }, + price: { type: "number" }, + }, + required: ["symbol", "price"], + }, +}); + +function validateQuoteRequest(req, res, next) { + const symbol = String(req.query.symbol ?? "").trim(); + if (!symbol) return res.status(400).json({ error: "symbol is required" }); + req.symbol = symbol.toUpperCase(); + return next(); +} + +app.get(quote.path, validateQuoteRequest, quote.handler, (req, res) => { + res.json({ symbol: req.symbol, price: 42 }); +}); + +dualDiscovery(app, dual, { + info: { + title: "Paid API", + description: "Paid routes accept x402 and MPP.", + version: "1.0.0", + }, + routes: [quote], +}); +``` + +`paidRoute(dual, { ..., paymentDescription })` is the right place to set the +human-readable label that ends up in the MPP `WWW-Authenticate` header. If +omitted, dual402 reuses `summary`. + +## Middleware Ordering + +Place validation that should not require payment **before** `route.handler`, +and place protected work **after** it. This keeps invalid requests free and +keeps the paid work reachable only via a verified credential. + +```js +const runRoute = paidRoute(dual, { + method: "post", + path: "/run", + amount: "0.10", + operationId: "runJob", + summary: "Run a job", + requestBodySchema: { + type: "object", + properties: { jobId: { type: "string" } }, + required: ["jobId"], + }, +}); + +app.post(runRoute.path, validateRequest, runRoute.handler, runPaidWork); +``` + +`validateRequest` rejects malformed inputs with `400` before payment is +required. `runRoute.handler` then issues a 402 if no valid credential is +present, or verifies and settles the payment if one is. `runPaidWork` only +runs after settlement. + +## CORS + +Browser-based agent clients cannot read 402 challenge headers from a +cross-origin response unless the server explicitly exposes them. Without this, +the browser fetch resolves successfully but the agent sees `null` for every +challenge header — and silently fails to retry with payment. + +Always expose all four payment headers: + +```js +app.use((_req, res, next) => { + res.setHeader( + "Access-Control-Expose-Headers", + "WWW-Authenticate, Payment-Receipt, PAYMENT-REQUIRED, PAYMENT-RESPONSE", + ); + next(); +}); +``` + +If the API is browser-accessible, also handle `Access-Control-Allow-*` and +preflight `OPTIONS` — see [examples/minimal-api.js](./examples/minimal-api.js) +for the complete shape. + +## Verify + +Boot the app and request a paid route without a credential: + +```bash +curl -i "http://localhost:8080/quote?symbol=ETH" +``` + +The response must be `402 Payment Required` with both: + +- `WWW-Authenticate: Payment ...` for MPP +- `PAYMENT-REQUIRED: ` for x402 + +If either header is missing, the middleware did not wire up — re-check the +config and ordering above. Discovery should also be live: + +```bash +curl "http://localhost:8080/openapi.json" +curl "http://localhost:8080/.well-known/x402" +``` + +## Guardrails + +- **Fail-fast on missing config.** Resolve every required env var with a + `requiredEnv()` helper that throws at startup. The example above is correct + for production. Do **not** fall back to `process.env.X402_NETWORK || + "eip155:84532"` or `process.env.X402_FACILITATOR_URL || + "https://x402.org/facilitator"` — that silently routes mainnet money to a + testnet facilitator. If a test fixture wants Sepolia defaults, use a + scoped `.env.test` instead of inline `||` fallbacks in the boot code. +- **Keep merchant wallets separate from payer wallets.** Self-transfers fail + on common facilitators. +- **Keep `USDC_TEMPO` and `MPP_TESTNET` aligned.** Testnet Tempo USDC with + `MPP_TESTNET=true`; mainnet Tempo USDC with `MPP_TESTNET=false`. Mixing + them can boot but fail when clients try to pay. +- **Do not use `https://x402.org/facilitator` for Base mainnet.** It is the + testnet facilitator. Use Coinbase's CDP facilitator with `cdpAuth`. + `createDual402()` rejects this combination at startup. +- **Leave x402 USDC metadata as `{ name: "USD Coin", version: "2" }`** unless + the user explicitly knows they need a different EIP-712 domain. +- **Set `BASE_URL` in production.** When the app runs behind a proxy or a + custom domain, the request `Host` header is the internal hostname; without + `BASE_URL`, discovery and challenge `resource` URLs will be wrong. +- **Keep request and response schemas accurate.** Agent clients use the + OpenAPI spec and the 402 schema hints to retry the same request after + paying. Inaccurate schemas break the retry loop. +- **Payment descriptions must be printable ASCII (U+0020–U+007E).** Anything + set as `paymentDescription` (or `description` on `dual.charge()`) is + serialized into HTTP `WWW-Authenticate` header values, which RFC 9110 limits + to ASCII. dual402 throws at config time if you pass non-ASCII characters + (em-dash, smart quotes, etc.). Use plain ASCII or transliterate. + +## Public Surface + +- `createDual402(config)` — validates config, returns a `Dual402Instance`. +- `dual.charge({ amount, description?, waitForSettle? })` — Express + middleware for one paid route. Lower level; prefer `paidRoute()`. +- `paidRoute(dual, options)` — Express middleware **plus** OpenAPI metadata + for one route, in one call. +- `dualDiscovery(app, dual, { info, routes, serviceInfo?, ownershipProofs? })` + — mounts `GET /openapi.json` and `GET /.well-known/x402`. + +All public types — `Dual402Config`, `MppConfig`, `X402Config`, `CdpAuth`, +`ChargeOptions`, `PaidRouteOptions`, `DiscoveryRoute`, `DiscoveryConfig`, +`Dual402Instance`, `JsonSchema` — are exported from the package root. diff --git a/README.md b/README.md index b67ac4d..0cddecc 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,68 @@ # dual402 -One Express middleware. Accepts both x402 (Base USDC) and MPP (Tempo USDC) on every route. One 402 response carries both challenges; the server accepts whichever signed credential comes back. +dual402 collapses the paid API setup into one route definition. Add a price +and schema to an Express route, and you get monetization plus discoverability: +one `402 Payment Required` with both [x402](https://x402.org) (Base USDC) and +[MPP](https://mpp.dev) (Tempo USDC), plus metadata that scanners, agent +markets, and agent clients can index. + +- Monetization: charge pay-per-request USDC without building a billing system. +- Discoverability: publish OpenAPI and `/.well-known/x402` from the same route + definition. +- Protocol reach: accept both x402 and MPP clients out of the box. +- Express ergonomics: validate first, charge, then run the handler. -```bash -npm install dual402 -``` - -Starter template: https://github.com/mmurrs/dual402-starter - -Protocol references: [x402.org](https://x402.org) · [mpp.dev](https://mpp.dev). - -## Quick prompt - -Hand this to your coding agent and it can take it from here: +## Install +```bash +npm install dual402 express@^5 ``` -Read github.com/mmurrs/dual402 and add dual x402 + MPP payments to my Express service. -``` - -## Scope -- x402: EVM-style payee / asset configuration, facilitator-based verify + settle -- MPP: delegated to `mppx` / Tempo -- Discovery: `GET /openapi.json` plus `GET /.well-known/x402` +Node 22 or newer is required. `express@^5` is a peer dependency. -This package is opinionated toward the production patterns used in `NYCTransitLive-x402`: strict local amount/payee checks, CDP auth support for Base mainnet, minimal static discovery, and challenge metadata that helps AgentCash-style clients retry correctly. +## Quickstart: Monetize + Discover -## Quickstart - -Three pieces: create the middleware, define a paid route, expose discovery. +The key helper is `paidRoute()`: it creates the payment middleware and the +discovery metadata together. ```js import express from "express"; import { createDual402, dualDiscovery, paidRoute } from "dual402"; const app = express(); +app.use(express.json()); + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +const facilitatorUrl = requiredEnv("X402_FACILITATOR_URL"); +const cdpAuth = + new URL(facilitatorUrl).host === "api.cdp.coinbase.com" + ? { + apiKeyId: requiredEnv("CDP_API_KEY_ID"), + apiKeySecret: requiredEnv("CDP_API_KEY_SECRET"), + } + : undefined; -// 1. One-time setup: explicit payment config, matching mppx/x402 style const dual = createDual402({ mpp: { - currency: process.env.USDC_TEMPO, - recipient: process.env.MPP_RECIPIENT, - secretKey: process.env.MPP_SECRET_KEY, + currency: requiredEnv("USDC_TEMPO"), + recipient: requiredEnv("MPP_RECIPIENT"), + secretKey: requiredEnv("MPP_SECRET_KEY"), testnet: process.env.MPP_TESTNET === "true", }, x402: { - payTo: process.env.X402_PAYEE_ADDRESS, - network: process.env.X402_NETWORK || "eip155:84532", - facilitatorUrl: process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator", + payTo: requiredEnv("X402_PAYEE_ADDRESS"), + network: requiredEnv("X402_NETWORK"), + facilitatorUrl, + ...(process.env.X402_ASSET && { asset: process.env.X402_ASSET }), + ...(cdpAuth && { cdpAuth }), }, }); -// 2. Define the paid route once. The returned object is used for both -// Express middleware and discovery metadata. const quote = paidRoute(dual, { method: "get", path: "/quote", @@ -64,56 +74,58 @@ const quote = paidRoute(dual, { ], responseSchema: { type: "object", - properties: { symbol: { type: "string" }, price: { type: "number" } }, + properties: { + symbol: { type: "string" }, + price: { type: "number" }, + }, required: ["symbol", "price"], }, }); -app.get(quote.path, quote.handler, (req, res) => { - res.json({ symbol: req.query.symbol, price: 42 }); +function validateQuote(req, res, next) { + const symbol = String(req.query.symbol ?? "").trim(); + if (!symbol) return res.status(400).json({ error: "symbol is required" }); + req.symbol = symbol.toUpperCase(); + return next(); +} + +app.get(quote.path, validateQuote, quote.handler, (req, res) => { + res.json({ symbol: req.symbol, price: 42 }); }); -// 3. Expose /openapi.json and /.well-known/x402 dualDiscovery(app, dual, { - info: { - title: "Example API", - description: "Paid quote API", - version: "1.0.0", - }, + info: { title: "Quote API", description: "", version: "1.0.0" }, routes: [quote], }); ``` -That's the whole surface. Every unauthenticated request gets a 402 with both payment challenges; any compliant client pays and proceeds. +That is the collapsed flow: `/quote` is monetized, discoverable, and still just +an Express route. Invalid requests stay free, valid unpaid requests receive both +payment challenges, and paid retries continue to the protected handler. -Looking for a runnable project you can deploy? The [starter](https://github.com/mmurrs/dual402-starter) wires this up with a Dockerfile and a one-command EigenCompute deploy. - -## Try the Example +## Example ```bash cp .env.example .env -# Fill in MPP_SECRET_KEY, USDC_TEMPO, MPP_RECIPIENT, X402_PAYEE_ADDRESS. +npm run build node --env-file=.env examples/minimal-api.js ``` -Then inspect the unpaid challenge and discovery document: - ```bash curl -i "http://localhost:8080/quote?symbol=ETH" -curl "http://localhost:8080/openapi.json" +curl "http://localhost:8080/openapi.json" +curl "http://localhost:8080/.well-known/x402" ``` -## Install - -```bash -npm install dual402 express -``` +## Config -`express` is a peer dependency. `mppx` is the MPP reference implementation used under the hood. +Start from [.env.example](.env.example). The required values are: -## Base Mainnet +- MPP: `MPP_SECRET_KEY`, `USDC_TEMPO`, `MPP_RECIPIENT`, `MPP_TESTNET` +- x402: `X402_PAYEE_ADDRESS`, `X402_NETWORK`, `X402_FACILITATOR_URL` +- recommended behind proxies: `BASE_URL` -For Base mainnet, do not use `https://x402.org/facilitator`. That host is for Base Sepolia testing. Use Coinbase's CDP facilitator instead: +For Base mainnet, use Coinbase's CDP facilitator and credentials: ```env X402_NETWORK=eip155:8453 @@ -122,94 +134,33 @@ CDP_API_KEY_ID=... CDP_API_KEY_SECRET=... ``` -And configure: - -```js -x402: { - payTo: process.env.X402_PAYEE_ADDRESS, - network: process.env.X402_NETWORK, - facilitatorUrl: process.env.X402_FACILITATOR_URL, - cdpAuth: { - apiKeyId: process.env.CDP_API_KEY_ID, - apiKeySecret: process.env.CDP_API_KEY_SECRET, - }, -} -``` +`createDual402()` fails fast for common money-routing mistakes, including Base +mainnet with the public Sepolia facilitator and CDP facilitator usage without +CDP credentials. -Two practical rules matter here: +## API -- `extra.name` for USDC must resolve to `USD Coin`, not `USDC` -- your merchant wallet must differ from the wallet you use to test payments +- `createDual402(config)` validates shared x402 and MPP configuration. +- `paidRoute(dual, options)` creates route middleware and discovery metadata. +- `dualDiscovery(app, dual, config)` mounts `GET /openapi.json` and + `GET /.well-known/x402`. +- `dual.charge({ amount, description?, waitForSettle? })` is the lower-level + middleware factory when you do not need discovery metadata. -The middleware defaults USDC's x402 metadata to `{ name: "USD Coin", version: "2" }` for this reason. +For production integration details, hand the packaged agent guide to your +coding agent: -## Discovery Notes - -`dualDiscovery()` keeps `/.well-known/x402` intentionally minimal: - -```json -{ "version": 1, "resources": ["GET /quote"] } -``` - -`/openapi.json` carries the richer service metadata. Paid operations include the canonical `x-payment-info.offers[]` shape used by MPP discovery, with both Tempo and x402 offers for the same route. Runtime `PAYMENT-REQUIRED` remains authoritative for exact payment terms and also carries Bazaar-style request/response schema hints so clients can preserve inputs on paid retries. - -## Standards Alignment - -`dual402` keeps the public API close to the two reference libraries: - -- MPP/mppx: one server object, one `charge({ amount })` middleware per protected route, and OpenAPI discovery with `x-payment-info.offers[]`. -- x402: route-level payment requirements with `scheme`, `network`, `payTo`, facilitator verify/settle, and a `PAYMENT-REQUIRED` challenge clients can pay and retry. - -The helper path is intentionally narrower than either underlying SDK: - -```js -const dual = createDual402({ - mpp: { currency, recipient, secretKey }, - x402: { payTo, network, facilitatorUrl }, -}); -const quote = paidRoute(dual, { - method: "get", - path: "/quote", - amount: "0.02", - operationId: "getQuote", - summary: "Get a quote", -}); -app.get(quote.path, quote.handler, handler); -dualDiscovery(app, dual, { routes: [quote] }); +```text +Install dual402, read node_modules/dual402/AGENTS.md, and make my Express API +accept paid requests through both x402 and MPP. ``` -Use `dual.charge()` directly when you want the lower-level mppx-style middleware shape. - -## Config - -Core values are shown in [.env.example](.env.example). - -- `MPP_SECRET_KEY`, `USDC_TEMPO`, `MPP_RECIPIENT` are required for MPP -- `X402_PAYEE_ADDRESS`, `X402_NETWORK`, `X402_FACILITATOR_URL` are required for x402 -- `BASE_URL` is recommended when the service sits behind a proxy or custom domain -- `MPP_REALM` lets you override the realm advertised in MPP challenges -- `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET` are only needed for CDP-hosted facilitation - ## Testing ```bash npm test ``` -The smoke suite covers: - -- config validation -- dual 402 header injection -- route-scoped discovery metadata -- CDP verify / settle wire format -- fail-closed local payee checks - -## Notes - -- x402 settlement is blocking by default; use `waitForSettle: false` only for low-value routes -- `PAYMENT-RESPONSE` is kept for clients, but logs mask transaction hashes -- `BASE_URL` is preferred over whatever internal host the app sees at runtime - -## Architecture +## License -[ARCHITECTURE.md](ARCHITECTURE.md) has the longer protocol walkthrough. +MIT diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..28c06ff --- /dev/null +++ b/examples/README.md @@ -0,0 +1,32 @@ +# dual402 Examples + +Standalone examples for the dual x402 + MPP Express middleware. + +## Examples + +| Example | Description | +| --- | --- | +| [minimal-api.js](./minimal-api.js) | Smallest possible paid API: one `GET /quote` route plus discovery. Best place to start. | + +## Running + +From the repository root: + +```bash +cp .env.example .env +# Fill in MPP_SECRET_KEY, USDC_TEMPO, MPP_RECIPIENT, X402_PAYEE_ADDRESS, +# X402_NETWORK, X402_FACILITATOR_URL. +npm run build +node --env-file=.env examples/minimal-api.js +``` + +Then inspect the unpaid challenge and discovery documents: + +```bash +curl -i "http://localhost:8080/quote?symbol=ETH" +curl "http://localhost:8080/openapi.json" +curl "http://localhost:8080/.well-known/x402" +``` + +A 402 response carries `WWW-Authenticate` (MPP) and `PAYMENT-REQUIRED` (x402) +on the same request. Any compliant client can pay either offer and retry. diff --git a/examples/findmea-migration.js b/examples/findmea-migration.js deleted file mode 100644 index 5669de1..0000000 --- a/examples/findmea-migration.js +++ /dev/null @@ -1,295 +0,0 @@ -/** - * FindMeA — NYC Transit API - * - * Migration sketch: MPP-only -> dual402 (x402 + MPP) - * - * What changed: - * - `mppx/express` -> `dual402` - * - `Mppx.create()` -> `createDual402()` (one-time setup with both protocol configs) - * - `mppx.charge()` -> `dual.charge()` (same call signature, adds x402 under the hood) - * - `discovery()` -> `dualDiscovery()` (mounts /openapi.json AND /.well-known/x402) - * - Handler functions: UNCHANGED - * - * New env vars: X402_PAYEE_ADDRESS, X402_NETWORK, X402_FACILITATOR_URL, MPP_TESTNET - */ - -import express from "express"; -import { fileURLToPath } from "url"; -import path from "path"; -import { createRequire } from "module"; - -// --- BEFORE --- -// import { Mppx, tempo, discovery } from "mppx/express"; - -// --- AFTER --- -import { createDual402, dualDiscovery } from "dual402"; - -const require = createRequire(import.meta.url); -const GtfsRealtimeBindings = require("gtfs-realtime-bindings"); - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const app = express(); -const PORT = process.env.PORT || 8080; - -// CORS — expose BOTH protocol headers -app.use((req, res, next) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "*"); - res.setHeader( - "Access-Control-Expose-Headers", - // MPP headers + x402 headers - "WWW-Authenticate, Payment-Receipt, PAYMENT-REQUIRED, PAYMENT-RESPONSE" - ); - if (req.method === "OPTIONS") return res.sendStatus(204); - next(); -}); - -// --- Payment setup --- -// -// BEFORE: Two separate concepts (Mppx.create + mppx.charge) -// AFTER: One setup, shared price, protocol-specific config - -const dual = createDual402({ - mpp: { - currency: process.env.USDC_TEMPO, - recipient: process.env.MPP_RECIPIENT, - secretKey: process.env.MPP_SECRET_KEY, - testnet: process.env.MPP_TESTNET === "true", - }, - x402: { - payTo: process.env.X402_PAYEE_ADDRESS, - network: process.env.X402_NETWORK || "eip155:84532", - facilitatorUrl: - process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator", - }, -}); - -// charge() calls look exactly the same — amount + description per route -const chargeCitibike = dual.charge({ - amount: "0.02", - description: "Citi Bike station lookup", -}); - -const chargeSubway = dual.charge({ - amount: "0.02", - description: "Subway arrival lookup", -}); - -const chargeBus = dual.charge({ - amount: "0.02", - description: "Bus arrival lookup", -}); - -// --- Discovery --- -// -// BEFORE: discovery(app, mppx, { ... }) -> only /openapi.json -// AFTER: dualDiscovery(app, dual, { ... }) -> /openapi.json + /.well-known/x402 -// -// New in dual402 v0.2+: AgentCash-compliant OpenAPI 3.1.0 spec -// Required fields per route: operationId, tags (optional but recommended), parameters (for GET) - -dualDiscovery(app, dual, { - info: { - title: "FindMeA — NYC Transit API", - description: - "Real-time NYC transit for agents. Citi Bike stations, subway arrivals, and bus predictions — $0.02 per lookup via MPP or x402.", - version: "2.1.0", - "x-guidance": - "All endpoints require lat/lng coordinates. Use limit parameter to control result count (default 3, max 10). Each request costs $0.02 via MPP or x402 payment.", - }, - serviceInfo: { - categories: ["transportation", "transit", "nyc", "citibike", "subway", "bus"], - docs: { homepage: "https://findmea-nyc.vercel.app" }, - }, - ownershipProofs: [], - routes: [ - { - method: "get", - path: "/citibike/nearest", - handler: chargeCitibike, - operationId: "findNearestCitibikeBikes", - tags: ["citibike", "bikes"], - summary: - "Find nearest Citi Bike stations with available bikes, e-bike counts, and walking time.", - parameters: [ - { - name: "lat", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Latitude coordinate", - }, - { - name: "lng", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Longitude coordinate", - }, - { - name: "limit", - in: "query", - required: false, - schema: { type: "integer", minimum: 1, maximum: 10, default: 3 }, - description: "Number of results to return", - }, - ], - }, - { - method: "get", - path: "/citibike/dock", - handler: chargeCitibike, - operationId: "findNearestCitibikeDocks", - tags: ["citibike", "docks"], - summary: "Find nearest Citi Bike stations with available docks for parking.", - parameters: [ - { - name: "lat", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Latitude coordinate", - }, - { - name: "lng", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Longitude coordinate", - }, - { - name: "limit", - in: "query", - required: false, - schema: { type: "integer", minimum: 1, maximum: 10, default: 3 }, - description: "Number of results to return", - }, - ], - }, - { - method: "get", - path: "/subway/nearest", - handler: chargeSubway, - operationId: "findNearestSubwayArrivals", - tags: ["subway", "mta"], - summary: - "Find nearest subway stations with real-time train arrivals. Returns upcoming trains with ETAs, lines, and direction.", - parameters: [ - { - name: "lat", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Latitude coordinate", - }, - { - name: "lng", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Longitude coordinate", - }, - { - name: "limit", - in: "query", - required: false, - schema: { type: "integer", minimum: 1, maximum: 10, default: 3 }, - description: "Number of results to return", - }, - ], - }, - { - method: "get", - path: "/bus/nearest", - handler: chargeBus, - operationId: "findNearestBusArrivals", - tags: ["bus", "mta"], - summary: - "Find nearest bus stops with real-time arrival predictions. Returns routes, destinations, and ETAs.", - parameters: [ - { - name: "lat", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Latitude coordinate", - }, - { - name: "lng", - in: "query", - required: true, - schema: { type: "number", format: "float" }, - description: "Longitude coordinate", - }, - { - name: "limit", - in: "query", - required: false, - schema: { type: "integer", minimum: 1, maximum: 10, default: 3 }, - description: "Number of results to return", - }, - ], - }, - ], -}); - -// --- Route handlers (UNCHANGED from MPP-only version) --- - -function haversine(lat1, lon1, lat2, lon2) { - const R = 6_371_000; - const toRad = (d) => (d * Math.PI) / 180; - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -function parseLookupQuery(query) { - const lat = Number.parseFloat(query.lat); - const lng = Number.parseFloat(query.lng); - if (!Number.isFinite(lat) || !Number.isFinite(lng)) - return { error: "lat and lng query params required" }; - if (query.limit === undefined) return { value: { lat, lng, limit: 3 } }; - const limit = Number.parseInt(query.limit, 10); - if (!Number.isInteger(limit) || limit < 1 || limit > 10) - return { error: "limit must be an integer between 1 and 10" }; - return { value: { lat, lng, limit } }; -} - -function validateLookupQuery(req, res, next) { - const parsed = parseLookupQuery(req.query); - if (parsed.error) return res.status(400).json({ error: parsed.error }); - req.lookupQuery = parsed.value; - next(); -} - -// Routes — only change is chargeCitibike/chargeSubway/chargeBus now dual-protocol -app.get("/citibike/nearest", validateLookupQuery, chargeCitibike, async (req, res) => { - // ... existing handler, completely unchanged - res.json({ results: [] }); // placeholder -}); - -app.get("/citibike/dock", validateLookupQuery, chargeCitibike, async (req, res) => { - res.json({ results: [] }); -}); - -app.get("/subway/nearest", validateLookupQuery, chargeSubway, async (req, res) => { - res.json({ results: [] }); -}); - -app.get("/bus/nearest", validateLookupQuery, chargeBus, async (req, res) => { - res.json({ results: [] }); -}); - -// --- Static routes (unchanged) --- - -app.get("/", (req, res) => { - res.sendFile(path.join(__dirname, "index.html")); -}); - -app.listen(PORT, () => { - console.log(`FindMeA NYC Transit API on http://localhost:${PORT}`); -}); diff --git a/examples/minimal-api.js b/examples/minimal-api.js index f89c0b1..fe9f3ed 100644 --- a/examples/minimal-api.js +++ b/examples/minimal-api.js @@ -15,17 +15,34 @@ app.use((_req, res, next) => { next(); }); +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +const x402FacilitatorUrl = requiredEnv("X402_FACILITATOR_URL"); +const cdpAuth = + new URL(x402FacilitatorUrl).host === "api.cdp.coinbase.com" + ? { + apiKeyId: requiredEnv("CDP_API_KEY_ID"), + apiKeySecret: requiredEnv("CDP_API_KEY_SECRET"), + } + : undefined; + const dual = createDual402({ mpp: { - currency: process.env.USDC_TEMPO, - recipient: process.env.MPP_RECIPIENT, - secretKey: process.env.MPP_SECRET_KEY, + currency: requiredEnv("USDC_TEMPO"), + recipient: requiredEnv("MPP_RECIPIENT"), + secretKey: requiredEnv("MPP_SECRET_KEY"), testnet: process.env.MPP_TESTNET === "true", }, x402: { - payTo: process.env.X402_PAYEE_ADDRESS, - network: process.env.X402_NETWORK || "eip155:84532", - facilitatorUrl: process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator", + payTo: requiredEnv("X402_PAYEE_ADDRESS"), + network: requiredEnv("X402_NETWORK"), + facilitatorUrl: x402FacilitatorUrl, + ...(process.env.X402_ASSET && { asset: process.env.X402_ASSET }), + ...(cdpAuth && { cdpAuth }), }, }); @@ -69,9 +86,15 @@ dualDiscovery(app, dual, { routes: [quote], }); -app.get(quote.path, quote.handler, (req, res) => { - const symbol = String(req.query.symbol ?? "").toUpperCase(); +function validateQuoteRequest(req, res, next) { + const symbol = String(req.query.symbol ?? "").trim().toUpperCase(); if (!symbol) return res.status(400).json({ error: "symbol is required" }); + req.symbol = symbol; + return next(); +} + +app.get(quote.path, validateQuoteRequest, quote.handler, (req, res) => { + const symbol = req.symbol; res.json({ symbol, price: 42, currency: "USD" }); }); diff --git a/package-lock.json b/package-lock.json index 283a1c4..a374f04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.2", "license": "MIT", "dependencies": { - "mppx": "0.6.14" + "mppx": "0.6.27" }, "devDependencies": { "@types/express": "^5.0.0", @@ -93,6 +93,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scalar/openapi-types": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.8.0.tgz", + "integrity": "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -130,9 +139,9 @@ } }, "node_modules/@toon-format/toon": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.2.0.tgz", - "integrity": "sha512-FMYqrlZnMN72YIT9KVt7Kxc41gat+RgMIzDmvRRPHw0J7pqW/FeBGDY/4BIWjT71Y+EdI9fCJip90uXuGuYhjw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz", + "integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==", "license": "MIT" }, "node_modules/@types/body-parser": { @@ -234,9 +243,9 @@ } }, "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.4.tgz", + "integrity": "sha512-dpKH+N27vRjarMVTFFkeY445VTKftzGWpL0FiT7xmVmzQRKazZexzC5uHG0f6XKsVLAuUlndnbGau6lRejClxg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/wevm" @@ -707,13 +716,14 @@ } }, "node_modules/incur": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/incur/-/incur-0.3.25.tgz", - "integrity": "sha512-jrSkzauM42ilbQJ6THVkAY6dTulkyVW0sZpVHdA8gfiBwrLrLnLUf8U3bAOegAKBIMSOFgk1idchgu9xm9HMng==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/incur/-/incur-0.4.6.tgz", + "integrity": "sha512-vrvmmZmfhU0OOm+KuofBClaYaioJ0JrxPn89Zfp8TDfXOBgWsDXqX5QgZS6FItvizqXEuzaoNiBiRgC5vJazDg==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@modelcontextprotocol/server": "^2.0.0-alpha.2", + "@scalar/openapi-types": "^0.8.0", "@toon-format/toon": "^2.1.0", "tokenx": "^1.3.0", "yaml": "^2.8.2", @@ -828,14 +838,14 @@ } }, "node_modules/mppx": { - "version": "0.6.14", - "resolved": "https://registry.npmjs.org/mppx/-/mppx-0.6.14.tgz", - "integrity": "sha512-sux4amv+pIPR/Wf2znvgnh76DG/gWGAplzLuCvEbaYfGV9aycRrZc3u6vttws/prJmYs7+9qkx+/I4gonNqR8w==", + "version": "0.6.27", + "resolved": "https://registry.npmjs.org/mppx/-/mppx-0.6.27.tgz", + "integrity": "sha512-7KxM+Uau7dDcOBI9RjJYSLcDlF7glAC09eX6h64AopmQ9zZXN5gicSg7Ty8hmutXkUglEFPG/8YfWXGK4CQXSw==", "license": "MIT", "dependencies": { - "incur": "^0.3.25", - "ox": "0.14.18", - "zod": "^4.3.6" + "incur": "^0.4.5", + "ox": "0.14.22", + "zod": "^4.4.3" }, "bin": { "mppx": "dist/bin.js", @@ -845,8 +855,8 @@ "@modelcontextprotocol/sdk": ">=1.25.0", "elysia": ">=1", "express": ">=5", - "hono": ">=4.12.14", - "viem": ">=2.47.5" + "hono": ">=4.12.18", + "viem": ">=2.50.4" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -863,36 +873,6 @@ } } }, - "node_modules/mppx/node_modules/ox": { - "version": "0.14.18", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.18.tgz", - "integrity": "sha512-1Irk/tvMsw7xJDuCTT/u9azSjz0YX9hrYFgJOacIuFwibaW2zZBXAMrpzegndYb5o8GLpxB6/0qro4/c40q6VQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.2.3", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -947,9 +927,9 @@ } }, "node_modules/ox": { - "version": "0.14.13", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.13.tgz", - "integrity": "sha512-N3slDyEUq3qGw/53Xd8YZPZD7NUbbiOJDeWKvQ1ElNo2mFjjz6cV2TIbGenHw7k5ATcefDQh42dwUWoGtxU9Hg==", + "version": "0.14.22", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.22.tgz", + "integrity": "sha512-nb5msL8qWbPglhIfZbGJAfw3cqiJjFMiWmACt7kgyWtLib12tcctbHufMT9Hb0Lr6Pt4k9I3dbpueTpbhvbqvA==", "funding": [ { "type": "github", @@ -957,7 +937,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", @@ -1291,9 +1270,9 @@ } }, "node_modules/viem": { - "version": "2.47.11", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.11.tgz", - "integrity": "sha512-UbEKBW11wKI+TkGMl3ONPvFYN2tV0Srhtm6Bbvct/cirhBRNfv0hB3S1Pnn/zIl+U0RvNEm+yRXEOQVIr8rK+g==", + "version": "2.50.4", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.50.4.tgz", + "integrity": "sha512-rf98F4s3Vlb+uJZEKfay3IbBw3CNCbVtx5Y3UIljlO2tSX420g/J0WQSYsjzBSasUFgxgsXabji14O9kGbiqgg==", "funding": [ { "type": "github", @@ -1309,8 +1288,8 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.13", - "ws": "8.18.3" + "ox": "0.14.22", + "ws": "8.20.1" }, "peerDependencies": { "typescript": ">=5.0.4" @@ -1321,6 +1300,28 @@ } } }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1329,9 +1330,9 @@ "peer": true }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "peer": true, "engines": { @@ -1351,9 +1352,9 @@ } }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -1366,9 +1367,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index a9b9bfb..f187783 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dual402", "version": "0.1.2", - "description": "Express middleware that accepts both x402 and MPP payments on the same route", + "description": "Make Express APIs agent-payable with x402 and MPP on the same route, plus OpenAPI and /.well-known/x402 discovery.", "type": "module", "main": "dist/express.js", "types": "dist/express.d.ts", @@ -16,8 +16,11 @@ } }, "files": [ + "AGENTS.md", + ".env.example", "dist", - "examples", + "examples/README.md", + "examples/minimal-api.js", "README.md", "ARCHITECTURE.md", "LICENSE" @@ -33,7 +36,7 @@ "prepare": "npm run build" }, "dependencies": { - "mppx": "0.6.14" + "mppx": "0.6.27" }, "engines": { "node": ">=22" @@ -57,10 +60,20 @@ "keywords": [ "x402", "mpp", + "mppx", "micropayments", "express", + "express-middleware", + "402", "payments", + "stablecoin", + "usdc", "tempo", + "base", + "coinbase", + "cdp", + "agentic", + "agents", "agentcash" ] } diff --git a/src/express.ts b/src/express.ts index 73c6e4e..07bb4d8 100644 --- a/src/express.ts +++ b/src/express.ts @@ -1,7 +1,19 @@ /** - * dual402/express + * dual402 * - * Public entrypoint for the dual x402 + MPP middleware. + * Express middleware that gates a route behind both x402 (Base USDC, via a + * facilitator) and MPP (Tempo USDC, via mppx) at the same time. One 402 + * response carries both challenges; the server accepts whichever signed + * credential the client returns. + * + * Three public functions: + * + * - {@link createDual402} — validate config and mint a {@link Dual402Instance}. + * - {@link paidRoute} — Express middleware + OpenAPI metadata for one route. + * - {@link dualDiscovery} — mount `GET /openapi.json` and `GET /.well-known/x402`. + * + * `Dual402Instance.charge()` is the lower-level middleware factory; prefer + * {@link paidRoute} so the discovery layer can read the same metadata. */ import type { Express, NextFunction, Request, RequestHandler, Response } from "express"; @@ -28,43 +40,74 @@ export type { JsonSchema } from "./internal/x402.js"; export { maskHex } from "./internal/x402.js"; export { parseCdpPrivateKey } from "./internal/cdp.js"; -/** MPP (Tempo USDC) configuration — passed to {@link createDual402}. */ +/** MPP (Tempo USDC) configuration. Passed to {@link createDual402} as `mpp`. */ export type MppConfig = { - /** Tempo USDC token contract address. Mainnet `0x20C0...E8b50`, testnet `0x20c0...0000`. */ + /** + * Tempo USDC token contract address. + * - Mainnet: `0x20c000000000000000000000b9537d11c60e8b50` + * - Testnet: `0x20c0000000000000000000000000000000000000` + * + * Must align with `testnet` — mixing them will fail when mppx tries to settle. + */ currency: `0x${string}`; - /** EVM address that receives MPP payments. Must not equal the payer's wallet. */ + /** EVM address that receives MPP payments. Must not equal any wallet you use to test paying — self-transfers fail on common facilitators. */ recipient: `0x${string}`; - /** HMAC key used by mppx to sign/verify payment challenges. 32+ random bytes. */ + /** HMAC key used by mppx to bind challenges to this server. 32+ random bytes; generate with `openssl rand -hex 32`. */ secretKey: string; - /** Hostname advertised in MPP `WWW-Authenticate` challenges. Defaults to the request host; set when behind a proxy. */ + /** Hostname advertised in MPP `WWW-Authenticate` challenges. Defaults to the request `Host` header, then `BASE_URL`. Set explicitly when running behind a proxy. */ realm?: string; - /** Use Tempo testnet (chain 42431) instead of mainnet (chain 4217). */ + /** Use Tempo testnet (chain 42431) instead of mainnet (chain 4217). Default `false`. */ testnet?: boolean; }; -/** Coinbase Developer Platform credentials for the CDP-hosted x402 facilitator. Required on Base mainnet. */ +/** + * Coinbase Developer Platform credentials for the CDP-hosted x402 facilitator. + * Required when {@link X402Config.facilitatorUrl} resolves to `api.cdp.coinbase.com`, + * which is the case for Base mainnet. + */ export type CdpAuth = { - /** UUID from portal.cdp.coinbase.com (or `organizations/.../apiKeys/...` path). */ + /** UUID from `portal.cdp.coinbase.com`, or the full `organizations/.../apiKeys/...` path. */ apiKeyId: string; - /** Base64 Ed25519 key or PEM block. Keep out of logs. */ + /** + * The CDP private key. Accepts any of the formats CDP issues: + * - PEM block (begins with `-----BEGIN`) + * - 48-byte PKCS#8 DER blob, base64-encoded + * - Raw Ed25519 seed (32 or 64 bytes), base64-encoded + * + * Treated as a secret; never logged. + */ apiKeySecret: string; }; -/** x402 (EVM USDC) configuration — passed to {@link createDual402}. */ +/** x402 (EVM USDC) configuration. Passed to {@link createDual402} as `x402`. */ export type X402Config = { - /** EVM address that receives x402 payments. Must not equal the payer's wallet. */ + /** EVM address that receives x402 payments. Must not equal any wallet you use to test paying. */ payTo: `0x${string}`; - /** CAIP-2 chain ID. `eip155:8453` for Base mainnet, `eip155:84532` for Base Sepolia. */ + /** + * CAIP-2 chain ID. + * - Base mainnet: `"eip155:8453"` + * - Base Sepolia: `"eip155:84532"` + * - Ethereum mainnet: `"eip155:1"` + * + * Anything else needs an explicit `asset` because dual402 only ships the + * default USDC for the chains above. + */ network: string; - /** Facilitator `/verify` + `/settle` endpoint. Mainnet: `https://api.cdp.coinbase.com/platform/v2/x402` (needs `cdpAuth`). */ + /** + * Facilitator `/verify` + `/settle` endpoint. + * - Base mainnet: `"https://api.cdp.coinbase.com/platform/v2/x402"` — requires {@link cdpAuth}. + * - Base Sepolia: `"https://x402.org/facilitator"`. + * + * Pointing Base mainnet at the Sepolia host throws at startup. + */ facilitatorUrl: string; - /** USDC contract address. Defaults to the known USDC for `network` if unset. */ + /** USDC contract address. Defaults to the known USDC for {@link network}; set explicitly to use a different stablecoin. */ asset?: `0x${string}`; - /** EIP-712 domain for the asset. Defaults to `{ name: "USD Coin", version: "2" }` — do not override unless you know what you're doing. */ + /** EIP-712 domain for the asset. Defaults to `{ name: "USD Coin", version: "2" }` — only override if the user explicitly knows the asset's domain differs. */ extra?: { name: string; version: string }; - /** Facilitator fetch timeout in ms. Default 5000, override via `X402_FACILITATOR_TIMEOUT_MS`. */ + /** Facilitator fetch timeout in ms. Default `5000`. Overridable at process scope via the `X402_FACILITATOR_TIMEOUT_MS` env var. */ timeoutMs?: number; - /** CDP credentials. Required when `facilitatorUrl` is CDP-hosted. */ + /** CDP credentials. Required when {@link facilitatorUrl} host is `api.cdp.coinbase.com`. */ cdpAuth?: CdpAuth; }; @@ -73,8 +116,13 @@ export type Dual402Config = { mpp: MppConfig; x402: X402Config; /** - * Optional hook called after facilitator-side verify succeeds but before settlement/`next()`. - * Return `false` to reject the payment; useful for replay protection or per-caller policy. + * Optional hook called after the facilitator's `/verify` succeeds but before + * `/settle` or `next()`. Return `false` to reject the payment. Useful for + * replay protection (track payer + nonce) or per-caller policy. + * + * The `payload` parameter is the canonicalized x402 payment payload — the + * same shape sent to the facilitator's `/verify`. It carries the payer + * (`payload.authorization.from`), the route's resource URL, and the nonce. */ onVerify?: ( payload: JsonObject, @@ -84,21 +132,21 @@ export type Dual402Config = { /** Per-route options for {@link Dual402Instance.charge}. */ export type ChargeOptions = { - /** Price in USDC as a decimal string. E.g. `"0.02"` = 2 cents. */ + /** Price in USDC as a decimal string, e.g. `"0.02"` for two cents. Pass a string, not a number, to avoid float drift. */ amount: string; - /** Human-readable description. ASCII only — ends up in `WWW-Authenticate` header values. */ + /** Human-readable description shown in the MPP `WWW-Authenticate` header. Printable ASCII only — em-dashes and smart quotes throw at config time. */ description?: string; - /** Block on x402 settlement before returning the response. Default is `true`; opt out only for low-value routes. */ + /** Block on x402 settlement before returning the response. Default `true`. Set `false` only on low-value routes where you accept a settle failure after the response was sent. */ waitForSettle?: boolean; }; -/** Options for {@link paidRoute}: route metadata plus price. */ +/** Options for {@link paidRoute}. Combines OpenAPI route metadata with a per-route price. */ export type PaidRouteOptions = Omit & { - /** Price in USDC as a decimal string. E.g. `"0.02"` = 2 cents. */ + /** Price in USDC as a decimal string, e.g. `"0.02"` for two cents. Pass a string, not a number, to avoid float drift. */ amount: string; - /** Short payment challenge description. Defaults to `summary`. ASCII only. */ + /** Short payment challenge description shown in the MPP `WWW-Authenticate` header. Defaults to `summary`. Printable ASCII only. */ paymentDescription?: string; - /** Block on x402 settlement before returning the response. Default is `true`. */ + /** Block on x402 settlement before returning the response. Default `true`. */ waitForSettle?: boolean; }; @@ -192,9 +240,18 @@ type ResolvedX402Config = Readonly<{ cdpAuth: Readonly | null; }>; -/** The object returned by {@link createDual402}. Use `.charge()` to mint per-route middleware. */ +/** + * The object returned by {@link createDual402}. The only public method is + * {@link Dual402Instance.charge|charge}, which mints per-route middleware. + * Most apps should reach for {@link paidRoute} instead so the charge handler + * and the OpenAPI metadata stay aligned. + */ export type Dual402Instance = { - /** Create an Express middleware that accepts both x402 and MPP payments for this route. */ + /** + * Mint Express middleware for one paid route. The same middleware verifies + * x402 credentials (via the configured facilitator) and MPP credentials + * (via mppx), and emits a 402 with both challenges when none is present. + */ charge(options: ChargeOptions): DualChargeHandler; /** @internal The underlying mppx instance. Prefer the public `charge()` API. */ _mppx: any; @@ -239,20 +296,35 @@ const DEFAULT_FACILITATOR_TIMEOUT_MS = (() => { })(); /** - * Define a paid route once, then use the returned object for both Express mounting - * and `dualDiscovery()`. + * Define a paid route once. The returned object can be passed straight into + * `app.get(...)` / `app.post(...)` for Express mounting, and into the `routes` + * array of {@link dualDiscovery} so the OpenAPI spec stays in sync with the + * actual middleware. + * + * Prefer `paidRoute()` over the lower-level {@link Dual402Instance.charge} when + * the route should be discoverable. * * @example - * ```js + * ```ts * const quote = paidRoute(dual, { * method: "get", * path: "/quote", * amount: "0.02", * operationId: "getQuote", * summary: "Get a quote", + * parameters: [ + * { name: "symbol", in: "query", required: true, schema: { type: "string" } }, + * ], + * }); + * + * app.get(quote.path, quote.handler, (req, res) => + * res.json({ symbol: String(req.query.symbol).toUpperCase(), price: 42 }), + * ); + * + * dualDiscovery(app, dual, { + * info: { title: "Example", description: "", version: "1.0.0" }, + * routes: [quote], * }); - * app.get(quote.path, quote.handler, getQuote); - * dualDiscovery(app, dual, { routes: [quote] }); * ``` */ export function paidRoute( @@ -272,15 +344,31 @@ export function paidRoute( } /** - * Create a dual-protocol payment handler. Validates config, throws on mainnet misconfigurations - * (self-transfer, wrong facilitator for the network, missing CDP auth on Base mainnet). + * Validate the dual402 config and return an instance. Fails fast at startup for + * misconfigurations that would cost money in production: pointing Base mainnet + * at a testnet facilitator, missing CDP credentials when the facilitator host + * is `api.cdp.coinbase.com`, an unparseable `CDP_API_KEY_SECRET`, an unknown + * USDC for `x402.network`, or an MPP secret shorter than 32 characters. * * @example - * ```js + * ```ts * const dual = createDual402({ - * mpp: { currency, recipient, secretKey }, - * x402: { payTo, network: "eip155:8453", facilitatorUrl: CDP_URL, cdpAuth }, + * mpp: { + * currency: process.env.USDC_TEMPO, + * recipient: process.env.MPP_RECIPIENT, + * secretKey: process.env.MPP_SECRET_KEY, + * }, + * x402: { + * payTo: process.env.X402_PAYEE_ADDRESS, + * network: "eip155:8453", + * facilitatorUrl: "https://api.cdp.coinbase.com/platform/v2/x402", + * cdpAuth: { + * apiKeyId: process.env.CDP_API_KEY_ID, + * apiKeySecret: process.env.CDP_API_KEY_SECRET, + * }, + * }, * }); + * * const chargeQuote = dual.charge({ amount: "0.02", description: "Quote lookup" }); * app.get("/quote", chargeQuote, (req, res) => res.json({ price: 42 })); * ``` @@ -304,8 +392,8 @@ export function createDual402(config: Dual402Config): Dual402Instance { const x402Asset = config.x402.asset ?? USDC_BY_NETWORK[config.x402.network]; if (!x402Asset) { throw new Error( - `dual402: no default USDC for network "${config.x402.network}". ` + - `Set x402.asset explicitly or pick one of ${Object.keys(USDC_BY_NETWORK).join(", ")}.`, + `dual402: no default USDC known for x402.network "${config.x402.network}". ` + + `Set x402.asset (env X402_ASSET) explicitly, or use one of ${Object.keys(USDC_BY_NETWORK).join(", ")}.`, ); } @@ -313,12 +401,15 @@ export function createDual402(config: Dual402Config): Dual402Instance { const facilitatorUrlHost = facilitatorHost(facilitatorUrl); if (config.x402.network === "eip155:8453" && facilitatorUrlHost !== CDP_FACILITATOR_HOST) { throw new Error( - `dual402: Base mainnet (${config.x402.network}) requires Coinbase CDP's x402 facilitator (${CDP_FACILITATOR_HOST}).`, + `dual402: Base mainnet (x402.network=${config.x402.network}) requires Coinbase's CDP facilitator at ${CDP_FACILITATOR_HOST}, ` + + `got x402.facilitatorUrl=${JSON.stringify(facilitatorUrl)}. ` + + "Set X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402 and provide CDP_API_KEY_ID + CDP_API_KEY_SECRET.", ); } if (facilitatorUrlHost === CDP_FACILITATOR_HOST && !config.x402.cdpAuth) { throw new Error( - "dual402: x402.cdpAuth is required when using Coinbase CDP's x402 facilitator.", + `dual402: x402.cdpAuth is required when x402.facilitatorUrl host is ${CDP_FACILITATOR_HOST}. ` + + "Set CDP_API_KEY_ID and CDP_API_KEY_SECRET, then pass them as { apiKeyId, apiKeySecret }.", ); } const timeoutMs = @@ -335,19 +426,20 @@ export function createDual402(config: Dual402Config): Dual402Instance { const { apiKeyId, apiKeySecret } = config.x402.cdpAuth; if (!apiKeyId) { throw new Error( - "dual402: x402.cdpAuth.apiKeyId is required when cdpAuth is set.", + "dual402: x402.cdpAuth.apiKeyId (env CDP_API_KEY_ID) is required when cdpAuth is set.", ); } if (!apiKeySecret) { throw new Error( - "dual402: x402.cdpAuth.apiKeySecret is required when cdpAuth is set.", + "dual402: x402.cdpAuth.apiKeySecret (env CDP_API_KEY_SECRET) is required when cdpAuth is set.", ); } try { parseCdpPrivateKey(apiKeySecret); } catch (error) { throw new Error( - `dual402: CDP_API_KEY_SECRET could not be parsed: ${errorMessage(error)}`, + `dual402: x402.cdpAuth.apiKeySecret (env CDP_API_KEY_SECRET) could not be parsed: ${errorMessage(error)}. ` + + "Accepts a PEM block, a 48-byte PKCS#8 DER blob (base64), or a raw Ed25519 seed (32 or 64 bytes, base64).", ); } cdpAuth = Object.freeze({ apiKeyId, apiKeySecret }); @@ -512,13 +604,21 @@ export function createDual402(config: Dual402Config): Dual402Instance { } /** - * Mount `GET /openapi.json` and `GET /.well-known/x402` on the Express app. The OpenAPI spec - * is built from the `routes` you pass; the `/.well-known/x402` fallback advertises the minimal - * `{ version: 1, resources: [...] }` shape. Runtime `PAYMENT-REQUIRED` headers carry the richer - * per-route schemas so agent clients can retry with a valid body. + * Mount `GET /openapi.json` and `GET /.well-known/x402` on the Express app. + * + * The OpenAPI spec is built from the `routes` you pass — every paid operation + * advertises both an MPP and an x402 offer in its `x-payment-info.offers[]`, + * with matching amounts and the configured payee/network. The + * `/.well-known/x402` document is intentionally minimal: + * `{ version: 1, resources: ["GET /quote"] }`. * - * Every route's `handler` must be the same middleware you registered on the app — discovery - * reads amount/description metadata off it. + * Runtime `PAYMENT-REQUIRED` headers (issued by the per-route middleware) + * carry the richer per-route request/response schema hints, so agent clients + * can preserve their inputs on a paid retry. + * + * Every entry in `routes` must use the **same** middleware object that was + * registered on Express — discovery reads `_dualAmount` / `_dualDescription` + * off the handler. Passing a route without a dual402 charge handler throws. */ export function dualDiscovery( app: Express, @@ -735,31 +835,36 @@ function assertConfig(config: Dual402Config): void { if (missing.length > 0) { throw new Error( `dual402: missing required config:\n - ${missing.join("\n - ")}\n` + - "Create a .env from your example values before booting.", + "Resolve every value through a fail-fast helper at startup (see AGENTS.md).", ); } if (String(config.mpp.secretKey).length < 32) { - throw new Error("dual402: mpp.secretKey must be at least 32 characters."); + throw new Error( + "dual402: mpp.secretKey must be at least 32 characters (env MPP_SECRET_KEY). " + + "Generate one with `openssl rand -hex 32`.", + ); } if (!EVM_ADDR_RE.test(config.mpp.currency)) { throw new Error( - `dual402: mpp.currency must be an EVM token address, got ${JSON.stringify(config.mpp.currency)}.`, + `dual402: mpp.currency (env USDC_TEMPO) must be an EVM token address, got ${JSON.stringify(config.mpp.currency)}. ` + + "Use the Tempo USDC contract for your target network: testnet 0x20c0...0000, mainnet 0x20c0...e8b50.", ); } if (!EVM_ADDR_RE.test(config.mpp.recipient)) { throw new Error( - `dual402: mpp.recipient must be an EVM address, got ${JSON.stringify(config.mpp.recipient)}.`, + `dual402: mpp.recipient (env MPP_RECIPIENT) must be an EVM address, got ${JSON.stringify(config.mpp.recipient)}.`, ); } if (String(config.x402.network).startsWith("eip155:") && !EVM_ADDR_RE.test(config.x402.payTo)) { throw new Error( - `dual402: x402.payTo must be an EVM address for ${config.x402.network}, got ${JSON.stringify(config.x402.payTo)}.`, + `dual402: x402.payTo must be an EVM address for ${config.x402.network} (env X402_PAYEE_ADDRESS), got ${JSON.stringify(config.x402.payTo)}.`, ); } if (config.x402.asset !== undefined && !EVM_ADDR_RE.test(config.x402.asset)) { throw new Error( - `dual402: x402.asset must be an EVM token address, got ${JSON.stringify(config.x402.asset)}.`, + `dual402: x402.asset (env X402_ASSET) must be an EVM token address, got ${JSON.stringify(config.x402.asset)}. ` + + "Leave it unset to use the default USDC for x402.network.", ); } } @@ -774,7 +879,8 @@ function normalizeFacilitatorUrl(value: string): string { return url.toString().replace(/\/+$/, ""); } catch (error) { throw new Error( - `dual402: x402.facilitatorUrl must be an absolute http(s) URL: ${errorMessage(error)}`, + `dual402: x402.facilitatorUrl must be an absolute http(s) URL (env X402_FACILITATOR_URL) — ${errorMessage(error)}. ` + + "Try https://x402.org/facilitator (Sepolia) or https://api.cdp.coinbase.com/platform/v2/x402 (Base mainnet).", ); } } @@ -810,11 +916,14 @@ function normalizeRealm(value: string | undefined): string | undefined { function assertChargeAmount(amount: string): void { if (typeof amount !== "string" || !/^\d+(\.\d+)?$/.test(amount)) { throw new Error( - `dual402.charge: amount must be a decimal string like "0.02" — got ${JSON.stringify(amount)}`, + `dual402.charge: amount must be a USDC decimal string like "0.02" - got ${JSON.stringify(amount)}. ` + + "Pass a string, not a number, to avoid float precision drift.", ); } if (/^0+(\.0+)?$/.test(amount)) { - throw new Error(`dual402.charge: amount must be > 0 — got ${JSON.stringify(amount)}.`); + throw new Error( + `dual402.charge: amount must be greater than zero - got ${JSON.stringify(amount)}.`, + ); } } @@ -822,7 +931,7 @@ function assertHeaderSafeDescription(description: string | undefined): void { if (description === undefined) return; if (typeof description !== "string") { throw new Error( - `dual402.charge: description must be a string when set — got ${typeof description}`, + `dual402.charge: description must be a string when set - got ${typeof description}.`, ); } @@ -830,8 +939,10 @@ function assertHeaderSafeDescription(description: string | undefined): void { const code = description.charCodeAt(i); if (code < 0x20 || code > 0x7e) { throw new Error( - `dual402.charge: description must contain printable ASCII only because it is used in HTTP payment headers. ` + - `Invalid character at index ${i} (U+${code.toString(16).toUpperCase().padStart(4, "0")}).`, + `dual402.charge: description must contain printable ASCII only (U+0020-U+007E) ` + + `because it is serialized into the WWW-Authenticate header (RFC 9110). ` + + `Invalid character at index ${i} (U+${code.toString(16).toUpperCase().padStart(4, "0")}). ` + + "Replace em-dashes, smart quotes, and non-Latin characters with ASCII equivalents.", ); } } diff --git a/src/internal/cdp.ts b/src/internal/cdp.ts index 4a3cf26..976ae6c 100644 --- a/src/internal/cdp.ts +++ b/src/internal/cdp.ts @@ -27,9 +27,17 @@ function errorMessage(error: unknown): string { } /** - * Parse a CDP API key secret into a Node crypto KeyObject. Accepts PEM blocks, - * PKCS#8 DER (48-byte base64), or raw Ed25519 seeds (32 or 64 bytes base64). - * Throws `cdp_key_unrecognized: ...` with the reason on unexpected formats. + * Parse a Coinbase Developer Platform API key secret into a Node `KeyObject`, + * normalizing across the formats CDP issues: + * + * - PEM block (begins with `-----BEGIN`) + * - PKCS#8 DER (48-byte base64 blob) + * - Raw Ed25519 seed (32 or 64 bytes, base64-encoded) + * + * Throws an `Error` whose message starts with `cdp_key_unrecognized:` when the + * input does not match any known shape. The message includes the byte length + * of the decoded blob, which is the most useful diagnostic for "I copy-pasted + * the wrong field from the CDP portal". */ export function parseCdpPrivateKey(secret: string): crypto.KeyObject { const trimmed = String(secret).trim(); @@ -69,6 +77,16 @@ export function parseCdpPrivateKey(secret: string): crypto.KeyObject { }); } +/** + * Sign a short-lived CDP JWT for a single facilitator request. Used by the + * x402 verify/settle calls when the facilitator is hosted at + * `api.cdp.coinbase.com`. + * + * The token's `uris` claim binds it to one `${requestMethod} ${requestHost}${requestPath}` + * tuple, so it cannot be replayed against a different endpoint. + * + * @internal + */ export function generateCdpJwt(args: { apiKeyId: string; apiKeySecret: string; diff --git a/src/internal/x402.ts b/src/internal/x402.ts index 701741b..cdeb1c2 100644 --- a/src/internal/x402.ts +++ b/src/internal/x402.ts @@ -65,8 +65,10 @@ export function sanitizeLogValue(value: unknown, limit = 80): string { } /** - * Shorten a hex string for safe logging. `0xabc...1234` by default. - * Use for payer/wallet addresses and tx hashes in public logs. + * Shorten a hex string for safe logging — `0xabc...1234` by default. + * Use for payer/wallet addresses and transaction hashes in public boot logs; + * the full value is too noisy for routine output and shouldn't go on a + * shared third-party-auditable log surface unmasked. */ export function maskHex( value: unknown,