From 34c55fd3d158dfcebc4cc7977868b20b2d5b34cb Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Wed, 11 Nov 2026 08:12:50 -0800 Subject: [PATCH] feat(x402-seller): support request-time pricing in x402 and MPP helpers --- .changeset/request-pricing.md | 7 + docs/mpp/architecture.md | 7 + docs/x402/architecture.md | 28 +- examples/mpp-seller-express/README.md | 27 +- examples/mpp-seller-express/src/index.ts | 61 ++- examples/mpp-seller-hono/README.md | 26 +- examples/mpp-seller-hono/src/index.ts | 45 +- examples/x402-seller-express/README.md | 26 +- examples/x402-seller-express/src/index.ts | 79 ++-- examples/x402-seller-fastify/README.md | 15 +- examples/x402-seller-fastify/src/index.ts | 51 +-- examples/x402-seller-hono/README.md | 13 +- examples/x402-seller-hono/src/index.ts | 36 +- examples/x402-seller-next/README.md | 22 +- examples/x402-seller-next/proxy.ts | 41 +- packages/mpp-seller/src/compose.server.ts | 36 +- packages/mpp-seller/src/index.ts | 2 +- .../test/integration/dynamic-pricing.test.ts | 232 ++++++++++ .../test/unit/compose-server.test.ts | 23 + packages/x402-seller/README.md | 52 +++ .../x402-seller/src/http-resource-server.ts | 114 +++++ packages/x402-seller/src/index.ts | 6 +- packages/x402-seller/src/inflow-accepts.ts | 74 ++- packages/x402-seller/src/inflow-route.ts | 57 ++- packages/x402-seller/src/resource-server.ts | 34 ++ .../test/integration/dynamic-pricing.test.ts | 420 ++++++++++++++++++ .../test/integration/upfront-adapters.test.ts | 175 +++++++- .../test/unit/inflow-route.test.ts | 95 +++- .../test/unit/resource-server.test.ts | 223 ++++++++++ 29 files changed, 1761 insertions(+), 266 deletions(-) create mode 100644 .changeset/request-pricing.md create mode 100644 packages/mpp-seller/test/integration/dynamic-pricing.test.ts create mode 100644 packages/x402-seller/src/http-resource-server.ts create mode 100644 packages/x402-seller/src/resource-server.ts create mode 100644 packages/x402-seller/test/integration/dynamic-pricing.test.ts create mode 100644 packages/x402-seller/test/unit/resource-server.test.ts diff --git a/.changeset/request-pricing.md b/.changeset/request-pricing.md new file mode 100644 index 0000000..d4e8285 --- /dev/null +++ b/.changeset/request-pricing.md @@ -0,0 +1,7 @@ +--- +'@inflowpayai/x402-seller': minor +'@inflowpayai/mpp-seller': minor +--- + +Support request-time pricing in seller helpers, including configured currency selection and synchronous or asynchronous +callbacks. Preserve fixed-price integrations and delegate payment processing to the existing upstream handlers. diff --git a/docs/mpp/architecture.md b/docs/mpp/architecture.md index b3bd4d6..f600069 100644 --- a/docs/mpp/architecture.md +++ b/docs/mpp/architecture.md @@ -29,6 +29,13 @@ that encrypted payload. The seller checks the credential against its challenge a This is the exact analog of the x402 facilitator boundary documented in [../x402/architecture.md](../x402/architecture.md). +For request-time `inflow/charge` prices, pass a synchronous or asynchronous callback to `inflowCharges` or +`inflowChargesNodeListener` instead of a fixed price list. The callback receives a Web `Request` or Node +`IncomingMessage`, respectively, and returns the same `{ amount, currency }` entries. It runs once per request, +including paid retries. Keep the price stable for the same purchase: a credential for a different amount or currency +does not authorize the request. Configuration lookup, rail selection, challenge binding, and receipt handling remain +with the existing helpers and method. See the [Express example](../../examples/mpp-seller-express/src/index.ts). + ## Package layering ``` diff --git a/docs/x402/architecture.md b/docs/x402/architecture.md index a5e5339..9eb0d59 100644 --- a/docs/x402/architecture.md +++ b/docs/x402/architecture.md @@ -15,7 +15,7 @@ resolution via declaration order. InFlow plugs into that with these factories an | `createUnauthenticatedInflowFacilitator` | foundation `FacilitatorClient` | same — for facilitator-only deployments | | `createInflowSellerClient` | `InflowSellerClient` | drives `inflowAccepts` | | `inflowAccepts(client, options)` | foundation `PaymentOption[]` | a route's `accepts` field in `RoutesConfig` | -| `inflowRoute(client, options)` | foundation `RouteConfig` | a route's offers and token-gated sponsorship declarations | +| `inflowRoute(client, options)` | `RouteConfig` or route resolver | a route's offers and token-gated sponsorship declarations | | `inflowSchemeRegistrations(client)` | `Promise` | a foundation adapter's `schemes` argument — the foundation refuses to boot without these registrations | The buyer side ships `InflowClient`, a subclass of the foundation's `x402Client`. The buyer composes by passing the @@ -83,8 +83,26 @@ buyer foundation middleware InFlow facilitator The seller fetches `/v1/x402/config` once at startup (via `createInflowSellerClient`), expands it into `PaymentOption[]` via `inflowAccepts`, and hands the result to the foundation middleware in each route's `accepts` field. The foundation -middleware never calls `/v1/x402/config` itself — it consumes `PaymentOption[]` shapes that already have `payTo`, -`asset`, and atomic `amount` resolved. +middleware never calls `/v1/x402/config` itself. Fixed prices have `payTo`, `asset`, and atomic `amount` resolved at +startup. Request-time route callbacks call the same helpers with the price for the current purchase. + +`createInflowHTTPResourceServer(server, routes)` accepts fixed route objects and callbacks that receive the foundation's +`HTTPRequestContext` and return a concrete `RouteConfig`. Each matching callback runs once per request, before HTTP +protected-request hooks receive the resolved route. It can await pricing, call `inflowAccepts`, and filter, reorder, or +edit the resulting offers. The HTTP server uses upstream routing and delegates payment processing to a request-local +upstream HTTP server. The shared resource server retains its registered schemes, facilitators, and hooks. Settlement +uses the same resolved route as verification. Initialization is shared; a failed initialization can be retried. Callback +routes are validated at request time. Pricing errors and empty resolved offer lists stop the request before +verification, settlement, or the protected handler. See the +[request-priced Express example](../../examples/x402-seller-express/src/index.ts). + +Callbacks run again on paid retries; keep the price stable for the same purchase. Fixed prices remain compatible with +`paymentMiddlewareFromConfig`. Other facilitators and fixed options can share the resource server. For body-based +pricing, Express needs its body parser before payment middleware. Hono and Next provide body access through their +request adapters. Fastify's payment middleware runs before body parsing; use query, path, or header inputs there. For +Fastify, import `x402ResourceServer` from `@x402/fastify` and pass `{ resourceServer: x402ResourceServer }` to +`createInflowResourceServer`. Likewise pass its `x402HTTPResourceServer` as the `httpServer` option to +`createInflowHTTPResourceServer`. This keeps the returned servers compatible with the adapter's own x402 core version. ## Buyer side — request lifecycle @@ -184,7 +202,9 @@ Permit2 settlement operation; the facilitator broadcasts only at settlement. Del failure. `inflowRoute`'s optional `assetTransferMethod: 'permit2'` selects configured Permit2 alternatives without changing -ordinary offers. `FacilitatorClient.getSupported().extensions` advertises capability names, not route declarations. +ordinary offers. `FacilitatorClient.getSupported().extensions` advertises capability names, not route declarations. With +a price callback, `inflowRoute` returns a route resolver after capturing seller configuration and facilitator support +during setup. Sponsorship uses each request's resolved offers and the same capability checks as fixed prices. ## `inflowSchemeRegistrations` diff --git a/examples/mpp-seller-express/README.md b/examples/mpp-seller-express/README.md index 2d64a0d..5cbb1af 100644 --- a/examples/mpp-seller-express/README.md +++ b/examples/mpp-seller-express/README.md @@ -1,7 +1,7 @@ # Example — MPP seller on Express -A minimal Express app that accepts MPP payments via InFlow. Uses `mppx`'s own Express adapter (`Mppx` from -`mppx/express`) with InFlow's `inflow` seller method. `Mppx.create` mints and HMAC-binds the challenge locally with +A minimal Express app that accepts MPP payments via InFlow. Uses `inflowChargesNodeListener` with request-time pricing +and InFlow's `inflow` seller method on `mppx/server`. `Mppx.create` mints and HMAC-binds the challenge locally with `secretKey`; the `inflow` method's `verify` redeems and settles through the InFlow PSP. ## Prerequisite @@ -52,17 +52,18 @@ seller must use the same environment. Send API keys only to an InFlow API you tr The server listens on `http://localhost:3000` and serves these routes: -| Route | Price | Notes | -| -------------------- | -------------------------- | ------------------------------------------------------------------------------- | -| `GET /api/widgets` | `0.01 USDC` | Single currency via the Express adapter's `charge`. Crypto → `balance` rail. | -| `POST /api/upload` | `0.10 USDC` | Single currency via `charge`. Crypto → `balance` rail. | -| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. | -| `GET /api/checkout` | `1.0 USD` or `0.0095 USDC` | Multi-currency: one challenge per price (USD → `instrument`, USDC → `balance`). | -| `GET /free` | — | Not gated; passes through. | - -The Express adapter (`mppx/express`) exposes only the single-currency `charge` — it strips `compose`. The multi-currency -`GET /api/checkout` and `GET /api/subscribe` therefore use core `mppx/server` instances, bridged into Express with -InFlow's Node listener helpers. +| Route | Price | Notes | +| -------------------- | --------------------------- | ------------------------------------------------------------------------------ | +| `GET /api/widgets` | `0.01` or `0.05 USDC` | `?bundle=large` selects the larger bundle; balance rail. | +| `POST /api/upload` | `0.10` or `0.20 USDC` | `?priority=high` selects priority processing; balance rail. | +| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. | +| `GET /api/checkout` | `1.00 USD` or `0.0095 USDC` | `?bundle=large` doubles both prices (USD → `instrument`, USDC → `balance`). | +| `GET /api/search` | Request-priced USD or USDC | `?detail=full` selects USD 1.00 or USDC 0.25; otherwise USD 0.50 or USDC 0.10. | +| `GET /free` | — | Not gated; passes through. | + +Each charge callback reads the request and returns the seller's prices; it does not accept a buyer-supplied amount. The +helper handles both single- and multi-currency routes. Prices must remain stable between the unpaid request and its paid +retry. Fixed price lists are also supported. Subscriptions use a separate helper with fixed plan terms. Hit it with the matching buyer example or any other MPP client: diff --git a/examples/mpp-seller-express/src/index.ts b/examples/mpp-seller-express/src/index.ts index 9d6366f..8ebdbc6 100644 --- a/examples/mpp-seller-express/src/index.ts +++ b/examples/mpp-seller-express/src/index.ts @@ -1,6 +1,5 @@ import 'dotenv/config'; import express from 'express'; -import { Mppx } from 'mppx/express'; import { Mppx as MppxServer } from 'mppx/server'; import { inflow, @@ -16,22 +15,13 @@ if (apiKey === undefined || apiKey === '') { } const baseUrl = process.env['INFLOW_BASE_URL']; -// Build the `inflow` method (which primes `GET /v1/mpp/config` once) and the binding `secretKey` a single time, then -// stand up two instances over them — they expose different APIs: -// - `mppx` (mppx/express) wraps each method as an Express `RequestHandler` for ergonomic single-currency routes, -// but the framework adapters strip `compose`. -// - `core` (mppx/server) keeps `compose(...)`, which the multi-currency route below needs; its handlers are -// Web-fetch functions, bridged into Express with InFlow's `inflowChargesNodeListener` helper. -// `Mppx.create` only reads its argument, so sharing the method object is safe and avoids a second `/config` fetch. -// The `methods` array stays inline in each call so mppx can infer the precise method tuple (a hoisted config object -// would widen it and drop the typed handlers). +// One core instance handles single- and multi-currency prices through the Node listener helper. const method = inflow({ apiKey, environment: 'sandbox', ...(baseUrl === undefined || baseUrl === '' ? {} : { baseUrl }), }); const secretKey = process.env['MPP_SECRET_KEY']; -const mppx = Mppx.create({ methods: [method], secretKey, transport: paymentHttpTransport() }); const core = MppxServer.create({ methods: [method], secretKey, transport: paymentHttpTransport() }); const subscriptionMethod = inflow.subscription({ apiKey, @@ -47,12 +37,24 @@ const subscriptionCore = MppxServer.create({ const app = express(); app.use(express.json()); -// The rail is derived from the charge currency (crypto USDC → `balance`); the buyer never chooses it. -app.get('/api/widgets', mppx.charge({ amount: '0.01', currency: 'USDC' }), (_req, res) => { +// Prices come from the seller's price list, not an amount supplied by the buyer. +const widgets = inflowChargesNodeListener(core, (request) => { + const bundle = new URL(request.url ?? '/', 'http://localhost').searchParams.get('bundle'); + return [{ amount: bundle === 'large' ? '0.05' : '0.01', currency: 'USDC' }]; +}); +app.get('/api/widgets', async (req, res) => { + const result = await widgets(req, res); + if (result.status === 402) return; res.json({ widgets: [1, 2, 3] }); }); -app.post('/api/upload', mppx.charge({ amount: '0.10', currency: 'USDC' }), (_req, res) => { +const upload = inflowChargesNodeListener(core, (request) => { + const priority = new URL(request.url ?? '/', 'http://localhost').searchParams.get('priority'); + return [{ amount: priority === 'high' ? '0.20' : '0.10', currency: 'USDC' }]; +}); +app.post('/api/upload', async (req, res) => { + const result = await upload(req, res); + if (result.status === 402) return; res.json({ status: 'received' }); }); @@ -74,10 +76,13 @@ app.get('/api/subscribe', async (req, res) => { // Multi-currency: one WWW-Authenticate challenge per price. USD settles on the `instrument` rail, USDC on `balance`; // the buyer picks one. `inflowChargesNodeListener` returns a Node listener, mountable directly in an Express route. -const checkout = inflowChargesNodeListener(core, [ - { amount: '1.0', currency: 'USD' }, - { amount: '0.0095', currency: 'USDC' }, -]); +const checkout = inflowChargesNodeListener(core, (request) => { + const bundle = new URL(request.url ?? '/', 'http://localhost').searchParams.get('bundle'); + return [ + { amount: bundle === 'large' ? '2.00' : '1.00', currency: 'USD' }, + { amount: bundle === 'large' ? '0.019' : '0.0095', currency: 'USDC' }, + ]; +}); app.get('/api/checkout', async (req, res) => { // On 402 the challenge response is already written; on 200 the receipt header is set, so we write the body. const result = await checkout(req, res); @@ -87,6 +92,19 @@ app.get('/api/checkout', async (req, res) => { res.json({ ok: true }); }); +const search = inflowChargesNodeListener(core, (request) => { + const detail = new URL(request.url ?? '/', 'http://localhost').searchParams.get('detail'); + return [ + { amount: detail === 'full' ? '1.00' : '0.50', currency: 'USD' }, + { amount: detail === 'full' ? '0.25' : '0.10', currency: 'USDC' }, + ]; +}); +app.get('/api/search', async (req, res) => { + const result = await search(req, res); + if (result.status === 402) return; + res.json({ results: ['Sample search result'] }); +}); + app.get('/free', (_req, res) => { res.json({ ok: true, note: 'no payment required' }); }); @@ -94,9 +112,10 @@ app.get('/free', (_req, res) => { const port = Number(process.env['PORT'] ?? 3000); app.listen(port, () => { console.log(`mpp seller listening on http://localhost:${port.toString()}`); - console.log(` GET /api/widgets (0.01 USDC, single currency)`); - console.log(` POST /api/upload (0.10 USDC, single currency)`); + console.log(` GET /api/widgets (0.01 USDC; ?bundle=large costs 0.05 USDC)`); + console.log(` POST /api/upload (0.10 USDC; ?priority=high costs 0.20 USDC)`); console.log(` GET /api/subscribe (1.00 USDC each month)`); - console.log(` GET /api/checkout (1.0 USD on instrument, or 0.0095 USDC on balance)`); + console.log(` GET /api/checkout (1.00 USD or 0.0095 USDC; ?bundle=large doubles the price)`); + console.log(` GET /api/search?detail=full (request-time USD and USDC prices)`); console.log(` GET /free (no payment)`); }); diff --git a/examples/mpp-seller-hono/README.md b/examples/mpp-seller-hono/README.md index a894189..42513eb 100644 --- a/examples/mpp-seller-hono/README.md +++ b/examples/mpp-seller-hono/README.md @@ -1,8 +1,8 @@ # Example — MPP seller on Hono -A minimal Hono app that accepts MPP payments via InFlow. Uses `mppx`'s own Hono adapter (`Mppx` from `mppx/hono`) with -InFlow's `inflow` seller method, served on Node via `@hono/node-server`. `Mppx.create` mints and HMAC-binds the -challenge locally with `secretKey`; the `inflow` method's `verify` redeems and settles through the InFlow PSP. +A minimal Hono app that accepts MPP payments via InFlow. Uses `mppx/server` with InFlow's `inflowCharges` request-time +pricing helper and `inflow` seller method, served on Node via `@hono/node-server`. `Mppx.create` mints and HMAC-binds +the challenge locally with `secretKey`; the `inflow` method's `verify` redeems and settles through the InFlow PSP. ## Prerequisite @@ -24,17 +24,15 @@ pnpm dev The server listens on `http://localhost:3000` and serves these routes: -| Route | Price | Notes | -| -------------------- | -------------------------- | ------------------------------------------------------------------------------- | -| `GET /api/widgets` | `0.01 USDC` | Single currency via the Hono adapter's `charge`. Crypto → `balance` rail. | -| `POST /api/upload` | `0.10 USDC` | Single currency via `charge`. Crypto → `balance` rail. | -| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. | -| `GET /api/checkout` | `1.0 USD` or `0.0095 USDC` | Multi-currency: one challenge per price (USD → `instrument`, USDC → `balance`). | -| `GET /free` | — | Not gated; passes through. | - -The Hono adapter (`mppx/hono`) exposes only the single-currency `charge` — it strips `compose`. The multi-currency -`GET /api/checkout` and `GET /api/subscribe` therefore use core `mppx/server` instances; Hono drives the InFlow -Web-fetch handlers directly from `c.req.raw`. +| Route | Price | Notes | +| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------- | +| `GET /api/widgets` | `0.01 USDC`; `?bundle=large`: `0.05 USDC` | `balance` rail. | +| `POST /api/upload` | `0.10 USDC`; `?priority=high`: `0.20 USDC` | `balance` rail. | +| `GET /api/subscribe` | `1.00 USDC` monthly | Recurring subscription on the `balance` rail. | +| `GET /api/checkout` | `1.0 USD` or `0.0095 USDC`; `?bundle=large`: `2.0 USD` or `0.0190 USDC` | USD on `instrument`; USDC on `balance`. | +| `GET /free` | — | Not gated; passes through. | + +Hono passes `c.req.raw` to the InFlow Web-fetch handlers, returning the challenge or adding the receipt to the response. Hit it with the matching buyer example: diff --git a/examples/mpp-seller-hono/src/index.ts b/examples/mpp-seller-hono/src/index.ts index 9729e82..620d35b 100644 --- a/examples/mpp-seller-hono/src/index.ts +++ b/examples/mpp-seller-hono/src/index.ts @@ -1,7 +1,6 @@ import 'dotenv/config'; import { serve } from '@hono/node-server'; import { Hono } from 'hono'; -import { Mppx } from 'mppx/hono'; import { Mppx as MppxServer } from 'mppx/server'; import { inflow, inflowCharges, inflowSubscriptions, paymentHttpTransport } from '@inflowpayai/mpp-seller'; @@ -11,19 +10,8 @@ if (apiKey === undefined || apiKey === '') { process.exit(1); } -// Build the `inflow` method (which primes `GET /v1/mpp/config` once) and the binding `secretKey` a single time, then -// stand up two instances over them — they expose different APIs: -// - `mppx` (mppx/hono) wraps each method as a Hono `MiddlewareHandler` for ergonomic single-currency routes, but -// the framework adapters strip `compose`. -// - `core` (mppx/server) keeps `compose(...)`, which the multi-currency route below needs. Hono is fetch-native, so -// we drive the helper's Web-fetch handler directly off `c.req.raw` and reflect its `{ status, challenge | -// withReceipt }` result. -// `Mppx.create` only reads its argument, so sharing the method object is safe and avoids a second `/config` fetch. -// The `methods` array stays inline in each call so mppx can infer the precise method tuple (a hoisted config object -// would widen it and drop the typed handlers). const method = inflow({ apiKey, environment: 'sandbox' }); const secretKey = process.env['MPP_SECRET_KEY']; -const mppx = Mppx.create({ methods: [method], secretKey, transport: paymentHttpTransport() }); const core = MppxServer.create({ methods: [method], secretKey, transport: paymentHttpTransport() }); const subscriptionMethod = inflow.subscription({ apiKey, environment: 'sandbox' }); const subscriptionCore = MppxServer.create({ @@ -31,10 +19,19 @@ const subscriptionCore = MppxServer.create({ secretKey, transport: paymentHttpTransport(), }); -const checkout = inflowCharges(core, [ - { amount: '1.0', currency: 'USD' }, - { amount: '0.0095', currency: 'USDC' }, +const widgets = inflowCharges(core, (request) => [ + { amount: new URL(request.url).searchParams.get('bundle') === 'large' ? '0.05' : '0.01', currency: 'USDC' }, ]); +const upload = inflowCharges(core, (request) => [ + { amount: new URL(request.url).searchParams.get('priority') === 'high' ? '0.20' : '0.10', currency: 'USDC' }, +]); +const checkout = inflowCharges(core, (request) => { + const large = new URL(request.url).searchParams.get('bundle') === 'large'; + return [ + { amount: large ? '2.0' : '1.0', currency: 'USD' }, + { amount: large ? '0.0190' : '0.0095', currency: 'USDC' }, + ]; +}); const subscribe = inflowSubscriptions(subscriptionCore, [ { amount: '1.00', @@ -49,8 +46,16 @@ const subscribe = inflowSubscriptions(subscriptionCore, [ const app = new Hono(); // The rail is derived from the charge currency (crypto USDC → `balance`); the buyer never chooses it. -app.get('/api/widgets', mppx.charge({ amount: '0.01', currency: 'USDC' }), (c) => c.json({ widgets: [1, 2, 3] })); -app.post('/api/upload', mppx.charge({ amount: '0.10', currency: 'USDC' }), (c) => c.json({ status: 'received' })); +app.get('/api/widgets', async (c) => { + const result = await widgets(c.req.raw); + if (result.status === 402) return result.challenge; + return result.withReceipt(c.json({ widgets: [1, 2, 3] })); +}); +app.post('/api/upload', async (c) => { + const result = await upload(c.req.raw); + if (result.status === 402) return result.challenge; + return result.withReceipt(c.json({ status: 'received' })); +}); app.get('/api/subscribe', async (c) => { const result = await subscribe(c.req.raw); if (result.status === 402) return result.challenge; @@ -72,9 +77,9 @@ app.get('/free', (c) => c.json({ ok: true, note: 'no payment required' })); const port = Number(process.env['PORT'] ?? 3000); serve({ fetch: app.fetch, port }, () => { console.log(`mpp seller listening on http://localhost:${port.toString()}`); - console.log(` GET /api/widgets (0.01 USDC, single currency)`); - console.log(` POST /api/upload (0.10 USDC, single currency)`); + console.log(` GET /api/widgets (0.01 USDC; ?bundle=large: 0.05 USDC)`); + console.log(` POST /api/upload (0.10 USDC; ?priority=high: 0.20 USDC)`); console.log(` GET /api/subscribe (1.00 USDC each month)`); - console.log(` GET /api/checkout (1.0 USD on instrument, or 0.0095 USDC on balance)`); + console.log(` GET /api/checkout (1.0 USD or 0.0095 USDC; ?bundle=large: 2.0 USD or 0.0190 USDC)`); console.log(` GET /free (no payment)`); }); diff --git a/examples/x402-seller-express/README.md b/examples/x402-seller-express/README.md index 5fd5bca..f800a60 100644 --- a/examples/x402-seller-express/README.md +++ b/examples/x402-seller-express/README.md @@ -1,8 +1,7 @@ # Example — x402 seller on Express -A minimal Express app that accepts x402 payments via InFlow. Uses the foundation V2 middleware -(`paymentMiddlewareFromConfig` from `@x402/express`) with InFlow's facilitator client + seller client + `inflowAccepts` -helper. +A minimal Express app that accepts x402 payments via InFlow. Uses the foundation V2 middleware (`paymentMiddleware` from +`@x402/express`) with `createInflowHTTPResourceServer` and request-time routes built using `inflowAccepts`. ## Prerequisite @@ -24,13 +23,20 @@ pnpm dev The server listens on `http://localhost:3000`: -| Route | Price | Notes | -| -------------------- | ----------- | ------------------------------------------------------------- | -| `GET /api/widgets` | `$0.01` | Exact payments; authorization flow. | -| `GET /api/sponsored` | `0.01 USDC` | Base Sepolia Permit2; authorization flow. | -| `POST /api/upload` | `0.10 USDC` | Balance + exact schemes only. | -| `GET /api/report` | `0.10 USDC` | Balance + exact; upfront settlement before report generation. | -| `GET /free` | — | Not protected; passes through. | +| Route | Price | Notes | +| -------------------- | --------------------- | -------------------------------------------------------------- | +| `GET /api/widgets` | `$0.01` or `$0.05` | `?bundle=large` selects the larger bundle; exact payments. | +| `GET /api/sponsored` | `0.01 USDC` | Base Sepolia Permit2; authorization flow. | +| `POST /api/upload` | `0.10` or `0.20 USDC` | `?priority=high` selects priority processing; balance + exact. | +| `GET /api/report` | `0.10` or `0.25 USDC` | `?detail=full` selects the full report; upfront settlement. | +| `GET /api/search` | `0.10` or `0.25` | Request-time amount and currency via `inflowAccepts`. | +| `GET /free` | — | Not protected; passes through. | + +For `/api/search`, `?detail=full` selects 0.25 instead of 0.10; `?currency=USDT` selects USDT instead of USDC. The +callback returns the seller's price for the requested service. Networks without the selected currency are omitted; `$` +prices expand to configured stablecoins. A request with no matching payment options fails rather than becoming free. +Prices must remain stable between the unpaid request and its paid retry. Fixed prices are also supported; the sponsored +route demonstrates fixed pricing with `inflowRoute`. Hit it with the matching buyer example or any other x402 client: diff --git a/examples/x402-seller-express/src/index.ts b/examples/x402-seller-express/src/index.ts index 3d4485d..8162c64 100644 --- a/examples/x402-seller-express/src/index.ts +++ b/examples/x402-seller-express/src/index.ts @@ -1,12 +1,13 @@ import 'dotenv/config'; -import { paymentMiddlewareFromConfig } from '@x402/express'; +import { paymentMiddlewareFromHTTPServer } from '@x402/express'; import express from 'express'; import { createInflowFacilitator, createInflowSellerClient, + createInflowResourceServer, + createInflowHTTPResourceServer, inflowAccepts, inflowRoute, - inflowSchemeRegistrations, } from '@inflowpayai/x402-seller'; const apiKey = process.env['INFLOW_API_KEY']; @@ -28,42 +29,49 @@ const seller = await createInflowSellerClient({ environment: 'sandbox', apiKey } const app = express(); app.use(express.json()); -// 3. Foundation middleware + InFlow-built `PaymentOption[]` entries. -// `inflowAccepts` expands the seller's config into AssetAmount-form -// `accepts[]` (atomic amount + asset contract address pre-resolved). -// `inflowSchemeRegistrations` ships passthrough scheme servers so -// `x402HTTPResourceServer.initialize()` sees a registered scheme for -// every `(scheme, network)` the seller can emit — without these the -// foundation aborts at boot with `RouteConfigurationError`. +// Prices come from the seller's price list, not an amount supplied by the buyer. +const server = await createInflowResourceServer(seller, [inflow]); app.use( - paymentMiddlewareFromConfig( - { - 'GET /api/sponsored': await inflowRoute(seller, { - price: '0.01 USDC', - schemes: ['exact'], - networks: ['eip155:84532'], - assetTransferMethod: 'permit2', - }), - 'GET /api/widgets': { + paymentMiddlewareFromHTTPServer( + createInflowHTTPResourceServer(server, { + 'GET /api/widgets': async (context) => ({ accepts: await inflowAccepts(seller, { - price: '$0.01', + price: context.adapter.getQueryParam?.('bundle') === 'large' ? '$0.05' : '$0.01', schemes: ['exact'], }), - }, - 'POST /api/upload': { + }), + 'GET /api/search': async (context) => ({ + accepts: await inflowAccepts(seller, { + schemes: ['exact', 'balance'], + price: { + amount: context.adapter.getQueryParam?.('detail') === 'full' ? '0.25' : '0.10', + currency: context.adapter.getQueryParam?.('currency') === 'USDT' ? 'USDT' : 'USDC', + }, + }), + }), + 'POST /api/upload': async (context) => ({ + accepts: await inflowAccepts(seller, { + price: { + amount: context.adapter.getQueryParam?.('priority') === 'high' ? '0.20' : '0.10', + currency: 'USDC', + }, + schemes: ['balance', 'exact'], + }), + }), + 'GET /api/report': async (context) => ({ accepts: await inflowAccepts(seller, { - price: '0.10 USDC', + price: context.adapter.getQueryParam?.('detail') === 'full' ? '0.25 USDC' : '0.10 USDC', schemes: ['balance', 'exact'], + paymentFlow: 'upfront', }), - }, - 'GET /api/report': await inflowRoute(seller, { - price: '0.10 USDC', - schemes: ['balance', 'exact'], - paymentFlow: 'upfront', }), - }, - [inflow], - await inflowSchemeRegistrations(seller), + 'GET /api/sponsored': await inflowRoute(seller, { + price: (context) => (context.adapter.getQueryParam?.('detail') === 'full' ? '0.02 USDC' : '0.01 USDC'), + schemes: ['exact'], + networks: ['eip155:84532'], + assetTransferMethod: 'permit2', + }), + }), ), ); @@ -71,6 +79,10 @@ app.get('/api/widgets', (_req, res) => { res.json({ widgets: [1, 2, 3] }); }); +app.get('/api/search', (_req, res) => { + res.json({ results: ['Sample search result'] }); +}); + app.get('/api/sponsored', (_req, res) => { res.json({ paid: true }); }); @@ -92,8 +104,9 @@ app.get('/free', (_req, res) => { const port = Number(process.env['PORT'] ?? 3000); app.listen(port, () => { console.log(`x402 seller listening on http://localhost:${port.toString()}`); - console.log(` GET /api/widgets ($0.01)`); - console.log(` POST /api/upload (0.10 USDC)`); - console.log(` GET /api/report (0.10 USDC, upfront)`); + console.log(` GET /api/widgets ($0.01; ?bundle=large costs $0.05)`); + console.log(` GET /api/search?detail=full¤cy=USDT (request-time pricing; currencies must be configured)`); + console.log(` POST /api/upload (0.10 USDC; ?priority=high costs 0.20 USDC)`); + console.log(` GET /api/report (0.10 USDC; ?detail=full costs 0.25 USDC, upfront)`); console.log(` GET /free (no payment)`); }); diff --git a/examples/x402-seller-fastify/README.md b/examples/x402-seller-fastify/README.md index 17e9d2d..5b6701b 100644 --- a/examples/x402-seller-fastify/README.md +++ b/examples/x402-seller-fastify/README.md @@ -1,9 +1,8 @@ # Example — x402 seller on Fastify A minimal Fastify app that accepts x402 payments via InFlow. Uses the foundation V2 middleware -(`paymentMiddlewareFromConfig` from `@x402/fastify`) with InFlow's facilitator client + seller client + `inflowAccepts` -helper. `@x402/fastify` mutates the Fastify instance in place rather than returning a middleware function — that's the -only shape difference from the Express and Hono variants. +(`paymentMiddlewareFromHTTPServer` from `@x402/fastify`) with InFlow's request-time routes and `inflowAccepts` helper. +Prices use query parameters: Fastify's payment hook runs before body parsing, leaving the body for the route handler. ## Prerequisite @@ -25,11 +24,11 @@ pnpm dev The server listens on `http://localhost:3000` and serves three routes: -| Route | Price | Notes | -| ------------------ | ----------- | ------------------------------ | -| `GET /api/widgets` | `$0.01` | Exact scheme only. | -| `POST /api/upload` | `0.10 USDC` | Balance + exact schemes only. | -| `GET /free` | — | Not protected; passes through. | +| Route | Price | Notes | +| ------------------ | ------------------------------------------ | ------------------------------ | +| `GET /api/widgets` | `$0.01`; `?tier=premium`: `$0.02` | Exact scheme only. | +| `POST /api/upload` | `0.10 USDC`; `?priority=high`: `0.20 USDC` | Balance + exact schemes only. | +| `GET /free` | — | Not protected; passes through. | Hit it with the matching buyer example: diff --git a/examples/x402-seller-fastify/src/index.ts b/examples/x402-seller-fastify/src/index.ts index ef0fe1a..12481f6 100644 --- a/examples/x402-seller-fastify/src/index.ts +++ b/examples/x402-seller-fastify/src/index.ts @@ -1,11 +1,12 @@ import 'dotenv/config'; -import { paymentMiddlewareFromConfig } from '@x402/fastify'; +import { paymentMiddlewareFromHTTPServer, x402ResourceServer, x402HTTPResourceServer } from '@x402/fastify'; import Fastify from 'fastify'; import { createInflowFacilitator, createInflowSellerClient, inflowAccepts, - inflowSchemeRegistrations, + createInflowResourceServer, + createInflowHTTPResourceServer, } from '@inflowpayai/x402-seller'; const apiKey = process.env['INFLOW_API_KEY']; @@ -26,34 +27,28 @@ const seller = await createInflowSellerClient({ environment: 'sandbox', apiKey } const app = Fastify(); -// 3. Foundation middleware + InFlow-built `PaymentOption[]` entries. -// `@x402/fastify`'s `paymentMiddlewareFromConfig` mutates the Fastify -// instance in place; it registers the preHandler hook and a -// handle-404 hook for unprotected routes. `inflowAccepts` expands the -// seller's config into AssetAmount-form `accepts[]` (atomic amount + -// asset contract address pre-resolved). `inflowSchemeRegistrations` -// ships passthrough scheme servers so `x402HTTPResourceServer.initialize()` -// sees a registered scheme for every `(scheme, network)` the seller -// can emit — without these the foundation aborts at boot with -// `RouteConfigurationError`. -paymentMiddlewareFromConfig( +// Use the adapter's resource-server class so its x402 types stay aligned. +const resourceServer = await createInflowResourceServer(seller, inflow, { resourceServer: x402ResourceServer }); +paymentMiddlewareFromHTTPServer( app, - { - 'GET /api/widgets': { - accepts: await inflowAccepts(seller, { - price: '$0.01', - schemes: ['exact'], + createInflowHTTPResourceServer( + resourceServer, + { + 'GET /api/widgets': async (context) => ({ + accepts: await inflowAccepts(seller, { + price: context.adapter.getQueryParam?.('tier') === 'premium' ? '$0.02' : '$0.01', + schemes: ['exact'], + }), }), - }, - 'POST /api/upload': { - accepts: await inflowAccepts(seller, { - price: '0.10 USDC', - schemes: ['balance', 'exact'], + 'POST /api/upload': async (context) => ({ + accepts: await inflowAccepts(seller, { + price: context.adapter.getQueryParam?.('priority') === 'high' ? '0.20 USDC' : '0.10 USDC', + schemes: ['balance', 'exact'], + }), }), }, - }, - [inflow], - await inflowSchemeRegistrations(seller), + { httpServer: x402HTTPResourceServer }, + ), ); app.get('/api/widgets', async () => ({ widgets: [1, 2, 3] })); @@ -63,6 +58,6 @@ app.get('/free', async () => ({ ok: true, note: 'no payment required' })); const port = Number(process.env['PORT'] ?? 3000); await app.listen({ port }); console.log(`x402 seller listening on http://localhost:${port.toString()}`); -console.log(` GET /api/widgets ($0.01)`); -console.log(` POST /api/upload (0.10 USDC)`); +console.log(` GET /api/widgets ($0.01; ?tier=premium: $0.02)`); +console.log(` POST /api/upload (0.10 USDC; ?priority=high: 0.20 USDC)`); console.log(` GET /free (no payment)`); diff --git a/examples/x402-seller-hono/README.md b/examples/x402-seller-hono/README.md index 3f0717f..a049210 100644 --- a/examples/x402-seller-hono/README.md +++ b/examples/x402-seller-hono/README.md @@ -1,8 +1,7 @@ # Example — x402 seller on Hono A minimal Hono app that accepts x402 payments via InFlow. Uses the foundation V2 middleware -(`paymentMiddlewareFromConfig` from `@x402/hono`) with InFlow's facilitator client + seller client + `inflowAccepts` -helper. +(`paymentMiddlewareFromHTTPServer` from `@x402/hono`) with InFlow's request-time routes and `inflowAccepts` helper. ## Prerequisite @@ -24,11 +23,11 @@ pnpm dev The server listens on `http://localhost:3000` and serves three routes: -| Route | Price | Notes | -| ------------------ | ----------- | --------------------------------------- | -| `GET /api/widgets` | `$0.01` | All schemes the facilitator advertises. | -| `POST /api/upload` | `0.10 USDC` | Balance + exact schemes only. | -| `GET /free` | — | Not protected; passes through. | +| Route | Price | Notes | +| ------------------ | ------------------------------------------ | ------------------------------ | +| `GET /api/widgets` | `$0.01`; `?bundle=large`: `$0.05` | Balance + exact schemes. | +| `POST /api/upload` | `0.10 USDC`; `?priority=high`: `0.20 USDC` | Balance + exact schemes. | +| `GET /free` | — | Not protected; passes through. | Hit it with the matching buyer example: diff --git a/examples/x402-seller-hono/src/index.ts b/examples/x402-seller-hono/src/index.ts index c9534c9..a2409ec 100644 --- a/examples/x402-seller-hono/src/index.ts +++ b/examples/x402-seller-hono/src/index.ts @@ -1,12 +1,13 @@ import 'dotenv/config'; import { serve } from '@hono/node-server'; import { Hono } from 'hono'; -import { paymentMiddlewareFromConfig } from '@x402/hono'; +import { paymentMiddlewareFromHTTPServer } from '@x402/hono'; import { createInflowFacilitator, createInflowSellerClient, inflowAccepts, - inflowSchemeRegistrations, + createInflowResourceServer, + createInflowHTTPResourceServer, } from '@inflowpayai/x402-seller'; const apiKey = process.env['INFLOW_API_KEY']; @@ -25,26 +26,23 @@ const seller = await createInflowSellerClient({ environment: 'sandbox', apiKey } const app = new Hono(); -// `inflowSchemeRegistrations` ships passthrough scheme servers so -// `x402HTTPResourceServer.initialize()` sees a registered scheme for -// every `(scheme, network)` the seller can emit — without these the -// foundation aborts at boot with `RouteConfigurationError`. +const resourceServer = await createInflowResourceServer(seller, inflow); app.use( '*', - paymentMiddlewareFromConfig( - { - 'GET /api/widgets': { - accepts: await inflowAccepts(seller, { price: '$0.01' }), - }, - 'POST /api/upload': { + paymentMiddlewareFromHTTPServer( + createInflowHTTPResourceServer(resourceServer, { + 'GET /api/widgets': async (context) => ({ accepts: await inflowAccepts(seller, { - price: '0.10 USDC', + price: context.adapter.getQueryParam?.('bundle') === 'large' ? '$0.05' : '$0.01', + }), + }), + 'POST /api/upload': async (context) => ({ + accepts: await inflowAccepts(seller, { + price: context.adapter.getQueryParam?.('priority') === 'high' ? '0.20 USDC' : '0.10 USDC', schemes: ['balance', 'exact'], }), - }, - }, - [inflow], - await inflowSchemeRegistrations(seller), + }), + }), ), ); @@ -55,7 +53,7 @@ app.get('/free', (c) => c.json({ ok: true, note: 'no payment required' })); const port = Number(process.env['PORT'] ?? 3000); serve({ fetch: app.fetch, port }, () => { console.log(`x402 seller listening on http://localhost:${port.toString()}`); - console.log(` GET /api/widgets ($0.01)`); - console.log(` POST /api/upload (0.10 USDC)`); + console.log(` GET /api/widgets ($0.01; ?bundle=large: $0.05)`); + console.log(` POST /api/upload (0.10 USDC; ?priority=high: 0.20 USDC)`); console.log(` GET /free (no payment)`); }); diff --git a/examples/x402-seller-next/README.md b/examples/x402-seller-next/README.md index 185d8e5..a86d6df 100644 --- a/examples/x402-seller-next/README.md +++ b/examples/x402-seller-next/README.md @@ -1,9 +1,9 @@ # Example — x402 seller on Next 16 A minimal Next 16 App Router app that accepts x402 payments via InFlow. Uses the foundation V2 proxy -(`paymentProxyFromConfig` from `@x402/next`) with InFlow's facilitator client + seller client + `inflowAccepts` helper. -The InFlow setup lives at module top in `proxy.ts` so `await createInflowSellerClient(...)` primes its caches once at -the cold-start boundary. +(`paymentProxyFromHTTPServer` from `@x402/next`) with InFlow's request-time routes and `inflowAccepts` helper. The +InFlow setup lives at module top in `proxy.ts` so `await createInflowSellerClient(...)` primes its caches once at the +cold-start boundary. Next 16 only — `@x402/next@2.27.0`'s peer dep is `next >=16.2.6`. There is no 14/15 adapter. The file convention is `proxy.ts` (renamed from `middleware.ts` in Next 16); the export is `const proxy`, not `const middleware`. @@ -28,11 +28,11 @@ pnpm dev The server listens on `http://localhost:3000` and serves three routes: -| Route | Price | Notes | -| ------------------ | ----------- | ------------------------------------- | -| `GET /api/widgets` | `$0.01` | Exact scheme only. | -| `POST /api/upload` | `0.10 USDC` | Balance + exact schemes only. | -| `GET /free` | — | Not matched by proxy; passes through. | +| Route | Price | Notes | +| ------------------ | ------------------------------------------ | ------------------------------------- | +| `GET /api/widgets` | `$0.01`; `?bundle=large`: `$0.05` | Exact scheme only. | +| `POST /api/upload` | `0.10 USDC`; `?priority=high`: `0.20 USDC` | Balance + exact schemes only. | +| `GET /free` | — | Not matched by proxy; passes through. | Hit it with the matching buyer example: @@ -44,12 +44,12 @@ INFLOW_API_KEY=$INFLOW_API_KEY TARGET_URL=http://localhost:3000/api/widgets pnpm ## Layout ``` -proxy.ts # paymentProxyFromConfig — InFlow wiring lives here +proxy.ts # paymentProxyFromHTTPServer — InFlow wiring lives here app/api/widgets/route.ts # plain Route Handler, no x402 code app/api/upload/route.ts # plain Route Handler, no x402 code app/free/route.ts # plain Route Handler, not protected ``` Next 16's `proxy.ts` always runs on the Node.js runtime; no route-segment `config` export is needed (or allowed). The -proxy dispatches by matching the request against the `routes` keys passed to `paymentProxyFromConfig`, so `/free` — -which is not in that map — passes through to its Route Handler untouched. +proxy dispatches by matching the request against the `routes` keys passed to `createInflowHTTPResourceServer`, so +`/free` — which is not in that map — passes through to its Route Handler untouched. diff --git a/examples/x402-seller-next/proxy.ts b/examples/x402-seller-next/proxy.ts index 1d318af..2797669 100644 --- a/examples/x402-seller-next/proxy.ts +++ b/examples/x402-seller-next/proxy.ts @@ -1,10 +1,11 @@ import 'dotenv/config'; -import { paymentProxyFromConfig } from '@x402/next'; +import { paymentProxyFromHTTPServer } from '@x402/next'; import { createInflowFacilitator, createInflowSellerClient, inflowAccepts, - inflowSchemeRegistrations, + createInflowResourceServer, + createInflowHTTPResourceServer, } from '@inflowpayai/x402-seller'; const apiKey = process.env['INFLOW_API_KEY']; @@ -25,36 +26,20 @@ const inflow = createInflowFacilitator({ environment: 'sandbox', apiKey }); // the warmed client. const seller = await createInflowSellerClient({ environment: 'sandbox', apiKey }); -// 3. Foundation proxy + InFlow-built `PaymentOption[]` entries. -// `paymentProxyFromConfig` returns a `(req) => Promise` -// that we export as `proxy` (the Next 16 file-convention name — -// `middleware.ts` was renamed to `proxy.ts` in Next 16). -// `inflowAccepts` expands the seller's config into AssetAmount-form -// `accepts[]` (atomic amount + asset contract address pre-resolved). -// `inflowSchemeRegistrations` ships passthrough scheme servers so -// `x402HTTPResourceServer.initialize()` sees a registered scheme for -// every `(scheme, network)` the seller can emit — without these the -// foundation aborts at boot with `RouteConfigurationError`. -// Next 16's `proxy.ts` always runs on the Node.js runtime and does -// not accept a `config` export — the function itself dispatches by -// matching the request against the `routes` keys. `/free` is not in -// that map, so the proxy passes it through to its Route Handler -// untouched. -export const proxy = paymentProxyFromConfig( - { - 'GET /api/widgets': { +const resourceServer = await createInflowResourceServer(seller, inflow); +export const proxy = paymentProxyFromHTTPServer( + createInflowHTTPResourceServer(resourceServer, { + 'GET /api/widgets': async (context) => ({ accepts: await inflowAccepts(seller, { - price: '$0.01', + price: context.adapter.getQueryParam?.('bundle') === 'large' ? '$0.05' : '$0.01', schemes: ['exact'], }), - }, - 'POST /api/upload': { + }), + 'POST /api/upload': async (context) => ({ accepts: await inflowAccepts(seller, { - price: '0.10 USDC', + price: context.adapter.getQueryParam?.('priority') === 'high' ? '0.20 USDC' : '0.10 USDC', schemes: ['balance', 'exact'], }), - }, - }, - [inflow], - await inflowSchemeRegistrations(seller), + }), + }), ); diff --git a/packages/mpp-seller/src/compose.server.ts b/packages/mpp-seller/src/compose.server.ts index 6dd9ca8..517d4c8 100644 --- a/packages/mpp-seller/src/compose.server.ts +++ b/packages/mpp-seller/src/compose.server.ts @@ -1,4 +1,5 @@ import { Mppx } from 'mppx/server'; +import type { IncomingMessage } from 'node:http'; /** * One accepted price for a multi-currency route: an `amount` denominated in `currency`. The rail is not specified here @@ -12,6 +13,10 @@ export interface InflowChargePrice { currency: string; } +export type InflowChargePrices = ( + input: Input, +) => readonly InflowChargePrice[] | Promise; + /** The `compose` entry shape for the `inflow` charge method: a `[methodKey, options]` tuple. */ type InflowChargeEntry = readonly ['inflow/charge', { amount: string; currency: string }]; @@ -67,11 +72,28 @@ function toEntries(prices: readonly InflowChargePrice[]): InflowChargeEntry[] { * request time as `MppUnsupportedCurrencyError`, exactly as with `charge`. * * @param mppx - A core `Mppx.create({ methods: [inflow(...)] })` instance. - * @param prices - One `{ amount, currency }` per currency to accept. + * @param prices - Fixed prices or a callback evaluated once per request. Callback errors propagate without payment. * @returns The composed Web-fetch handler. * @throws {@link Error} On an empty list, a duplicate currency, or an empty `amount`/`currency`. */ -export function inflowCharges(mppx: InflowComposable, prices: readonly InflowChargePrice[]): Handler { +export function inflowCharges( + mppx: InflowComposable, + prices: InflowChargePrices, +): InflowFetchHandler; +export function inflowCharges(mppx: InflowComposable, prices: readonly InflowChargePrice[]): Handler; +export function inflowCharges( + mppx: InflowComposable, + prices: readonly InflowChargePrice[] | InflowChargePrices, +): InflowFetchHandler { + if (typeof prices === 'function') { + return async (request) => { + const input = + request.bodyUsed || request.body?.locked + ? new Request(request.url, { method: request.method, headers: request.headers, signal: request.signal }) + : request.clone(); + return mppx.compose(...toEntries(await prices(input)))(request); + }; + } return mppx.compose(...toEntries(prices)); } @@ -81,14 +103,20 @@ export function inflowCharges(mppx: InflowComposable, prices: * resolves `{ status: 402 }`; on a 200 the `Payment-Receipt` header is set and the caller writes the body. * * @param mppx - A core `Mppx.create({ methods: [inflow(...)] })` instance. - * @param prices - One `{ amount, currency }` per currency to accept. + * @param prices - Fixed prices or a callback receiving the incoming Node request, before response handling. * @returns A Node `(req, res) => Promise<...>` listener. * @throws {@link Error} On an empty list, a duplicate currency, or an empty `amount`/`currency`. */ export function inflowChargesNodeListener( mppx: InflowComposable, - prices: readonly InflowChargePrice[], + prices: readonly InflowChargePrice[] | InflowChargePrices, ): ReturnType { + if (typeof prices === 'function') { + return async (request, response) => { + const handler = inflowCharges(mppx, await prices(request)); + return Mppx.toNodeListener(handler)(request, response); + }; + } return Mppx.toNodeListener(inflowCharges(mppx, prices)); } diff --git a/packages/mpp-seller/src/index.ts b/packages/mpp-seller/src/index.ts index 19c3d18..f0e9fae 100644 --- a/packages/mpp-seller/src/index.ts +++ b/packages/mpp-seller/src/index.ts @@ -11,7 +11,7 @@ export { inflowSubscriptions, inflowSubscriptionsNodeListener, } from './compose.server.js'; -export type { InflowChargePrice, InflowSubscriptionPlan } from './compose.server.js'; +export type { InflowChargePrice, InflowChargePrices, InflowSubscriptionPlan } from './compose.server.js'; export { createConfigClient } from './config-client.js'; export { paymentHttpTransport } from './http-transport.js'; diff --git a/packages/mpp-seller/test/integration/dynamic-pricing.test.ts b/packages/mpp-seller/test/integration/dynamic-pricing.test.ts new file mode 100644 index 0000000..06a3947 --- /dev/null +++ b/packages/mpp-seller/test/integration/dynamic-pricing.test.ts @@ -0,0 +1,232 @@ +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { setTimeout } from 'node:timers/promises'; +import { Challenge, Credential } from 'mppx'; +import { decode } from '@inflowpayai/mpp'; +import type { MppCredential } from '@inflowpayai/mpp'; +import { expect, it } from 'vitest'; + +import { inflow, inflowCharges, inflowChargesNodeListener, Mppx, paymentHttpTransport } from '../../src/index.js'; + +it.each([ + ['fetch', 'GET', 'unread'], + ['fetch', 'POST', 'unread'], + ['fetch', 'POST', 'consumed'], + ['fetch', 'POST', 'locked'], + ['node', 'GET', 'unread'], +] as const)( + 'prices per request through the %s helper for %s with a %s body through real mppx composition', + async (adapter, method, bodyState) => { + let configHits = 0; + const events: string[] = []; + const platform = createServer((request, response) => { + response.setHeader('Content-Type', 'application/json'); + if (request.url === '/v1/mpp/config') { + configHits += 1; + response.end( + JSON.stringify({ + sellerId: '22222222-2222-2222-2222-222222222222', + featureFlags: { idempotencyKeyEnabled: true }, + replayPolicy: { managedBy: 'psp' }, + supportedMethods: [ + { + id: 'inflow', + supportedIntents: ['charge'], + supportedCurrencies: ['USDC', 'USDT', 'USD'], + methodDetails: { + intentCurrencyRails: { + charge: { USDC: [{ rail: 'balance' }], USDT: [{ rail: 'balance' }], USD: [{ rail: 'instrument' }] }, + }, + }, + }, + ], + }), + ); + return; + } + events.push(request.url ?? ''); + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { + body += chunk; + }); + request.on('end', () => { + // The local platform receives the SDK's serialized credential, not a fabricated mppx response. + const { credential } = JSON.parse(body) as { credential: MppCredential }; + if (request.url === '/v1/mpp/validate') { + response.end( + JSON.stringify({ + success: true, + credential, + challenge: credential.challenge, + details: {}, + intent: 'charge', + method: 'inflow', + source: credential.source, + request: decode(credential.challenge.request), + }), + ); + } else if (request.url === '/v1/mpp/broadcast') { + response.end( + JSON.stringify({ + receipt: { + challengeId: credential.challenge.id, + method: 'inflow', + reference: 'payment-1', + status: 'success', + timestamp: new Date().toISOString(), + }, + }), + ); + } else response.writeHead(404).end('{}'); + }); + }); + platform.listen(0, '127.0.0.1'); + await once(platform, 'listening'); + const address = platform.address(); + if (address === null || typeof address === 'string') throw new Error('Expected TCP listener'); + const mppx = Mppx.create({ + methods: [inflow({ apiKey: 'test-key', baseUrl: `http://127.0.0.1:${String(address.port)}` })], + secretKey: 'seller-binding-secret-at-least-32-bytes', + realm: 'seller.test', + transport: paymentHttpTransport(), + }); + let calls = 0; + const prices = async (url: string) => { + calls += 1; + const tier = new URL(url, 'http://seller.test').searchParams.get('tier'); + await setTimeout(tier === 'premium' ? 1 : 10); + if (tier === 'error') throw new Error('pricing unavailable'); + if (tier === 'empty') return []; + if (tier === 'instrument') return [{ amount: '1', currency: 'USD' }]; + if (tier === 'duplicate') + return [ + { amount: '1', currency: 'USDC' }, + { amount: '2', currency: 'USDC' }, + ]; + return tier === 'premium' + ? [{ amount: '2', currency: 'USDT' }] + : [ + { amount: '1', currency: 'USDC' }, + { amount: '1.5', currency: 'USDT' }, + ]; + }; + const fetchHandler = inflowCharges(mppx, async (request) => { + if (request.method === 'POST' && bodyState === 'unread') { + const body = (await request.json()) as { tier: string }; + return prices(`/paid?tier=${body.tier}`); + } + return prices(request.url); + }); + const nodeHandler = inflowChargesNodeListener(mppx, (request) => prices(request.url ?? '/')); + expect(calls).toBe(0); + const resource = createServer((request, response) => { + void nodeHandler(request, response) + .then((result) => { + if (result.status === 200) { + events.push('handler'); + response.end('paid content'); + } + }) + .catch(() => response.writeHead(503).end('Unavailable')); + }); + resource.listen(0, '127.0.0.1'); + await once(resource, 'listening'); + const resourceAddress = resource.address(); + if (resourceAddress === null || typeof resourceAddress === 'string') throw new Error('Expected TCP listener'); + const origin = `http://127.0.0.1:${String(resourceAddress.port)}`; + const call = async (tier: string, authorization?: string) => { + const request = new Request(`${origin}/paid?tier=${tier}`, { + method, + ...(method === 'POST' ? { body: JSON.stringify({ tier }) } : {}), + headers: authorization === undefined ? {} : { Authorization: authorization }, + }); + if (adapter === 'node') return fetch(request); + if (bodyState === 'consumed') await request.json(); + const reader = bodyState === 'locked' ? request.body?.getReader() : undefined; + try { + const result = await fetchHandler(request); + expect(request.bodyUsed).toBe(bodyState === 'consumed'); + if (result.status === 402) return result.challenge; + if (method === 'POST' && bodyState === 'unread') expect(await request.json()).toEqual({ tier }); + events.push('handler'); + return result.withReceipt(new Response('paid content')); + } catch { + return new Response('Unavailable', { status: 503 }); + } finally { + reader?.releaseLock(); + } + }; + try { + const [basic, premium] = await Promise.all([call('basic'), call('premium')]); + expect(basic.status).toBe(402); + expect(premium.status).toBe(402); + const basicChallenges = Challenge.fromResponseList(basic); + const premiumChallenge = Challenge.fromResponse(premium); + await Promise.all([basic.arrayBuffer(), premium.arrayBuffer()]); + expect(basicChallenges.map((challenge) => challenge.request)).toMatchObject([ + { amount: '1', currency: 'USDC' }, + { amount: '1.5', currency: 'USDT' }, + ]); + expect(premiumChallenge.request).toMatchObject({ amount: '2', currency: 'USDT' }); + expect(events).toEqual([]); + const authorization = Credential.serialize({ + challenge: premiumChallenge, + source: 'did:inflow:buyer', + payload: { transactionId: 'test-payment' }, + }); + const wrong = await call('basic', authorization); + expect(wrong.status).toBe(402); + await wrong.arrayBuffer(); + expect(events).toEqual([]); + const paid = await call('premium', authorization); + expect(paid.status).toBe(200); + expect(await paid.text()).toBe('paid content'); + expect(paid.headers.has('Payment-Receipt')).toBe(true); + expect(events).toEqual(['/v1/mpp/validate', '/v1/mpp/broadcast', 'handler']); + const instrument = await call('instrument'); + expect(instrument.status).toBe(402); + const instrumentChallenge = Challenge.fromResponse(instrument); + await instrument.arrayBuffer(); + expect(instrumentChallenge.request).toMatchObject({ + amount: '1', + currency: 'USD', + methodDetails: { rail: 'instrument' }, + }); + const instrumentPaid = await call( + 'instrument', + Credential.serialize({ + challenge: instrumentChallenge, + source: 'did:inflow:buyer', + payload: { transactionId: 'test-instrument-payment' }, + }), + ); + expect(instrumentPaid.status).toBe(200); + expect(await instrumentPaid.text()).toBe('paid content'); + expect(instrumentPaid.headers.has('Payment-Receipt')).toBe(true); + expect(events).toEqual([ + '/v1/mpp/validate', + '/v1/mpp/broadcast', + 'handler', + '/v1/mpp/validate', + '/v1/mpp/broadcast', + 'handler', + ]); + for (const tier of ['error', 'empty', 'duplicate']) { + const failed = await call(tier); + expect(failed.status).toBe(503); + await failed.arrayBuffer(); + } + expect(events).toHaveLength(6); + expect(configHits).toBe(1); + expect(calls).toBe(9); + } finally { + const closed = [once(resource, 'close'), once(platform, 'close')]; + resource.close(); + resource.closeAllConnections(); + platform.close(); + platform.closeAllConnections(); + await Promise.all(closed); + } + }, +); diff --git a/packages/mpp-seller/test/unit/compose-server.test.ts b/packages/mpp-seller/test/unit/compose-server.test.ts index d6b5a8d..9afbd0f 100644 --- a/packages/mpp-seller/test/unit/compose-server.test.ts +++ b/packages/mpp-seller/test/unit/compose-server.test.ts @@ -43,6 +43,29 @@ const USD = { amount: '1.0', currency: 'USD' }; const USDC = { amount: '0.0095', currency: 'USDC' }; describe('inflowCharges', () => { + it('accepts a synchronous callback without mutating its prices', async () => { + const mppx = stubMppx(); + const prices = Object.freeze([Object.freeze({ amount: '1', currency: 'USDC' })]); + const request = new Request('https://seller.test/paid'); + const handler = inflowCharges(mppx, (input) => { + expect(input).not.toBe(request); + expect(input.url).toBe(request.url); + return prices; + }); + await handler(request); + expect(mppx.calls[0]).toEqual([['inflow/charge', { amount: '1', currency: 'USDC' }]]); + }); + + it('propagates synchronous callback failures before composing a payment', async () => { + const mppx = stubMppx(); + const error = new Error('pricing failed'); + const handler = inflowCharges(mppx, () => { + throw error; + }); + await expect(handler(new Request('https://seller.test/paid'))).rejects.toBe(error); + expect(mppx.calls).toEqual([]); + }); + it('builds one inflow/charge compose entry per price, in order', () => { const mppx = stubMppx(); diff --git a/packages/x402-seller/README.md b/packages/x402-seller/README.md index 7dfa442..6549cb8 100644 --- a/packages/x402-seller/README.md +++ b/packages/x402-seller/README.md @@ -63,6 +63,10 @@ cannot load Seller configuration; `createInflowSellerClient()` rejects with an ` `accepts` field. The prices are pre-resolved to `AssetAmount` form (asset contract address + atomic-unit amount). - `inflowRoute(client, options)` — builds `accepts` and compatible sponsorship declarations. See below for explicit Permit2 selection. +- `createInflowResourceServer(client, facilitators, options?)` — creates the foundation resource server and registers + the seller's configured payment schemes. +- `createInflowHTTPResourceServer(server, routes, options?)` — accepts fixed routes or request-time route callbacks; + pass the result to the foundation adapter's `paymentMiddlewareFromHTTPServer`. - `inflowSchemeRegistrations(client, options?)` — async helper. Reads the seller's `/v1/x402/config` and returns one `SchemeRegistration` per selected `(scheme, network)` pair, with authorization as the default payment flow. Fixed-price registrations are passthrough; explicitly selected `upto` uses the foundation EVM server. Pass these @@ -125,6 +129,54 @@ fiat USD, not a stablecoin. ## Quickstart +### Request-time pricing + +Resolve the price inside a route callback, then build and optionally filter the concrete offers: + +```ts +import { paymentMiddlewareFromHTTPServer } from '@x402/express'; +import { + createInflowResourceServer, + createInflowHTTPResourceServer, + inflowAccepts, + inflowRoute, +} from '@inflowpayai/x402-seller'; + +const server = await createInflowResourceServer(client, [inflow]); +const httpServer = createInflowHTTPResourceServer(server, { + 'GET /api/widgets': async (context) => ({ + accepts: await inflowAccepts(client, { + price: context.adapter.getQueryParam?.('bundle') === 'large' ? '$0.05' : '$0.01', + schemes: ['exact', 'balance'], + }), + }), +}); +app.use(paymentMiddlewareFromHTTPServer(httpServer)); +``` + +The callback runs once per matching request, including paid retries. Return the same price for the same purchase. +Callbacks may be synchronous or asynchronous; errors and empty offer lists stop the request before payment processing or +the application handler. + +For sponsorship, pass a price callback to `inflowRoute` during setup. It captures the seller configuration and +facilitator capabilities once, then selects compatible sponsorship declarations for each request's offers: + +```ts +const sponsored = await inflowRoute(client, { + price: (context) => (context.adapter.getQueryParam?.('detail') === 'full' ? '0.02 USDC' : '0.01 USDC'), + schemes: ['exact'], + assetTransferMethod: 'permit2', +}); +const sponsoredServer = createInflowHTTPResourceServer(server, { 'GET /api/sponsored': sponsored }); +``` + +Express body-based pricing requires its body parser before payment middleware. Fastify's upstream middleware runs before +body parsing: use headers, the path, or query parameters for pricing. With Fastify, supply its exported +`x402ResourceServer` as the resource factory's `resourceServer` option and its `x402HTTPResourceServer` as the HTTP +factory's `httpServer` option. For Next.js route handlers, use `withX402FromHTTPServer`. + +### Fixed prices + ```ts import { paymentMiddlewareFromConfig } from '@x402/express'; import express from 'express'; diff --git a/packages/x402-seller/src/http-resource-server.ts b/packages/x402-seller/src/http-resource-server.ts new file mode 100644 index 0000000..ff4cce4 --- /dev/null +++ b/packages/x402-seller/src/http-resource-server.ts @@ -0,0 +1,114 @@ +import { x402HTTPResourceServer, type x402ResourceServer } from '@x402/core/server'; +import type { HTTPRequestContext, RouteConfig, RoutesConfig } from '@x402/core/http'; +import { bazaarResourceServerExtension, validateBazaarRouteExtensions } from '@x402/extensions/bazaar'; + +export type InflowRouteResolver = (context: HTTPRequestContext) => RouteConfig | Promise; +export type InflowRoutesConfig = Record; + +type HTTPServer = Pick< + x402HTTPResourceServer, + 'initialize' | 'requiresPayment' | 'processHTTPRequest' | 'processSettlement' +> & { + onProtectedRequest(hook: Parameters[0]): unknown; + registerPaywallProvider(provider: Parameters[0]): unknown; +}; + +type CoreServer = Pick & { + registerExtension(extension: Parameters[0]): unknown; +}; + +export function createInflowHTTPResourceServer( + server: Core, + routes: InflowRoutesConfig, + options: { httpServer: new (server: Core, routes: RoutesConfig) => Server }, +): Server; +export function createInflowHTTPResourceServer( + server: x402ResourceServer, + routes: InflowRoutesConfig, +): x402HTTPResourceServer; +export function createInflowHTTPResourceServer( + server: Core, + routes: InflowRoutesConfig, + options?: { httpServer: new (server: Core, routes: RoutesConfig) => Server }, +): Server | x402HTTPResourceServer { + const create = (config: RoutesConfig) => + options === undefined ? new x402HTTPResourceServer(server, config) : new options.httpServer(server, config); + const declarations: Record = {}; + const resolvers = new Map(); + for (const [pattern, route] of Object.entries(routes)) { + const declaration = typeof route === 'function' ? { accepts: [] } : route; + declarations[pattern] = declaration; + if (typeof route === 'function') resolvers.set(declaration, { pattern, resolve: route }); + } + const http = create(declarations); + const router = create(declarations); + const matched = new WeakMap< + HTTPRequestContext['adapter'], + { pattern: string; resolve: InflowRouteResolver; context: HTTPRequestContext } + >(); + // Use upstream routing, including its precedence and encoded-path handling, without processing a payment. + router.onProtectedRequest((context, route) => { + const resolver = resolvers.get(route); + if (resolver !== undefined) matched.set(context.adapter, { ...resolver, context }); + return Promise.resolve({ grantAccess: true }); + }); + const requests = new WeakMap(); + const hooks: Parameters[0][] = []; + let provider: Parameters[0] | undefined; + let initialization: Promise | undefined; + const initialize = http.initialize.bind(http); + http.initialize = () => { + initialization ??= initialize().catch((error: unknown) => { + initialization = undefined; + throw error; + }); + return initialization; + }; + const registerHook = http.onProtectedRequest.bind(http); + const registerPaywall = http.registerPaywallProvider.bind(http); + Object.assign(http, { + onProtectedRequest(hook: Parameters[0]) { + hooks.push(hook); + registerHook(hook); + return http; + }, + registerPaywallProvider(paywall: Parameters[0]) { + provider = paywall; + registerPaywall(paywall); + return http; + }, + }); + const process = http.processHTTPRequest.bind(http); + http.processHTTPRequest = async (context, paywall) => { + await router.processHTTPRequest(context); + const match = matched.get(context.adapter); + matched.delete(context.adapter); + if (match === undefined) return process(context, paywall); + const resolved = await match.resolve(match.context); + const accepts = Array.isArray(resolved.accepts) ? resolved.accepts : [resolved.accepts]; + if (accepts.length === 0) throw new Error('No payment options match the requested price and filters'); + const resolvedRoutes = { [match.pattern]: resolved }; + if (resolved.extensions?.['bazaar'] !== undefined) { + if (!server.hasExtension('bazaar')) server.registerExtension(bazaarResourceServerExtension); + validateBazaarRouteExtensions(resolvedRoutes); + } + const request = create(resolvedRoutes); + for (const hook of hooks) request.onProtectedRequest(hook); + if (provider !== undefined) request.registerPaywallProvider(provider); + const result = await request.processHTTPRequest(context, paywall); + if (result.type === 'payment-verified') requests.set(context.adapter, request); + return result; + }; + const settle = http.processSettlement.bind(http); + http.processSettlement = async (...args) => { + const adapter = args[3]?.request.adapter; + const request = adapter === undefined ? undefined : requests.get(adapter); + if (request === undefined) return settle(...args); + try { + return await request.processSettlement(...args); + } finally { + if (adapter !== undefined) requests.delete(adapter); + } + }; + return http; +} diff --git a/packages/x402-seller/src/index.ts b/packages/x402-seller/src/index.ts index 56f8efa..d1e24cf 100644 --- a/packages/x402-seller/src/index.ts +++ b/packages/x402-seller/src/index.ts @@ -8,9 +8,13 @@ export { inflowAccepts } from './inflow-accepts.js'; export type { InflowAcceptsOptions, PriceSpec } from './inflow-accepts.js'; export { inflowRoute } from './inflow-route.js'; -export type { InflowRouteOptions } from './inflow-route.js'; +export type { InflowRouteOptions, InflowDynamicPrice } from './inflow-route.js'; export { inflowSchemeRegistrations } from './scheme-registrations.js'; export type { InflowSchemeRegistration, InflowSchemeRegistrationsOptions } from './scheme-registrations.js'; +export { createInflowResourceServer } from './resource-server.js'; +export { createInflowHTTPResourceServer } from './http-resource-server.js'; +export type { InflowRouteResolver, InflowRoutesConfig } from './http-resource-server.js'; + export { X402PriceParseError } from './errors.js'; diff --git a/packages/x402-seller/src/inflow-accepts.ts b/packages/x402-seller/src/inflow-accepts.ts index 91acd53..b323580 100644 --- a/packages/x402-seller/src/inflow-accepts.ts +++ b/packages/x402-seller/src/inflow-accepts.ts @@ -7,6 +7,8 @@ import type { X402WalletInfo, } from '@inflowpayai/x402'; import type { PaymentOption } from '@x402/core/http'; +import type { AssetAmount, Price } from '@x402/core/types'; +import { parseMoney } from '@x402/core/utils'; import { getExtra } from '@inflowpayai/x402/extras'; import { X402PriceParseError } from './errors.js'; @@ -43,15 +45,13 @@ export interface PriceSpec { currency?: 'USD' | 'USDC' | 'USDT' | 'PYUSD' | (string & {}); } +type ResolvedPaymentOption = Omit & { price: AssetAmount }; + /** Options accepted by {@link inflowAccepts}. */ export interface InflowAcceptsOptions { /** Upfront settles before the handler; authorization settles after successful execution. Defaults to authorization. */ paymentFlow?: 'authorization' | 'upfront'; - /** - * Price for the protected resource. Either a {@link PriceSpec} or a string in any of the three forms accepted by - * {@link PriceSpec.amount}. - */ - price: PriceSpec | string; + price: PriceSpec | Price; /** * Maximum lifetime (seconds) attached to each emitted entry. Defaults to `300` — matches the server-side * `X402Constants.INFLOW_MAX_TIMEOUT_SECONDS` constant. @@ -72,9 +72,8 @@ export interface InflowAcceptsOptions { } /** - * Build the `RouteConfig.accepts` array from the seller's cached `/v1/x402/config`. Each entry's `price` is - * pre-resolved to `AssetAmount` form (asset contract address + atomic-unit amount), so the foundation middleware never - * needs to consult the config itself. See + * Build the `RouteConfig.accepts` array from the seller's cached `/v1/x402/config`. Each entry's `price` is resolved to + * `AssetAmount` form (asset contract address + atomic-unit amount). See * {@link https://github.com/inflowpayai/inflow-node/blob/main/docs/x402/architecture.md#inflowaccepts-algorithm | the architecture doc} * for the full emission and ordering rules. * @@ -93,6 +92,19 @@ export function buildInflowAccepts( options: InflowAcceptsOptions, assetTransferMethod?: 'permit2', ): PaymentOption[] { + if (typeof options.price === 'object' && 'asset' in options.price) { + return buildAtomicAccepts(config, options.price, options, assetTransferMethod); + } + const price = + typeof options.price === 'number' ? { amount: parseMoney(options.price).amount, currency: 'USD' } : options.price; + return structuredClone(buildStaticAccepts(config, { ...options, price }, assetTransferMethod)); +} + +function buildStaticAccepts( + config: X402ConfigResponse, + options: Omit & { price: PriceSpec | string }, + assetTransferMethod?: 'permit2', +): ResolvedPaymentOption[] { const paymentFlow: unknown = options.paymentFlow; if (paymentFlow !== undefined && paymentFlow !== 'authorization' && paymentFlow !== 'upfront') { throw new Error('paymentFlow must be authorization or upfront'); @@ -100,7 +112,7 @@ export function buildInflowAccepts( const maxTimeoutSeconds = options.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS; const priceSpec = normalizePrice(options.price); const { amount: parsedAmount, currency: targetCurrency } = parsePriceSpec(priceSpec); - const entries: PaymentOption[] = []; + const entries: ResolvedPaymentOption[] = []; // On-chain entries: one per (wallet, matching-asset, transfer-method). for (const wallet of config.wallets) { @@ -180,6 +192,44 @@ export function buildInflowAccepts( return entries; } +function buildAtomicAccepts( + config: X402ConfigResponse, + resolved: AssetAmount, + options: InflowAcceptsOptions, + assetTransferMethod?: 'permit2', +): PaymentOption[] { + const candidates = buildStaticAccepts(config, { ...options, price: '$1' }, assetTransferMethod); + const matches = candidates.filter((candidate) => sameAsset(candidate.network, candidate.price.asset, resolved.asset)); + requireOffers(matches); + return matches.map((selected) => { + if (selected.scheme === SCHEMES.INSTRUMENT) { + // Candidates were built with $1, so this amount is exactly one dollar in atomic units. + const scale = BigInt(selected.price.amount); + const minimum = 50n * scale; + if (!/^\d+$/u.test(resolved.amount)) throw new X402PriceParseError(resolved.amount); + const cents = BigInt(resolved.amount) * 100n; + if (cents < minimum || cents % scale !== 0n || cents / scale > 9223372036854775807n) { + throw new X402PriceParseError( + `${resolved.amount} (card payments require USD 0.50–92233720368547758.07 in whole cents)`, + ); + } + } + return { + ...selected, + price: { asset: resolved.asset, amount: resolved.amount }, + extra: structuredClone({ ...selected.extra, ...resolved.extra }), + }; + }); +} + +function sameAsset(network: string, left: string, right: string): boolean { + return network.startsWith('eip155:') ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function requireOffers(offers: ResolvedPaymentOption[]): void { + if (offers.length === 0) throw new X402PriceParseError('No payment options match the requested price and filters'); +} + /** * Stablecoins the seller's config advertises for non-blockchain methods. 'USD' expands to every distinct currency * present in `config.assets`; any other target returns just that currency. @@ -207,7 +257,7 @@ interface OnChainOptionArgs { schemeExtra?: Record | undefined; } -function buildOnChainOption(args: OnChainOptionArgs): PaymentOption { +function buildOnChainOption(args: OnChainOptionArgs): ResolvedPaymentOption { const { wallet, asset, method, amount, maxTimeoutSeconds } = args; const extra: Record = {}; // Human-readable currency name. Emitted on every entry regardless of network so callers can render the currency without parsing `assetId`. @@ -251,9 +301,9 @@ interface PaymentMethodOptionArgs { maxTimeoutSeconds: number; } -function buildPaymentMethodOption(args: PaymentMethodOptionArgs): PaymentOption { +function buildPaymentMethodOption(args: PaymentMethodOptionArgs): ResolvedPaymentOption { const { method, currency, amount, maxTimeoutSeconds } = args; - const base: PaymentOption = { + const base: ResolvedPaymentOption = { scheme: method.scheme, // CAIP-2 string (`'inflow:1'` for InFlow balance and Instrument payments). network: method.network as PaymentOption['network'], diff --git a/packages/x402-seller/src/inflow-route.ts b/packages/x402-seller/src/inflow-route.ts index fb5ddb1..b31933a 100644 --- a/packages/x402-seller/src/inflow-route.ts +++ b/packages/x402-seller/src/inflow-route.ts @@ -3,11 +3,16 @@ import { declareInflowEip7702GasSponsoringExtension, INFLOW_EIP7702_GAS_SPONSORING, } from '@inflowpayai/x402/extensions'; -import type { RouteConfig } from '@x402/core/http'; +import type { HTTPRequestContext, PaymentOption, RouteConfig } from '@x402/core/http'; import { declareEip2612GasSponsoringExtension, EIP2612_GAS_SPONSORING } from '@x402/extensions'; import { buildInflowAccepts, type InflowAcceptsOptions } from './inflow-accepts.js'; import type { InflowSellerClient } from './seller-client.js'; +import type { InflowRouteResolver } from './http-resource-server.js'; + +export type InflowDynamicPrice = ( + context: HTTPRequestContext, +) => InflowAcceptsOptions['price'] | Promise; export interface InflowRouteOptions extends InflowAcceptsOptions { /** @@ -23,9 +28,42 @@ export interface InflowRouteOptions extends InflowAcceptsOptions { * External wallets sign sponsorship authorizations. InFlow-managed buyers cannot sign Permit2 payments. The middleware * must route sponsored pairs to this client's InFlow facilitator; put it before competing facilitators. */ -export async function inflowRoute(client: InflowSellerClient, options: InflowRouteOptions): Promise { +export function inflowRoute( + client: InflowSellerClient, + options: Omit & { price: InflowDynamicPrice }, +): Promise; +export function inflowRoute(client: InflowSellerClient, options: InflowRouteOptions): Promise; +export async function inflowRoute( + client: InflowSellerClient, + options: Omit & { price: InflowRouteOptions['price'] | InflowDynamicPrice }, +): Promise { const config = await client.config(); - const accepts = buildInflowAccepts(config, options, options.assetTransferMethod); + const { price, ...rest } = options; + if (typeof price === 'function') { + const snapshot = structuredClone(config); + const fixed = structuredClone(rest); + const supported = await client.refreshSupported(); + return async (context) => { + const accepts = buildInflowAccepts( + snapshot, + { ...fixed, price: await price(context) }, + fixed.assetTransferMethod, + ); + const extensions = sponsorship(accepts, supported); + return Object.keys(extensions).length === 0 ? { accepts } : { accepts, extensions }; + }; + } + const accepts = buildInflowAccepts(config, { ...rest, price }, options.assetTransferMethod); + const eligible = sponsorship(accepts); + if (Object.keys(eligible).length === 0) return { accepts }; + const extensions = sponsorship(accepts, await client.refreshSupported()); + return Object.keys(extensions).length === 0 ? { accepts } : { accepts, extensions }; +} + +function sponsorship( + accepts: Pick[], + supported?: Awaited>, +): Record { const permit2Offers = accepts.filter( (offer) => offer.extra?.[EXTRA_KEYS.ASSET_TRANSFER_METHOD] === ASSET_TRANSFER_METHODS.PERMIT2, ); @@ -41,7 +79,7 @@ export async function inflowRoute(client: InflowSellerClient, options: InflowRou ); }) ) - return { accepts }; + return {}; const eip2612 = permit2Offers.every((offer) => { const name = offer.extra?.[EXTRA_KEYS.NAME]; @@ -55,8 +93,9 @@ export async function inflowRoute(client: InflowSellerClient, options: InflowRou ); }); const eip7702 = permit2Offers.every((offer) => offer.extra?.[EXTRA_KEYS.SUPPORTS_EIP7702] === true); - if (!eip2612 && !eip7702) return { accepts }; - const supported = await client.refreshSupported(); + if (!eip2612 && !eip7702) return {}; + if (supported === undefined) + return eip2612 ? declareEip2612GasSponsoringExtension() : declareInflowEip7702GasSponsoringExtension(); const supportsKinds = (requireEip7702: boolean) => permit2Offers.every((offer) => supported.kinds.some( @@ -68,10 +107,10 @@ export async function inflowRoute(client: InflowSellerClient, options: InflowRou ), ); if (eip2612 && supported.extensions?.includes(EIP2612_GAS_SPONSORING.key) === true && supportsKinds(false)) { - return { accepts, extensions: declareEip2612GasSponsoringExtension() }; + return declareEip2612GasSponsoringExtension(); } if (eip7702 && supported.extensions?.includes(INFLOW_EIP7702_GAS_SPONSORING) === true && supportsKinds(true)) { - return { accepts, extensions: declareInflowEip7702GasSponsoringExtension() }; + return declareInflowEip7702GasSponsoringExtension(); } - return { accepts }; + return {}; } diff --git a/packages/x402-seller/src/resource-server.ts b/packages/x402-seller/src/resource-server.ts new file mode 100644 index 0000000..32c08f0 --- /dev/null +++ b/packages/x402-seller/src/resource-server.ts @@ -0,0 +1,34 @@ +import { x402ResourceServer, type FacilitatorClient } from '@x402/core/server'; + +import { inflowSchemeRegistrations, type InflowSchemeRegistrationsOptions } from './scheme-registrations.js'; +import type { InflowSellerClient } from './seller-client.js'; + +type ResourceServer = { + register(...args: Parameters): unknown; +}; + +export function createInflowResourceServer( + client: InflowSellerClient, + facilitatorClients: FacilitatorClient | FacilitatorClient[], + options: InflowSchemeRegistrationsOptions & { + resourceServer: new (facilitators: FacilitatorClient | FacilitatorClient[]) => Server; + }, +): Promise; +export function createInflowResourceServer( + client: InflowSellerClient, + facilitatorClients: FacilitatorClient | FacilitatorClient[], + options?: InflowSchemeRegistrationsOptions, +): Promise; +export async function createInflowResourceServer( + client: InflowSellerClient, + facilitatorClients: FacilitatorClient | FacilitatorClient[], + options: InflowSchemeRegistrationsOptions & { + resourceServer?: new (facilitators: FacilitatorClient | FacilitatorClient[]) => ResourceServer; + } = {}, +): Promise { + const server = new (options.resourceServer ?? x402ResourceServer)(facilitatorClients); + for (const registration of await inflowSchemeRegistrations(client, options)) { + server.register(registration.network, registration.server); + } + return server; +} diff --git a/packages/x402-seller/test/integration/dynamic-pricing.test.ts b/packages/x402-seller/test/integration/dynamic-pricing.test.ts new file mode 100644 index 0000000..3aff5f3 --- /dev/null +++ b/packages/x402-seller/test/integration/dynamic-pricing.test.ts @@ -0,0 +1,420 @@ +import { once } from 'node:events'; +import { setTimeout } from 'node:timers/promises'; + +import { decodePaymentRequiredHeader, encodePaymentSignatureHeader } from '@x402/core/http'; +import type { PaymentPayload, PaymentRequirements } from '@x402/core/types'; +import type { HTTPRequestContext, PaymentOption } from '@x402/core/http'; +import { paymentMiddlewareFromHTTPServer } from '@x402/express'; +import express from 'express'; +import { describe, expect, it, vi } from 'vitest'; + +import { + createInflowFacilitator, + createInflowResourceServer, + createInflowHTTPResourceServer, + inflowAccepts, +} from '../../src/index.js'; +import type { InflowAcceptsOptions } from '../../src/index.js'; +import { SAMPLE_CONFIG } from '../fixtures/config-response.js'; +import { fakeSellerClient } from '../fixtures/seller-client.js'; + +async function fixture( + price: + | InflowAcceptsOptions['price'] + | ((context: HTTPRequestContext) => InflowAcceptsOptions['price'] | Promise), + options: Partial = {}, + configSource = SAMPLE_CONFIG, + failure?: 'verify' | 'settle', + select: (offers: PaymentOption[]) => PaymentOption[] = (offers) => offers, +) { + const config = structuredClone(configSource); + config.paymentMethods.push({ scheme: 'instrument', network: 'inflow:1', payTo: config.sellerId, decimals: 18 }); + const seller = fakeSellerClient(config); + const events: string[] = []; + const settled: PaymentRequirements[] = []; + const app = express(); + app.use(express.json()); + app.get('/v1/x402/supported', (_request, response) => + response.json({ + kinds: [...config.supported, { x402Version: 2, scheme: 'instrument', network: 'inflow:1' }], + extensions: [], + signers: {}, + }), + ); + app.post('/v1/x402/verify', (_request, response) => { + events.push('verify'); + response.json({ isValid: failure !== 'verify', ...(failure === 'verify' ? { invalidReason: 'rejected' } : {}) }); + }); + app.post, unknown, { paymentRequirements: PaymentRequirements }>( + '/v1/x402/settle', + (request, response) => { + events.push('settle'); + settled.push(request.body.paymentRequirements); + response.json({ + success: failure !== 'settle', + transaction: 'test-payment', + network: request.body.paymentRequirements.network, + ...(failure === 'settle' ? { errorReason: 'rejected' } : {}), + }); + }, + ); + const makeRoute = async (resolvedPrice: InflowAcceptsOptions['price']) => ({ + settlementFailedResponseBody: () => ({ contentType: 'application/json', body: { rejectedPrice: resolvedPrice } }), + accepts: select( + await inflowAccepts(seller, { + schemes: ['exact', 'balance'], + networks: ['eip155:8453', 'inflow:1'], + ...options, + price: resolvedPrice, + }), + ), + }); + const route = + typeof price === 'function' + ? async (context: HTTPRequestContext) => makeRoute(await price(context)) + : await makeRoute(price); + const listener = app.listen(0, '127.0.0.1'); + await once(listener, 'listening'); + const address = listener.address(); + if (address === null || typeof address === 'string') throw new Error('Expected TCP listener'); + const origin = `http://127.0.0.1:${String(address.port)}`; + app.use( + paymentMiddlewareFromHTTPServer( + createInflowHTTPResourceServer( + await createInflowResourceServer(seller, [ + createInflowFacilitator({ environment: 'sandbox', apiKey: 'test-key', baseUrl: origin }), + ]), + { 'POST /paid': route }, + ), + ), + ); + app.post('/paid', (_request, response) => { + events.push('handler'); + response.json({ paid: true }); + }); + app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => + response.status(500).json({ error: error.message }), + ); + return { + events, + settled, + config, + request: (body: unknown = {}, accepted?: PaymentRequirements) => + fetch(`${origin}/paid`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + ...(accepted === undefined + ? {} + : { + 'payment-signature': encodePaymentSignatureHeader({ + x402Version: 2, + accepted, + payload: {}, + } satisfies PaymentPayload), + }), + }, + signal: AbortSignal.timeout(5000), + }), + close: () => new Promise((resolve, reject) => listener.close((error) => (error ? reject(error) : resolve()))), + }; +} + +async function requirements(response: Response) { + expect(response.status).toBe(402); + await response.arrayBuffer(); + const header = response.headers.get('payment-required'); + if (header === null) throw new Error('Missing payment challenge'); + return decodePaymentRequiredHeader(header).accepts; +} + +describe('request pricing through foundation Express', () => { + it('preserves reordered, duplicated, and interleaved concrete offers', async () => { + const harness = await fixture( + () => '0.10 USDC', + {}, + SAMPLE_CONFIG, + undefined, + (offers) => { + const [exact, balance] = offers; + if (exact === undefined || balance === undefined) throw new Error('Missing offers'); + return [balance, { ...exact, maxTimeoutSeconds: 19 }, balance]; + }, + ); + try { + const offers = await requirements(await harness.request()); + expect(offers.map((offer) => offer.scheme)).toEqual(['balance', 'exact', 'balance']); + expect(offers.map((offer) => offer.maxTimeoutSeconds)).toEqual([300, 19, 300]); + } finally { + await harness.close(); + } + }); + + it('uses the request-specific response when after-handler settlement fails', async () => { + const harness = await fixture(() => '0.10 USDC', { schemes: ['balance'] }, SAMPLE_CONFIG, 'settle'); + try { + const [accepted] = await requirements(await harness.request()); + if (accepted === undefined) throw new Error('Missing offer'); + const response = await harness.request({}, accepted); + expect(response.status).toBe(402); + expect(await response.json()).toEqual({ rejectedPrice: '0.10 USDC' }); + expect(harness.events).toEqual(['verify', 'handler', 'settle']); + } finally { + await harness.close(); + } + }); + + it.each(['exact', 'balance'])('preserves an integrator selection of %s offers', async (scheme) => { + const harness = await fixture( + () => '0.10 USDC', + {}, + SAMPLE_CONFIG, + undefined, + (offers) => offers.filter((offer) => offer.scheme === scheme), + ); + try { + const offers = await requirements(await harness.request()); + expect(offers.map((offer) => offer.scheme)).toEqual([scheme]); + } finally { + await harness.close(); + } + }); + + it('preserves integrator recipient, timeout, and extra changes', async () => { + const harness = await fixture( + () => '0.10 USDC', + { schemes: ['balance'] }, + SAMPLE_CONFIG, + undefined, + (offers) => + offers.map((offer) => ({ + ...offer, + payTo: 'selected-recipient', + maxTimeoutSeconds: 17, + extra: { ...offer.extra, selected: true }, + })), + ); + try { + const offers = await requirements(await harness.request()); + expect(offers).toHaveLength(1); + expect(offers[0]).toMatchObject({ + payTo: 'selected-recipient', + maxTimeoutSeconds: 17, + extra: { selected: true }, + }); + } finally { + await harness.close(); + } + }); + + it.each(['authorization', 'upfront'] as const)( + 'isolates concurrent prices and preserves %s order', + async (paymentFlow) => { + const seen: unknown[] = []; + const harness = await fixture( + async (context) => { + const body = context.adapter.getBody?.() as { premium: boolean }; + seen.push(body); + await setTimeout(body.premium ? 1 : 10); + return { amount: body.premium ? '0.25' : '0.10', currency: body.premium ? 'USDT' : 'USDC' }; + }, + { paymentFlow }, + ); + try { + expect(seen).toEqual([]); + const [basic, premium] = await Promise.all([ + harness.request({ premium: false }).then(requirements), + harness.request({ premium: true }).then(requirements), + ]); + expect(seen).toHaveLength(2); + expect(basic).toHaveLength(2); + expect(premium).toHaveLength(2); + expect(basic[0]).toMatchObject({ + amount: '100000', + extra: { name: 'USD Coin', version: '2', assetTransferMethod: 'eip3009' }, + }); + expect(premium[0]).toMatchObject({ + amount: '250000', + extra: { name: 'Tether USD', version: '1', assetTransferMethod: 'permit2' }, + }); + expect(basic[1]).toMatchObject({ asset: 'USDC', amount: '100000000000000000' }); + expect(premium[1]).toMatchObject({ asset: 'USDT', amount: '250000000000000000' }); + expect(basic[0]?.extra['permit2Proxy']).toBeUndefined(); + expect(premium[0]?.extra['permit2Proxy']).toBeDefined(); + const accepted = premium[1]; + if (accepted === undefined) throw new Error('Missing balance offer'); + const wrong = await harness.request({ premium: false }, accepted); + expect(wrong.status).toBe(402); + await wrong.arrayBuffer(); + expect(harness.events).toEqual([]); + const paid = await harness.request({ premium: true }, accepted); + expect(paid.status).toBe(200); + expect(await paid.json()).toEqual({ paid: true }); + expect(harness.settled).toEqual([accepted]); + expect(harness.events).toEqual( + paymentFlow === 'upfront' ? ['settle', 'handler'] : ['verify', 'handler', 'settle'], + ); + } finally { + await harness.close(); + } + }, + ); + + it.each([ + { amount: '0.25', currency: 'USDT' }, + '0.25 USDT', + { asset: SAMPLE_CONFIG.assets[1]?.assetId ?? '', amount: '250000', extra: { custom: 'preserved' } }, + ])('resolves synchronous callback result %j', async (price) => { + const harness = await fixture(() => price, { schemes: ['exact'], networks: ['eip155:8453'] }); + try { + const offers = await requirements(await harness.request()); + expect(offers).toHaveLength(1); + expect(offers[0]).toMatchObject({ + asset: SAMPLE_CONFIG.assets[1]?.assetId, + amount: '250000', + extra: { name: 'Tether USD' }, + }); + if (typeof price === 'object' && 'asset' in price) expect(offers[0]?.extra['custom']).toBe('preserved'); + } finally { + await harness.close(); + } + }); + + it.each([0.25, '$0.25', '0.25 USD', '0.25 USDC', 1e-6])( + 'resolves money %j on Solana and retains the fee payer', + async (price) => { + const harness = await fixture(() => price, { + schemes: ['exact'], + networks: [SAMPLE_CONFIG.assets[2]?.network ?? ''], + }); + try { + const offers = await requirements(await harness.request()); + expect(offers[0]).toMatchObject({ + amount: price === 1e-6 ? '1' : '250000', + asset: SAMPLE_CONFIG.assets[2]?.assetId, + extra: { feePayer: SAMPLE_CONFIG.wallets[1]?.feePayer }, + }); + } finally { + await harness.close(); + } + }, + ); + + it.each(['$0.50', { amount: '1', currency: 'USD' }, { asset: 'USD', amount: '500000000000000000' }])( + 'retains Instrument constraints for %j', + async (price) => { + const harness = await fixture(() => price, { schemes: ['instrument'] }); + try { + const offers = await requirements(await harness.request()); + expect(offers[0]?.asset).toBe('USD'); + const paid = await harness.request({}, offers[0]); + expect(paid.status).toBe(200); + expect(await paid.json()).toEqual({ paid: true }); + expect(paid.headers.get('payment-response')).not.toBeNull(); + expect(harness.settled).toEqual(offers); + expect(harness.events).toEqual(['verify', 'handler', 'settle']); + } finally { + await harness.close(); + } + }, + ); + + it.each([ + { price: () => '1 OTHER', options: { schemes: ['exact'] } }, + { price: () => ({ asset: 'unconfigured', amount: '1' }), options: {} }, + { + price: () => { + throw new Error('pricing failed'); + }, + options: {}, + }, + { price: () => Promise.reject(new Error('pricing failed')), options: {} }, + { price: () => '0.000000001 USDC', options: {} }, + ...['-1', '499999999999999999', '500000000000000001', '92233720368547758080000000000000000'].map((amount) => ({ + price: () => ({ asset: 'USD', amount }), + options: { schemes: ['instrument'] }, + })), + ])('does not run payment or application work when pricing fails: %j', async ({ price, options }) => { + const harness = await fixture(price, options); + const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const response = await harness.request(); + expect(response.status).toBe(500); + await response.arrayBuffer(); + expect(harness.events).toEqual([]); + expect(logged).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith(expect.any(Error)); + } finally { + logged.mockRestore(); + await harness.close(); + } + }); + + it('keeps caller-owned atomic prices unchanged', async () => { + const result = { asset: SAMPLE_CONFIG.assets[0]?.assetId.toUpperCase() ?? '', amount: '250000' }; + const harness = await fixture(() => result, { schemes: ['exact'], networks: ['eip155:8453'] }); + try { + const offers = await requirements(await harness.request()); + expect(offers[0]?.amount).toBe('250000'); + expect(result).toEqual({ asset: SAMPLE_CONFIG.assets[0]?.assetId.toUpperCase(), amount: '250000' }); + } finally { + await harness.close(); + } + }); + + it.each(['$1', '0.25 USDT', { amount: '0.25', currency: 'USDC' }, '1 OTHER'])( + 'matches fixed pricing for %j across Base, Solana, balance, and Instrument', + async (price) => { + const config = structuredClone(SAMPLE_CONFIG); + config.assets = config.assets.filter((asset) => asset.currency !== 'USDT'); + const solana = config.assets.find((asset) => asset.blockchain === 'SOLANA'); + if (solana === undefined) throw new Error('Missing Solana fixture'); + config.assets.push({ ...solana, currency: 'USDT', assetName: 'USDT', assetId: 'SolanaUsdtMint' }); + const options = { + schemes: ['exact', 'balance', 'instrument'], + networks: config.supported.map((kind) => kind.network), + }; + const fixed = await fixture(price, options, config); + const callback = vi.fn(() => price); + const dynamic = await fixture(callback, options, config); + try { + expect(callback).not.toHaveBeenCalled(); + const expected = await requirements(await fixed.request()); + const actual = await requirements(await dynamic.request()); + expect(actual).toEqual(expected); + expect(callback).toHaveBeenCalledTimes(1); + if (price === '$1') expect(actual).toHaveLength(6); + if (price === '0.25 USDT') { + expect(actual).toHaveLength(2); + expect(actual.some((offer) => offer.network === 'eip155:8453')).toBe(false); + } + } finally { + await fixed.close(); + await dynamic.close(); + } + }, + ); + + it.each(['verify', 'settle'] as const)('does not release content on rejected %s', async (failure) => { + const harness = await fixture( + () => '$1', + { + schemes: ['instrument'], + paymentFlow: failure === 'verify' ? 'authorization' : 'upfront', + }, + SAMPLE_CONFIG, + failure, + ); + try { + const [accepted] = await requirements(await harness.request()); + if (accepted === undefined) throw new Error('Missing Instrument offer'); + const response = await harness.request({}, accepted); + expect(response.status).toBe(402); + await response.arrayBuffer(); + expect(harness.events).toEqual([failure]); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/x402-seller/test/integration/upfront-adapters.test.ts b/packages/x402-seller/test/integration/upfront-adapters.test.ts index f8a1242..b8ef6bc 100644 --- a/packages/x402-seller/test/integration/upfront-adapters.test.ts +++ b/packages/x402-seller/test/integration/upfront-adapters.test.ts @@ -4,19 +4,31 @@ import { decodePaymentResponseHeader, encodePaymentSignatureHeader, } from '@x402/core/http'; -import { x402ResourceServer, type FacilitatorClient } from '@x402/core/server'; -import { paymentMiddlewareFromConfig as fastifyMiddleware } from '@x402/fastify'; -import { paymentMiddlewareFromConfig as honoMiddleware } from '@x402/hono'; -import { withX402 } from '@x402/next'; +import type { FacilitatorClient } from '@x402/core/server'; +import type { HTTPRequestContext } from '@x402/core/http'; +import { + paymentMiddlewareFromHTTPServer as fastifyMiddleware, + x402ResourceServer as FastifyResourceServer, + x402HTTPResourceServer as FastifyHTTPResourceServer, +} from '@x402/fastify'; +import { paymentMiddlewareFromHTTPServer as honoMiddleware } from '@x402/hono'; +import { withX402FromHTTPServer } from '@x402/next'; import Fastify from 'fastify'; import { Hono } from 'hono'; import { NextRequest, NextResponse } from 'next/server.js'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { inflowAccepts, inflowSchemeRegistrations } from '../../src/index.js'; +import { createInflowResourceServer, createInflowHTTPResourceServer, inflowAccepts } from '../../src/index.js'; import { fakeSellerClient } from '../fixtures/seller-client.js'; -describe.each(['Fastify', 'Hono', 'Next'] as const)('%s upfront execution', (adapter) => { +describe.each([ + ['Fastify', 'fixed'], + ['Fastify', 'dynamic'], + ['Hono', 'fixed'], + ['Hono', 'dynamic'], + ['Next', 'fixed'], + ['Next', 'dynamic'], +] as const)('%s upfront execution with %s prices', (adapter, pricing) => { it.each(['success', 'rejected', 'handler-error'] as const)( 'handles %s without an after-handler charge', async (outcome) => { @@ -53,28 +65,51 @@ describe.each(['Fastify', 'Hono', 'Next'] as const)('%s upfront execution', (ada }, }; const seller = fakeSellerClient(); - const route = { + const price = vi.fn((context: HTTPRequestContext) => { + if (adapter === 'Fastify') expect(context.adapter.getBody?.()).toBeUndefined(); + const amount = context.adapter.getQueryParam?.('amount'); + return { amount: typeof amount === 'string' ? amount : '1', currency: 'USDC' }; + }); + const makeRoute = async (resolvedPrice: Parameters[1]['price']) => ({ accepts: await inflowAccepts(seller, { - price: '1 USDC', + price: resolvedPrice, schemes: ['exact'], networks: ['eip155:8453'], paymentFlow: 'upfront', }), - }; - const registrations = await inflowSchemeRegistrations(seller); + }); + const route = + pricing === 'dynamic' ? (context: HTTPRequestContext) => makeRoute(price(context)) : await makeRoute('1 USDC'); + const server = await createInflowResourceServer(seller, facilitator); const status = outcome === 'handler-error' ? 500 : 200; let request: (headers?: Record) => Promise; let close = () => Promise.resolve(); if (adapter === 'Fastify') { const app = Fastify(); - fastifyMiddleware(app, { 'GET /report': route }, [facilitator], registrations); - app.get('/report', (_request, reply) => { + const fastifyServer = await createInflowResourceServer(seller, facilitator, { + resourceServer: FastifyResourceServer, + }); + fastifyMiddleware( + app, + createInflowHTTPResourceServer( + fastifyServer, + { 'POST /report': route }, + { httpServer: FastifyHTTPResourceServer }, + ), + ); + app.post('/report', (incoming, reply) => { + expect(incoming.body).toEqual({ quantity: 3 }); events.push('handler'); return reply.status(status).send({ report: true }); }); await app.ready(); request = async (headers = {}) => { - const response = await app.inject({ method: 'GET', url: '/report', headers }); + const response = await app.inject({ + method: 'POST', + url: '/report?amount=0.25', + headers, + payload: { quantity: 3 }, + }); const output = new Headers(); for (const [name, value] of Object.entries(response.headers)) { if (Array.isArray(value)) for (const item of value) output.append(name, item); @@ -85,41 +120,41 @@ describe.each(['Fastify', 'Hono', 'Next'] as const)('%s upfront execution', (ada close = () => app.close(); } else if (adapter === 'Hono') { const app = new Hono(); - app.use('*', honoMiddleware({ 'GET /report': route }, [facilitator], registrations)); + app.use('*', honoMiddleware(createInflowHTTPResourceServer(server, { 'GET /report': route }))); app.get('/report', (context) => { events.push('handler'); return context.json({ report: true }, status); }); - request = async (headers = {}) => app.request('http://localhost/report', { headers }); + request = async (headers = {}) => app.request('http://localhost/report?amount=0.25', { headers }); } else { - const server = new x402ResourceServer(facilitator); - for (const registration of registrations) server.register(registration.network, registration.server); - const handler = withX402( + const handler = withX402FromHTTPServer( () => { events.push('handler'); return Promise.resolve(NextResponse.json({ report: true }, { status })); }, - route, - server, + createInflowHTTPResourceServer(server, { 'GET /report': route }), ); request = async (headers = {}) => { - const response: unknown = await handler(new NextRequest('http://localhost/report', { headers })); + const response: unknown = await handler(new NextRequest('http://localhost/report?amount=0.25', { headers })); if (!(response instanceof Response)) throw new Error('Expected Next response'); return response; }; } try { + expect(price).not.toHaveBeenCalled(); const challenge = await request(); expect(challenge.status).toBe(402); const header = challenge.headers.get('payment-required'); if (header === null) throw new Error('Missing challenge'); const accepted = decodePaymentRequiredHeader(header).accepts[0]; if (accepted === undefined) throw new Error('Missing requirements'); + expect(accepted.amount).toBe(pricing === 'dynamic' ? '250000' : '1000000'); expect(events).toEqual([]); const response = await request({ 'payment-signature': encodePaymentSignatureHeader({ x402Version: 2, accepted, payload: {} }), }); expect(response.status).toBe(outcome === 'rejected' ? 402 : status); + expect(price).toHaveBeenCalledTimes(pricing === 'dynamic' ? 2 : 0); expect(events).toEqual(outcome === 'rejected' ? ['settle'] : ['settle', 'handler']); const receipt = response.headers.get('payment-response'); if (receipt === null) throw new Error('Missing settlement receipt'); @@ -133,3 +168,99 @@ describe.each(['Fastify', 'Hono', 'Next'] as const)('%s upfront execution', (ada }, ); }); + +describe.each(['Hono', 'Next'] as const)('%s body-based authorization pricing', (adapter) => { + it.each([true, false])('preserves the body and resolved route when settlement success is %s', async (success) => { + const events: string[] = []; + const facilitator: FacilitatorClient = { + getSupported: () => + Promise.resolve({ + kinds: [{ x402Version: 2, scheme: 'balance', network: 'inflow:1' }], + extensions: [], + signers: {}, + }), + verify: () => { + events.push('verify'); + return Promise.resolve({ isValid: true }); + }, + settle: (_payment, requirements) => { + events.push('settle'); + expect(requirements.amount).toBe('250000000000000000'); + return Promise.resolve({ + success, + transaction: 'fixture', + network: requirements.network, + ...(success ? {} : { errorReason: 'settlement_failed' }), + }); + }, + }; + const seller = fakeSellerClient(); + const resolveRoute = vi.fn(async (context: HTTPRequestContext) => { + const body: unknown = await context.adapter.getBody?.(); + expect(body).toEqual({ tier: 'premium' }); + if (typeof body !== 'object' || body === null || !('tier' in body)) throw new Error('Missing tier'); + return { + accepts: await inflowAccepts(seller, { + price: body.tier === 'premium' ? '0.25 USDC' : '0.10 USDC', + schemes: ['balance'], + }), + settlementFailedResponseBody: () => ({ + contentType: 'application/json', + body: { rejectedTier: body.tier }, + }), + }; + }); + const server = createInflowHTTPResourceServer(await createInflowResourceServer(seller, facilitator), { + 'POST /report': resolveRoute, + }); + let request: (init: { method: string; body: string; headers: Record }) => Promise; + if (adapter === 'Hono') { + const app = new Hono(); + app.use('*', honoMiddleware(server)); + app.post('/report', async (context) => { + expect(await context.req.json()).toEqual({ tier: 'premium' }); + events.push('handler'); + return context.json({ report: true }); + }); + request = async (init) => app.request('http://localhost/report', init); + } else { + const handler = withX402FromHTTPServer(async (incoming: NextRequest) => { + expect(await incoming.json()).toEqual({ tier: 'premium' }); + events.push('handler'); + return NextResponse.json({ report: true }); + }, server); + request = async (init) => { + const response: unknown = await handler(new NextRequest('http://localhost/report', init)); + if (!(response instanceof Response)) throw new Error('Expected Next response'); + return response; + }; + } + const init = { + method: 'POST', + body: JSON.stringify({ tier: 'premium' }), + headers: { 'content-type': 'application/json' }, + }; + const challenge = await request(init); + expect(challenge.status).toBe(402); + const header = challenge.headers.get('payment-required'); + if (header === null) throw new Error('Missing challenge'); + const accepted = decodePaymentRequiredHeader(header).accepts[0]; + if (accepted === undefined) throw new Error('Missing requirements'); + expect(accepted.amount).toBe('250000000000000000'); + expect(events).toEqual([]); + const response = await request({ + ...init, + headers: { + ...init.headers, + 'payment-signature': encodePaymentSignatureHeader({ x402Version: 2, accepted, payload: {} }), + }, + }); + expect(response.status).toBe(success ? 200 : 402); + expect(await response.json()).toEqual(success ? { report: true } : { rejectedTier: 'premium' }); + expect(events).toEqual(['verify', 'handler', 'settle']); + expect(resolveRoute).toHaveBeenCalledTimes(2); + const receipt = response.headers.get('payment-response'); + if (receipt === null) throw new Error('Missing settlement receipt'); + expect(decodePaymentResponseHeader(receipt).success).toBe(success); + }); +}); diff --git a/packages/x402-seller/test/unit/inflow-route.test.ts b/packages/x402-seller/test/unit/inflow-route.test.ts index 3169437..c4fd363 100644 --- a/packages/x402-seller/test/unit/inflow-route.test.ts +++ b/packages/x402-seller/test/unit/inflow-route.test.ts @@ -4,13 +4,21 @@ import { declareInflowEip7702GasSponsoringExtension, } from '@inflowpayai/x402/extensions'; import { x402Client } from '@x402/core/client'; -import { decodePaymentRequiredHeader, x402HTTPResourceServer, type HTTPAdapter } from '@x402/core/http'; +import { + decodePaymentRequiredHeader, + encodePaymentSignatureHeader, + x402HTTPResourceServer, + type HTTPAdapter, + type HTTPRequestContext, +} from '@x402/core/http'; import { x402ResourceServer, type FacilitatorClient } from '@x402/core/server'; import { registerExactEvmScheme, type EvmClientConfig } from '@x402/evm/exact/client'; import { declareEip2612GasSponsoringExtension, EIP2612_GAS_SPONSORING } from '@x402/extensions'; import { describe, expect, it, vi } from 'vitest'; import { inflowAccepts } from '../../src/inflow-accepts.js'; +import { createInflowResourceServer } from '../../src/resource-server.js'; +import { createInflowHTTPResourceServer } from '../../src/http-resource-server.js'; import { inflowRoute, type InflowRouteOptions } from '../../src/inflow-route.js'; import { inflowSchemeRegistrations } from '../../src/scheme-registrations.js'; import { SAMPLE_CONFIG, SAMPLE_SUPPORTED } from '../fixtures/config-response.js'; @@ -42,6 +50,91 @@ function fixture(assetOverrides: Partial = {}) { } describe('inflowRoute', () => { + it('selects sponsorship per request without mutating routes or losing declarations during settlement', async () => { + const { client, config, asset, supported } = fixture(); + config.assets.push( + { ...asset, assetId: '0x2222222222222222222222222222222222222222', currency: 'USDT', supportsEip2612: false }, + { + ...asset, + assetId: '0x3333333333333333333333333333333333333333', + currency: 'DAI', + supportsEip2612: false, + supportsEip7702: true, + }, + ); + supported.extensions.push(INFLOW_EIP7702_GAS_SPONSORING); + supported.kinds = [{ x402Version: 2, scheme: 'exact', network: 'eip155:8453', extra: { supportsEip7702: true } }]; + const price = vi.fn(async (context: { adapter: HTTPAdapter }) => { + const currency = context.adapter.getQueryParam?.('currency'); + await Promise.resolve(); + return { amount: '1', currency: typeof currency === 'string' ? currency : 'USDC' }; + }); + const resolve = await inflowRoute(client, { price, schemes: ['exact'], assetTransferMethod: 'permit2' }); + const route = async (context: HTTPRequestContext) => { + const resolved = await resolve(context); + return { ...resolved, extensions: { ...resolved.extensions, resolve: { info: { keep: true } } } }; + }; + const resource = await createInflowResourceServer(client, { + getSupported: () => + Promise.resolve({ + kinds: [{ x402Version: 2, scheme: 'exact', network: 'eip155:8453' }], + extensions: supported.extensions, + signers: {}, + }), + verify: () => Promise.resolve({ isValid: true }), + settle: () => Promise.resolve({ success: true, network: 'eip155:8453', transaction: 'test-payment' }), + }); + const verified: unknown[] = []; + const settled: unknown[] = []; + resource.onBeforeVerify((context) => { + verified.push(structuredClone(context.declaredExtensions)); + return Promise.resolve(); + }); + resource.onBeforeSettle((context) => { + settled.push(structuredClone(context.declaredExtensions)); + return Promise.resolve(); + }); + const http = createInflowHTTPResourceServer(resource, { 'GET /paid': route }); + await http.initialize(); + const request = (currency: string, payment?: string) => { + const adapter: HTTPAdapter = { + getHeader: (name) => (name.toLowerCase() === 'payment-signature' ? payment : undefined), + getMethod: () => 'GET', + getPath: () => '/paid', + getUrl: () => 'https://seller.example/paid?currency=' + currency, + getQueryParam: () => currency, + getAcceptHeader: () => 'application/json', + getUserAgent: () => 'test', + }; + return http.processHTTPRequest({ adapter, path: '/paid', method: 'GET' }); + }; + const currencies = ['USDC', 'USDT', 'DAI', 'USD']; + const challenges = await Promise.all(currencies.map((currency) => request(currency))); + expect(client.refreshSupported).toHaveBeenCalledTimes(1); + for (const [index, currency] of currencies.entries()) { + const challenge = challenges[index]; + if (challenge?.type !== 'payment-error') throw new Error('Expected challenge'); + const header = challenge.response.headers['PAYMENT-REQUIRED']; + if (header === undefined) throw new Error('Missing challenge header'); + const required = decodePaymentRequiredHeader(header); + const fixed = await inflowRoute(client, { + price: `1 ${currency}`, + schemes: ['exact'], + assetTransferMethod: 'permit2', + }); + expect(required.extensions).toEqual({ ...fixed.extensions, resolve: { info: { keep: true } } }); + const accepted = required.accepts[0]; + if (accepted === undefined) throw new Error('Missing offer'); + const paid = await request(currency, encodePaymentSignatureHeader({ x402Version: 2, accepted, payload: {} })); + if (paid.type !== 'payment-verified') throw new Error('Expected verification'); + expect(verified.at(-1)).toEqual(required.extensions); + expect(paid.declaredExtensions).toEqual(required.extensions); + await http.processSettlement(paid.paymentPayload, paid.paymentRequirements, paid.declaredExtensions); + expect(settled.at(-1)).toEqual(required.extensions); + } + expect(price).toHaveBeenCalledTimes(8); + }); + function eip7702Fixture() { const f = fixture({ supportsEip2612: false, supportsEip7702: true }); f.supported.extensions.push(INFLOW_EIP7702_GAS_SPONSORING); diff --git a/packages/x402-seller/test/unit/resource-server.test.ts b/packages/x402-seller/test/unit/resource-server.test.ts new file mode 100644 index 0000000..15a403d --- /dev/null +++ b/packages/x402-seller/test/unit/resource-server.test.ts @@ -0,0 +1,223 @@ +import type { HTTPRequestContext, PaymentOption } from '@x402/core/http'; +import type { FacilitatorClient } from '@x402/core/server'; +import type { Network } from '@x402/core/types'; +import { describe, expect, it, vi } from 'vitest'; +import { declareDiscoveryExtension } from '@x402/extensions/bazaar'; + +import { + createInflowResourceServer, + createInflowHTTPResourceServer, + inflowAccepts, + inflowSchemeRegistrations, +} from '../../src/index.js'; +import { SAMPLE_CONFIG } from '../fixtures/config-response.js'; +import { fakeSellerClient } from '../fixtures/seller-client.js'; + +const context: HTTPRequestContext = { + path: '/paid', + method: 'GET', + adapter: { + getHeader: () => undefined, + getMethod: () => 'GET', + getPath: () => '/paid', + getUrl: () => 'https://example.com/paid', + getAcceptHeader: () => 'application/json', + getUserAgent: () => 'test', + }, +}; + +function facilitator(kinds = SAMPLE_CONFIG.supported) { + return { + getSupported: vi.fn(() => + Promise.resolve({ + // Fixture network strings are CAIP-2 identifiers. + kinds: kinds.map((kind) => ({ ...kind, network: kind.network as Network })), + extensions: [], + signers: {}, + }), + ), + verify: vi.fn(() => Promise.resolve({ isValid: true })), + settle: vi.fn(() => Promise.resolve({ success: true, transaction: 'payment', network: 'inflow:1' as const })), + } satisfies FacilitatorClient; +} + +describe('request-priced resource server', () => { + it('preserves native routing precedence, decoded paths, fixed routes, and unmatched requests', async () => { + const server = await createInflowResourceServer(fakeSellerClient(), facilitator()); + const route = { accepts: await inflowAccepts(fakeSellerClient(), { price: '$1' }) }; + const plain = vi.fn(() => route); + const encoded = vi.fn(() => route); + const http = createInflowHTTPResourceServer(server, { + 'GET /paid/a': plain, + 'GET /paid/%61': encoded, + 'GET /fixed': route, + }); + await http.initialize(); + const input = { ...context, path: '/paid/%61', decodedPath: '/paid/a' }; + expect(http.requiresPayment(input)).toBe(true); + expect((await http.processHTTPRequest(input)).type).toBe('payment-error'); + expect(plain).toHaveBeenCalledOnce(); + expect(encoded).not.toHaveBeenCalled(); + expect(plain).toHaveBeenCalledWith(expect.objectContaining({ routePattern: '/paid/a' })); + expect( + (await http.processHTTPRequest({ ...context, path: '/paid/%61?query=1', decodedPath: '/paid/a?query=1' })).type, + ).toBe('payment-error'); + expect((await http.processHTTPRequest({ ...context, path: '/fixed' })).type).toBe('payment-error'); + expect(await http.processHTTPRequest({ ...context, path: '/free' })).toEqual({ type: 'no-payment-required' }); + expect(await http.processHTTPRequest({ ...context, path: '/paid/a', method: 'POST' })).toEqual({ + type: 'no-payment-required', + }); + expect(plain).toHaveBeenCalledTimes(2); + expect(encoded).not.toHaveBeenCalled(); + }); + + it('retains HTTP hooks, paywall rendering, and single-option route results', async () => { + const server = await createInflowResourceServer(fakeSellerClient(), facilitator()); + const [offer] = await inflowAccepts(fakeSellerClient(), { price: '$1', schemes: ['balance'] }); + if (offer === undefined) throw new Error('Missing offer'); + const http = createInflowHTTPResourceServer(server, { 'GET /paid': () => ({ accepts: offer }) }); + const hook = vi.fn(() => Promise.resolve()); + const generateHtml = vi.fn(() => 'Pay here'); + expect(http.onProtectedRequest(hook)).toBe(http); + expect(http.registerPaywallProvider({ generateHtml })).toBe(http); + await http.initialize(); + const input = { + ...context, + adapter: { ...context.adapter, getAcceptHeader: () => 'text/html', getUserAgent: () => 'Mozilla/5.0' }, + }; + expect(await http.processHTTPRequest(input)).toMatchObject({ + type: 'payment-error', + response: { body: 'Pay here', isHtml: true }, + }); + expect(hook).toHaveBeenCalledOnce(); + expect(generateHtml).toHaveBeenCalledOnce(); + http.onProtectedRequest(() => Promise.resolve({ grantAccess: true })); + expect(await http.processHTTPRequest(context)).toEqual({ type: 'no-payment-required' }); + }); + + it('uses upstream Bazaar registration and enrichment for resolved declarations', async () => { + const server = await createInflowResourceServer(fakeSellerClient(), facilitator()); + const accepts = await inflowAccepts(fakeSellerClient(), { price: '$1' }); + const extensions = declareDiscoveryExtension({ output: { example: { paid: true } } }); + const http = createInflowHTTPResourceServer(server, { 'GET /paid': () => ({ accepts, extensions }) }); + await http.initialize(); + expect(server.hasExtension('bazaar')).toBe(false); + expect((await http.processHTTPRequest(context)).type).toBe('payment-error'); + expect(server.hasExtension('bazaar')).toBe(true); + expect((await http.processHTTPRequest(context)).type).toBe('payment-error'); + }); + + it('preserves ordinary upstream extension enrichment without mutating the declaration', async () => { + const server = await createInflowResourceServer(fakeSellerClient(), facilitator()); + const enrich = vi.fn(() => ({ info: { url: context.adapter.getUrl() } })); + server.registerExtension({ key: 'custom', enrichDeclaration: enrich }); + const declaration = { custom: { info: { original: true } } }; + const result = server.enrichExtensions(declaration, context); + expect(result).toEqual({ custom: { info: { url: 'https://example.com/paid' } } }); + expect(enrich).toHaveBeenCalledWith(declaration.custom, context); + expect(declaration).toEqual({ custom: { info: { original: true } } }); + }); + + it('resolves independent helper groups once, preserving fixed options and foreign facilitator routing', async () => { + const seller = fakeSellerClient(); + const inflow = facilitator(); + const external = facilitator([{ scheme: 'exact', network: 'eip155:1', x402Version: 2 }]); + const server = await createInflowResourceServer(seller, [inflow, external]); + const [registration] = await inflowSchemeRegistrations(seller, { schemes: ['exact'] }); + if (registration === undefined) throw new Error('Missing exact registration'); + server.register('eip155:1', registration.server); + await server.initialize(); + const first = vi.fn(() => '$1'); + const second = vi.fn(() => '0.25 USDT'); + const foreignPrice = vi.fn(() => ({ asset: 'foreign-token', amount: '10' })); + const foreignPayTo = vi.fn(() => 'foreign-seller'); + const fixed = await inflowAccepts(seller, { price: '0.10 USDC' }); + const options: PaymentOption[] = [ + ...(await inflowAccepts(seller, { price: first() })), + ...fixed, + { scheme: 'exact', network: 'eip155:1', price: foreignPrice, payTo: foreignPayTo }, + ...(await inflowAccepts(seller, { price: second() })), + ]; + const before = JSON.stringify(options); + const offers = await server.buildPaymentRequirementsFromOptions(options, context); + expect(offers).toHaveLength(11); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + expect(foreignPrice).toHaveBeenCalledWith(context); + expect(foreignPayTo).toHaveBeenCalledWith(context); + const foreign = offers.find((offer) => offer.network === 'eip155:1'); + if (foreign === undefined) throw new Error('Missing foreign offer'); + await server.verifyPayment({ x402Version: 2, accepted: foreign, payload: {} }, foreign); + await server.settlePayment({ x402Version: 2, accepted: foreign, payload: {} }, foreign); + expect(external.verify).toHaveBeenCalledTimes(1); + expect(external.settle).toHaveBeenCalledTimes(1); + expect(inflow.verify).not.toHaveBeenCalled(); + expect(inflow.settle).not.toHaveBeenCalled(); + expect(inflow.getSupported).toHaveBeenCalledTimes(1); + expect(external.getSupported).toHaveBeenCalledTimes(1); + expect(JSON.stringify(options)).toBe(before); + }); + + it('rejects empty resolved routes without invoking the facilitator', async () => { + const seller = fakeSellerClient(); + const provider = facilitator(); + const server = await createInflowResourceServer(seller, provider); + const http = createInflowHTTPResourceServer(server, { 'GET /paid': () => ({ accepts: [] }) }); + await http.initialize(); + await expect(http.processHTTPRequest(context)).rejects.toThrow('No payment options'); + expect(provider.verify).not.toHaveBeenCalled(); + }); + + it('retries failed initialization and shares successful initialization', async () => { + const provider = facilitator(); + provider.getSupported.mockRejectedValueOnce(new Error('unavailable')); + const server = await createInflowResourceServer(fakeSellerClient(), provider); + const http = createInflowHTTPResourceServer(server, { 'GET /paid': () => ({ accepts: [] }) }); + const logged = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await expect(http.initialize()).rejects.toThrow('Failed to initialize'); + await Promise.all([http.initialize(), http.initialize()]); + await http.initialize(); + expect(provider.getSupported).toHaveBeenCalledTimes(2); + } finally { + logged.mockRestore(); + } + }); + + it('keeps atomic-price metadata independent between requests and caller-owned objects', async () => { + const seller = fakeSellerClient(); + const server = await createInflowResourceServer(seller, facilitator(), { schemes: ['balance'] }); + await server.initialize(); + const result = { asset: 'USDC', amount: '123', extra: { custom: { value: 'original' } } }; + const options = await inflowAccepts(seller, { price: result, schemes: ['balance'] }); + const [first] = await server.buildPaymentRequirementsFromOptions(options, context); + if (first === undefined) throw new Error('Missing offer'); + (first.extra['custom'] as { value: string }).value = 'changed'; + const [second] = await server.buildPaymentRequirementsFromOptions( + await inflowAccepts(seller, { price: result, schemes: ['balance'] }), + context, + ); + expect(second?.extra['custom']).toEqual({ value: 'original' }); + expect(result.extra.custom.value).toBe('original'); + }); + + it('isolates nested configuration metadata between resolved requests', async () => { + const config = structuredClone(SAMPLE_CONFIG); + const [method] = config.paymentMethods; + if (method === undefined) throw new Error('Missing balance method'); + method.extra = { custom: { value: 'original' } }; + const seller = fakeSellerClient(config); + const server = await createInflowResourceServer(seller, facilitator()); + await server.initialize(); + const options = await inflowAccepts(seller, { price: '1 USDC', schemes: ['balance'] }); + const [first] = await server.buildPaymentRequirementsFromOptions(options, context); + if (first === undefined) throw new Error('Missing offer'); + (first.extra['custom'] as { value: string }).value = 'changed'; + const [second] = await server.buildPaymentRequirementsFromOptions( + await inflowAccepts(seller, { price: '1 USDC', schemes: ['balance'] }), + context, + ); + expect(second?.extra['custom']).toEqual({ value: 'original' }); + expect(method.extra).toEqual({ custom: { value: 'original' } }); + }); +});