@profullstack/x402-gateway 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,6 +83,7 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
83
83
  | `siteUrl` | required | canonical origin, no trailing slash |
84
84
  | `coinpay.apiKey` | | a **scoped** CoinPay key (`cp_live_…`, from the business's API Keys tab) with `payments:create`. The legacy business key is refused by CoinPay's x402 routes. |
85
85
  | `payTo` | | EVM address that receives the USDC, on Base, Polygon and Ethereum alike |
86
+ | `nano` | off | `{ payTo: 'nano_…', rate }`: also offer XNO on network `nano`, paid straight to that address. `rate` is the price of 1 XNO in `currency` (a number, or a function read per offer); the amount is in raw (10^30 per XNO), rounded up. `xrb_` payees are rewritten to `nano_`. The buyer sends the block first and presents its hash, so a rate that moves between quote and proof refuses a payment already sent: keep it steady. CoinPay refuses every Nano proof unless its `NANO_RPC_URL` points at a Nano node |
86
87
  | `priceCents` | `100` | |
87
88
  | `passMinutes` | `1440` | a day: the term one price buys |
88
89
  | `maxDays` | `30` | the most terms one proof may buy at once |
@@ -111,6 +112,7 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
111
112
  | `denyCidrs` | `[]` | IPv4 ranges answered with a tiny `403` before anything else. For a VPS fleet that spoofs a browser: hosting ranges serve no readers. |
112
113
  | `chargeSpoofedBrowsers` | `false` | charge a request that claims `Chrome/…` but sends no `Sec-Fetch-Mode`. Every Chromium since 76, headless included, sends it on every request and no script or extension can remove it, so its absence means an HTTP client with a copied string. Firefox and Safari are not judged. |
113
114
  | `exempt` | | `(request) => boolean`, never charged: e.g. a request carrying your signed-in cookie |
115
+ | `appPaths` | `APP_PATHS` | extra paths never charged, added to the default sign-in, account, checkout, pay and webhook paths (`/login`, `/signup`, `/oauth`, `/dashboard`, `/checkout`, `/pay`, `/webhooks`, `/api/auth`, `/api/health`, ...; see `APP_PATHS`). Matched by whole segment. An app whose whole `/api/` is credentialed adds `'/api'`. `false` charges them like any page |
114
116
  | `powerKeys` | Profullstack's | Power Keys honoured as a pass, kid to public key. `false` switches it off. See below. |
115
117
  | `revokedKeys` | | Power Key subjects no longer honoured |
116
118
 
@@ -207,6 +209,8 @@ The sales page shows a **Buy a day pass** form (email, days) whenever CoinPay se
207
209
 
208
210
  USDC on Base, Polygon and Ethereum, and nothing else on purpose: CoinPay's v2 settle broadcasts EIP-3009 `transferWithAuthorization` only on chains 1, 137 and 8453, and USDC is the only EIP-3009 dollar token there (USDT has none). An offer for anything else would verify and never settle. Every other coin, and PayPal, is the checkout button's job.
209
211
 
212
+ The exception, opt-in, is XNO with `nano` set. CoinPay settles network `nano` without a relayer: the buyer publishes a send block to `nano.payTo`, and CoinPay's verify and settle ask a Nano node that the block is a confirmed send of at least the quoted raw amount to that address. The block hash is the replay key and the pass's `ref`. CoinPay needs `NANO_RPC_URL` for this; without it every Nano proof is refused.
213
+
210
214
  ## Who is on which list
211
215
 
212
216
  `TRAINING_AGENTS` is the documented corpus-crawl token of each operator; `RETRIEVAL_AGENTS` the search half of the same pairs: GPTBot / OAI-SearchBot, ClaudeBot / Claude-SearchBot, meta-externalagent / Meta-ExternalFetcher, Applebot-Extended / Applebot. Google-Extended stays welcome because Google documents it as also gating Gemini app grounding. Matching is a substring of the user agent, which identifies a self-declared crawler and nothing more: a crawler wearing a browser's user agent walks past this, and that one needs blocking at the edge.
package/index.d.ts CHANGED
@@ -74,6 +74,13 @@ export interface GatewayOptions {
74
74
  coinpay?: { apiKey?: string; baseUrl?: string };
75
75
  /** EVM address that receives the USDC. */
76
76
  payTo?: string;
77
+ /**
78
+ * Opt-in XNO on Nano: a `nano_` (or legacy `xrb_`) payee and the price of
79
+ * 1 XNO in `currency`, as a number or a function read per offer. Without a
80
+ * valid payee and a positive rate the offer is USDC only. CoinPay refuses
81
+ * Nano proofs unless its NANO_RPC_URL is set.
82
+ */
83
+ nano?: { payTo: string; rate: number | (() => number) };
77
84
  /** Default 100 ($1). */
78
85
  priceCents?: number;
79
86
  currency?: string;
@@ -114,6 +121,8 @@ export interface GatewayOptions {
114
121
  welcome?: WelcomeCrawler[] | false;
115
122
  /** Requests never charged, e.g. ones carrying a signed-in cookie. */
116
123
  exempt?: (request: Request) => boolean;
124
+ /** Extra sign-in, account and payment paths never charged, appended to APP_PATHS and matched by whole segment. `false` charges APP_PATHS like any page. */
125
+ appPaths?: string[] | false;
117
126
  /** Pass signing secret. Defaults to the CoinPay key. */
118
127
  secret?: string;
119
128
  page?: (ctx: PageContext) => string;
@@ -192,7 +201,7 @@ export interface AcceptEntry {
192
201
  description: string;
193
202
  mimeType: string;
194
203
  maxTimeoutSeconds: number;
195
- extra: { name: string; version: string };
204
+ extra: { name: string; version: string } | { decimals: number };
196
205
  }
197
206
 
198
207
  export interface Offer {
@@ -274,6 +283,10 @@ export function wantsHtml(accept?: string | null): boolean;
274
283
  export const TRAINING_AGENTS: string[];
275
284
  export const SCRAPER_AGENTS: string[];
276
285
  export const HEADLESS_AGENTS: string[];
286
+ /** Sign-in, account, payment and webhook paths the gateway never charges. */
287
+ export const APP_PATHS: readonly string[];
288
+ /** Whether `path` is one of `paths` (default APP_PATHS) or below it, by whole segment. */
289
+ export function isAppPath(path: string, paths?: readonly string[]): boolean;
277
290
  export const AI_INDEX_AGENTS: string[];
278
291
  export function challengeToken(secret: string, ip: string, day?: number): Promise<string>;
279
292
  export function passedChallenge(request: Request, options: { secret: string; cookie: string; ip: string; work?: number; css?: boolean; now?: number }): Promise<boolean>;
@@ -313,7 +326,11 @@ export function readPass(token: string, args: { secret: string; now?: number }):
313
326
 
314
327
  export const METHODS: Array<{ key: string; network: string; asset: string; label: string }>;
315
328
  export const X402_METHODS: typeof METHODS;
316
- export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS }): Offer;
329
+ export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS; nano?: { payTo: string; amount: string } | null }): Offer;
330
+ /** A Nano payee in canonical `nano_` form, or null. */
331
+ export function nanoPayee(address: unknown): string | null;
332
+ /** A price in raw XNO (10^30 per XNO), rounded up, at `rate` per XNO; null if either is not positive. */
333
+ export function xnoRaw(priceCents: number, rate: number): string | null;
317
334
  export function decodePayment(header: string | null | undefined): Record<string, unknown> | null;
318
335
  export function decodeHeader(header: string | null | undefined): Record<string, unknown> | null;
319
336
  export function encodeHeader(value: unknown): string;
@@ -322,14 +339,14 @@ export function paymentResponseHeader(args: { transaction?: string | null; netwo
322
339
  export function renderNotice(ctx: { siteName: string; siteUrl: string; buyUrl: string; title: string; body: string }): string;
323
340
  export function renderPassPage(ctx: { siteName: string; siteUrl: string; buyUrl: string; token: string; expiresAt: string; days: number; header: string; cookie: string; email?: string | null }): string;
324
341
  export function sessionIdFromWebhook(body: unknown): string | null;
325
- export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset: string } | null;
342
+ export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset?: string } | null;
326
343
  /** The value a proof authorizes, in the token's smallest unit, or null. */
327
344
  export function paidValueOf(payment: unknown): bigint | null;
328
345
  /** How many terms `value` buys at `unit` per term: a whole number in [1, maxDays], or 0. */
329
346
  export function daysPaid(value: bigint | null, unit: string | number | bigint, maxDays: number): number;
330
347
  export function verifyAndSettle(
331
348
  payment: unknown,
332
- expected: { amount: string; resource: string; payTo: string; asset: string },
349
+ expected: { amount: string; resource: string; payTo: string; asset?: string },
333
350
  coinpay: { apiKey: string; baseUrl: string; fetch?: typeof fetch; metadata?: Record<string, unknown> | null },
334
351
  ): Promise<{ ok: true; payer: string | null; ref: string | null; txHash: string | null; network: string | null } | { ok: false; reason: string; replay: boolean }>;
335
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/x402-gateway",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Sell crawl access to AI training crawlers by the day over x402, settled by CoinPay. One middleware: 402 with an offer, a sales page with CLI instructions, signed passes, Profullstack Power Keys honoured as a pass, and a robots.txt that keeps search crawlers welcome.",
6
6
  "keywords": [
package/src/index.js CHANGED
@@ -12,12 +12,14 @@ import {
12
12
  decodePayment,
13
13
  expectedFor,
14
14
  METHODS,
15
+ nanoPayee,
15
16
  nonceOf,
16
17
  paymentRequiredHeader,
17
18
  paymentResponseHeader,
18
19
  settleAgain,
19
20
  validBeforeOf,
20
21
  verifyAndSettle,
22
+ xnoRaw,
21
23
  } from './x402.js';
22
24
 
23
25
  export {
@@ -62,13 +64,78 @@ export {
62
64
  encodeHeader,
63
65
  expectedFor,
64
66
  METHODS,
67
+ nanoPayee,
65
68
  paymentRequiredHeader,
66
69
  paymentResponseHeader,
67
70
  verifyAndSettle,
71
+ xnoRaw,
68
72
  } from './x402.js';
69
73
  export { memoryQuotaStore, normaliseQuota, quotaHeaders, spend } from './quota.js';
70
74
  export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack/keys';
71
75
 
76
+ /**
77
+ * Where people and their agents sign in, pay and run an account. Never charged.
78
+ *
79
+ * The gateway sells reading, and none of these is reading: a 402 on /login is
80
+ * not an offer anybody can take, it is a locked door. Since 0.8 a stale or
81
+ * headless browser string is charged on every path, and every path included
82
+ * these, so an agent whose OAuth token lapsed could not sign back in, a person
83
+ * on a two-year-old Chrome could not reach checkout, and a processor's webhook
84
+ * wearing an old user agent was answered with a price. Each is matched as a
85
+ * whole path segment: `/pay` covers `/pay/abc` and not `/payouts-explained`.
86
+ * Sites add their own with `appPaths` (they are appended) and an app whose
87
+ * whole `/api/` is credentialed adds `/api`.
88
+ */
89
+ export const APP_PATHS = Object.freeze([
90
+ '/login',
91
+ '/logout',
92
+ '/signin',
93
+ '/sign-in',
94
+ '/signout',
95
+ '/sign-out',
96
+ '/signup',
97
+ '/sign-up',
98
+ '/register',
99
+ '/auth',
100
+ '/oauth',
101
+ '/sso',
102
+ '/callback',
103
+ '/forgot-password',
104
+ '/reset-password',
105
+ '/verify',
106
+ '/verify-email',
107
+ '/dashboard',
108
+ '/account',
109
+ '/settings',
110
+ '/admin',
111
+ '/onboarding',
112
+ '/checkout',
113
+ '/pay',
114
+ '/billing',
115
+ '/webhook',
116
+ '/webhooks',
117
+ '/api/auth',
118
+ '/api/oauth',
119
+ '/api/health',
120
+ '/api/webhook',
121
+ '/api/webhooks',
122
+ '/api/checkout',
123
+ ]);
124
+
125
+ /**
126
+ * Whether `path` is one of `paths` or below it, matched by whole segments.
127
+ *
128
+ * @param {string} path
129
+ * @param {readonly string[]} [paths=APP_PATHS]
130
+ * @returns {boolean}
131
+ */
132
+ export function isAppPath(path, paths = APP_PATHS) {
133
+ return paths.some((p) => {
134
+ const base = p.replace(/\/+$/, '');
135
+ return path === base || path.startsWith(`${base}/`);
136
+ });
137
+ }
138
+
72
139
  /**
73
140
  * A gateway that sells crawl access to training crawlers, by the day, over x402.
74
141
  *
@@ -90,6 +157,7 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
90
157
  * @param {string} [options.siteName] for the page; defaults to the hostname
91
158
  * @param {{apiKey: string, baseUrl?: string}} [options.coinpay] a SCOPED CoinPay key (cp_live_…) with payments:create
92
159
  * @param {string} [options.payTo] EVM address that receives the USDC
160
+ * @param {{payTo: string, rate: number|(() => number)}} [options.nano] opt-in XNO on Nano: a nano_ (or xrb_) payee and the price of 1 XNO in `currency`; CoinPay must have NANO_RPC_URL set
93
161
  * @param {number} [options.priceCents=100]
94
162
  * @param {string} [options.currency='USD']
95
163
  * @param {number} [options.passMinutes=1440] a day: the term one payment buys
@@ -109,6 +177,7 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
109
177
  * @param {{paths: string[], cookie?: string, work?: number|boolean, css?: boolean, cssPath?: string, when?: (request: Request, url: URL) => boolean}|false} [options.challenge] requests to these paths (and, with `when`, only those it accepts, e.g. faceted URLs) must run a one-line script (cookie + reload) first; with `work` (bits, `true` = 16) the script must also solve a SHA-256 proof of work, so a swarm that rotates its address every page pays it on every page; with `css` the page links a per-address stylesheet (at `cssPath`, default `/__pf/c/`) whose fetch sets a second cookie, so a scraper that blocks stylesheets never passes; off by default
110
178
  * @param {{token: string, cidrs: string[]}[]|false} [options.welcome] search crawlers never metered when name and address agree; default WELCOME_CRAWLERS
111
179
  * @param {(request: Request) => boolean} [options.exempt] requests never charged, e.g. ones carrying a signed-in cookie
180
+ * @param {string[]|false} [options.appPaths] extra sign-in, account and payment paths never charged, added to APP_PATHS; `false` charges them like any page
112
181
  * @param {string} [options.secret] pass signing secret; defaults to the CoinPay key
113
182
  * @param {Record<string,string>|false} [options.powerKeys] kid -> public key of the Power Keys honoured as a pass; defaults to Profullstack's, `false` switches it off
114
183
  * @param {Iterable<string>} [options.revokedKeys] Power Key subjects no longer honoured
@@ -215,6 +284,23 @@ export function createGateway(options = {}) {
215
284
  return Math.min(n, o.maxDays);
216
285
  };
217
286
 
287
+ /**
288
+ * The XNO entry's payee and raw amount for `days` terms, or null. `days`
289
+ * times one term's raw, never the raw of `days` times the price, so a proof
290
+ * for N terms is an exact multiple of the per-term amount, as for USDC.
291
+ */
292
+ const nanoFor = (days) => {
293
+ if (!o.nano) return null;
294
+ let rate;
295
+ try {
296
+ rate = typeof o.nano.rate === 'function' ? o.nano.rate() : o.nano.rate;
297
+ } catch {
298
+ return null;
299
+ }
300
+ const raw = xnoRaw(o.priceCents, rate);
301
+ return raw ? { payTo: o.nano.payTo, amount: String(BigInt(raw) * BigInt(days)) } : null;
302
+ };
303
+
218
304
  /** The offer for `days` terms: the same entries, `days` times the price. */
219
305
  const offer = (days = 1) =>
220
306
  enabled
@@ -223,6 +309,7 @@ export function createGateway(options = {}) {
223
309
  priceCents: o.priceCents * days,
224
310
  resource: buyUrl,
225
311
  description: `${days * o.passMinutes} minutes of crawl access to ${o.siteUrl}${days > 1 ? ` (${days} × ${o.passMinutes})` : ''}`,
312
+ nano: nanoFor(days),
226
313
  })
227
314
  : { x402Version: 2, accepts: [] };
228
315
 
@@ -803,6 +890,8 @@ export function createGateway(options = {}) {
803
890
  return stylesheetResponse(request, { secret, cookie: o.challenge.cookie, ip: clientIp(request), cssPath: o.challenge.cssPath });
804
891
  }
805
892
  if (o.exempt && o.exempt(request)) return null;
893
+ // Signing in, paying and running an account are never sold. See APP_PATHS.
894
+ if (o.appPaths && isAppPath(path, o.appPaths)) return null;
806
895
 
807
896
  /*
808
897
  * A valid pass is checked before anything else that could refuse, because
@@ -957,7 +1046,8 @@ export const wantsHtml = (accept = '') => String(accept ?? '').toLowerCase().inc
957
1046
 
958
1047
  /** The value a proof authorizes, in the token's smallest unit, or null. */
959
1048
  export function paidValueOf(payment) {
960
- const raw = payment?.payload?.authorization?.value;
1049
+ // A Nano proof states its amount in raw; CoinPay then holds the block to the offer.
1050
+ const raw = payment?.payload?.authorization?.value ?? (payment?.network === 'nano' ? payment?.payload?.amount : undefined);
961
1051
  if (raw === undefined || raw === null || raw === '') return null;
962
1052
  try {
963
1053
  const value = BigInt(raw);
@@ -1007,6 +1097,7 @@ function normalise(options) {
1007
1097
  baseUrl: (options.coinpay?.baseUrl ?? 'https://coinpayportal.com').replace(/\/+$/, ''),
1008
1098
  },
1009
1099
  payTo: options.payTo ?? '',
1100
+ nano: nanoPayee(options.nano?.payTo) ? { payTo: nanoPayee(options.nano.payTo), rate: options.nano.rate } : null,
1010
1101
  priceCents: Number.isFinite(options.priceCents) ? options.priceCents : 100,
1011
1102
  currency: options.currency ?? 'USD',
1012
1103
  passMinutes: Number.isFinite(options.passMinutes) && options.passMinutes > 0 ? options.passMinutes : 1440,
@@ -1019,6 +1110,7 @@ function normalise(options) {
1019
1110
  chargeStaleBrowsers: options.chargeStaleBrowsers !== false,
1020
1111
  chargeImpostors: options.chargeImpostors !== false,
1021
1112
  exempt: options.exempt ?? null,
1113
+ appPaths: options.appPaths === false ? null : [...APP_PATHS, ...(options.appPaths ?? [])],
1022
1114
  training,
1023
1115
  scrapers,
1024
1116
  headless,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * x402 endpoint verification badge — a well-known metadata endpoint that
3
+ * lets external indexers (like nullclear.dev) verify that an x402 gateway
4
+ * is live, what methods it accepts, and what its settlement flow looks like.
5
+ *
6
+ * GET /.well-known/x402-verify returns a JSON object with:
7
+ * - version: the x402 protocol version (1 or 2)
8
+ * - methods: the payment methods the gateway accepts
9
+ * - settleFlow: how settlement works (verify-then-settle)
10
+ * - lastChecked: ISO timestamp of the last successful settlement
11
+ *
12
+ * This is read-only metadata — no secrets, no keys, no payment logic.
13
+ * Indexers can poll it to confirm the endpoint is live and schema-valid.
14
+ */
15
+
16
+ /**
17
+ * Build the verification badge response for a given x402 gateway configuration.
18
+ *
19
+ * @param {object} opts - Gateway configuration
20
+ * @param {number} opts.x402Version - 1 or 2
21
+ * @param {Array} opts.methods - The METHODS array from x402.js
22
+ * @param {string} [opts.payee] - The payee address (optional, for transparency)
23
+ * @returns {object} The badge metadata
24
+ */
25
+ export function buildVerificationBadge({ x402Version = 2, methods = [], payee } = {}) {
26
+ return {
27
+ spec: 'x402-verify-badge/v1',
28
+ version: x402Version,
29
+ methods: methods.map((m) => ({
30
+ key: m.key,
31
+ network: m.network,
32
+ asset: m.asset,
33
+ label: m.label,
34
+ })),
35
+ settleFlow: 'verify-then-settle',
36
+ settleProvider: 'CoinPay',
37
+ payee: payee || undefined,
38
+ generatedAt: new Date().toISOString(),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Hono handler for GET /.well-known/x402-verify.
44
+ *
45
+ * Returns the badge as JSON with appropriate cache headers. Indexers should
46
+ * cache for 5 minutes; the badge is cheap to compute but there's no reason
47
+ * to hit the gateway on every poll.
48
+ *
49
+ * @param {object} badge - The badge object from buildVerificationBadge
50
+ * @returns {Response} JSON response with cache headers
51
+ */
52
+ export function verificationBadgeHandler(badge) {
53
+ return new Response(JSON.stringify(badge, null, 2), {
54
+ status: 200,
55
+ headers: {
56
+ 'content-type': 'application/json; charset=utf-8',
57
+ 'cache-control': 'public, max-age=300',
58
+ 'access-control-allow-origin': '*',
59
+ },
60
+ });
61
+ }
package/src/x402.js CHANGED
@@ -34,7 +34,9 @@
34
34
  * exactly 1, 137 and 8453, and USDC is the only EIP-3009 dollar token on them
35
35
  * (USDT has no EIP-3009). Advertising anything else would quote a payment
36
36
  * that verifies and then never settles. Buyers who want another coin, or
37
- * PayPal, use the hosted checkout on the sales page instead.
37
+ * PayPal, use the hosted checkout on the sales page instead. The one
38
+ * exception is XNO on Nano, which CoinPay settles by a node lookup rather
39
+ * than a relayer, and which `buildOffer` adds only when given a Nano payee.
38
40
  */
39
41
  export const METHODS = [
40
42
  {
@@ -60,6 +62,37 @@ export const METHODS = [
60
62
  const DECIMALS = 6;
61
63
  const DOMAIN = { name: 'USD Coin', version: '2' };
62
64
 
65
+ /**
66
+ * XNO on Nano, opt-in: CoinPay verifies and settles network `nano` by asking a
67
+ * Nano node whether the proof's block is a confirmed send of at least the
68
+ * asking price to `payTo`. Nothing is signed or broadcast, so the buyer sends
69
+ * the block first and presents its hash. Amounts are in raw, 10^30 per XNO.
70
+ */
71
+ const NANO_ADDRESS = /^(nano|xrb)_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/;
72
+
73
+ /** A Nano payee in canonical `nano_` form, or null if it is not one. */
74
+ export function nanoPayee(address) {
75
+ const a = String(address ?? '').trim().toLowerCase();
76
+ if (!NANO_ADDRESS.test(a)) return null;
77
+ return a.startsWith('xrb_') ? `nano_${a.slice(4)}` : a;
78
+ }
79
+
80
+ /**
81
+ * A price in raw XNO, rounded UP, at `rate` units of the price's currency per
82
+ * XNO. BigInt throughout: 10^30 is far past what a float holds exactly, and
83
+ * String() of a number that large is exponent notation no verifier parses.
84
+ * The rate is read to 10^-9 and the price to 10^-6 of a cent. Null if either
85
+ * is not a positive number.
86
+ */
87
+ export function xnoRaw(priceCents, rate) {
88
+ const cents = BigInt(Math.round(Number(priceCents) * 1e6));
89
+ const per = BigInt(Number.isFinite(Number(rate)) ? Math.round(Number(rate) * 1e9) : 0);
90
+ if (cents <= 0n || per <= 0n) return null;
91
+ // raw = (cents / 100) / rate * 10^30 = (cents*1e6) * 10^31 / (rate*1e9)
92
+ const num = cents * 10n ** 31n;
93
+ return String((num + per - 1n) / per);
94
+ }
95
+
63
96
  /**
64
97
  * A v2 402 body.
65
98
  *
@@ -74,9 +107,32 @@ export function buildOffer({
74
107
  description = 'Payment required',
75
108
  maxTimeoutSeconds = 300,
76
109
  methods = METHODS,
110
+ nano = null,
77
111
  }) {
78
112
  if (!payTo) throw new Error('an offer needs a payTo address');
79
113
  const amount = String(Math.ceil((Number(priceCents) / 100) * 10 ** DECIMALS));
114
+ /*
115
+ * XNO goes last, and only with a real Nano payee and a raw amount: without
116
+ * both the offer is exactly the USDC one.
117
+ */
118
+ const xnoTo = nanoPayee(nano?.payTo);
119
+ const xno =
120
+ xnoTo && /^[1-9]\d*$/.test(String(nano.amount ?? ''))
121
+ ? [
122
+ {
123
+ scheme: 'exact',
124
+ network: 'nano',
125
+ amount: String(nano.amount),
126
+ asset: 'XNO',
127
+ payTo: xnoTo,
128
+ resource,
129
+ description,
130
+ mimeType: 'application/json',
131
+ maxTimeoutSeconds,
132
+ extra: { decimals: 30 },
133
+ },
134
+ ]
135
+ : [];
80
136
  return {
81
137
  x402Version: 2,
82
138
  accepts: methods.map((m) => ({
@@ -90,7 +146,7 @@ export function buildOffer({
90
146
  mimeType: 'application/json',
91
147
  maxTimeoutSeconds,
92
148
  extra: { ...DOMAIN },
93
- })),
149
+ })).concat(xno),
94
150
  };
95
151
  }
96
152
 
@@ -138,6 +194,22 @@ export function decodePayment(header) {
138
194
  if (!parsed.scheme && chosen.scheme) parsed.scheme = chosen.scheme;
139
195
  if (parsed.x402Version === undefined) parsed.x402Version = 2;
140
196
  }
197
+ /*
198
+ * A Nano proof is a block hash, not an EIP-3009 authorization, and CoinPay
199
+ * reads it on its v1 path: `x402Version` must not be 2, and `network`,
200
+ * `scheme`, `to`, `amount` and `blockHash` are read from the payload. Lifted
201
+ * there from wherever this payer put them.
202
+ */
203
+ const network = String(parsed.network ?? parsed.payload?.network ?? '').toLowerCase();
204
+ if (network === 'nano') {
205
+ const payload = { ...(parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : {}) };
206
+ payload.network = 'nano';
207
+ payload.scheme = payload.scheme || parsed.scheme || 'exact';
208
+ if (!payload.blockHash && parsed.blockHash) payload.blockHash = parsed.blockHash;
209
+ if (!payload.to && chosen?.payTo) payload.to = chosen.payTo;
210
+ if (payload.amount === undefined && chosen?.amount) payload.amount = String(chosen.amount);
211
+ return { ...parsed, x402Version: 1, scheme: payload.scheme, network: 'nano', payload };
212
+ }
141
213
  return parsed;
142
214
  }
143
215
 
@@ -190,14 +262,24 @@ export function expectedFor(payment, offer) {
190
262
  const network = String(payment?.network ?? '').toLowerCase();
191
263
  const entry = (offer?.accepts ?? []).find((a) => a.network.toLowerCase() === network);
192
264
  if (!entry) return null;
265
+ /*
266
+ * XNO is Nano's native coin, which CoinPay's v1 check accepts only with no
267
+ * asset named on either side, or the same one named on both. The block is
268
+ * what proves the payment either way.
269
+ */
270
+ if (network === 'nano') {
271
+ const named = payment?.payload?.asset;
272
+ return { amount: entry.amount, resource: entry.resource, payTo: entry.payTo, ...(named ? { asset: entry.asset } : {}) };
273
+ }
193
274
  return { amount: entry.amount, resource: entry.resource, payTo: entry.payTo, asset: entry.asset };
194
275
  }
195
276
 
196
- /** The single-use nonce, which is what CoinPay keys replay detection on. */
197
- export const nonceOf = (payment) => payment?.payload?.authorization?.nonce ?? null;
277
+ /** The single-use nonce, which is what CoinPay keys replay detection on: a Nano proof's is its block hash. */
278
+ export const nonceOf = (payment) => payment?.payload?.authorization?.nonce ?? payment?.payload?.blockHash ?? null;
198
279
 
199
- /** When the payer's signature stops being valid, as a unix timestamp in seconds. */
280
+ /** When the payer's signature stops being valid, as a unix timestamp in seconds. Null for Nano, which signs nothing with a deadline. */
200
281
  export const validBeforeOf = (payment) => {
282
+ if (String(payment?.network ?? '').toLowerCase() === 'nano') return null;
201
283
  const v = Number(payment?.payload?.authorization?.validBefore);
202
284
  return Number.isFinite(v) && v > 0 ? v : null;
203
285
  };
@@ -228,6 +310,14 @@ export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetc
228
310
  return { status: res.status, json };
229
311
  };
230
312
 
313
+ /*
314
+ * A block cannot name the resource it bought, so for Nano the gateway names
315
+ * it: CoinPay compares `payload.resource` with `expected.resource` before it
316
+ * asks the node about the block.
317
+ */
318
+ if (String(payment?.network ?? '').toLowerCase() === 'nano' && payment.payload && !payment.payload.resource) {
319
+ payment = { ...payment, payload: { ...payment.payload, resource: expected.resource } };
320
+ }
231
321
  const v = await call('/api/x402/verify', { payment, expected });
232
322
  if (!v.json?.valid) {
233
323
  const reason = String(v.json?.error ?? v.json?.reason ?? `verify failed (${v.status})`);