@profullstack/x402-gateway 0.13.0 → 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 +4 -0
- package/index.d.ts +21 -4
- package/package.json +1 -1
- package/src/challenge.js +8 -3
- package/src/index.js +93 -1
- package/src/verify-badge.js +61 -0
- package/src/x402.js +95 -5
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
|
|
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
|
|
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.
|
|
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/challenge.js
CHANGED
|
@@ -117,8 +117,13 @@ export async function stylesheetResponse(request, { secret, cookie, ip, cssPath,
|
|
|
117
117
|
return new Response('/* ok */\n', { status: 200, headers });
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Wait for the challenge stylesheet; if it cannot load, say so rather than
|
|
122
|
+
* reload. The link's own onload/onerror record the outcome in `__pfc`, because
|
|
123
|
+
* the load can finish before this script runs and Chrome hands a failed link
|
|
124
|
+
* an empty `sheet` anyway, so neither a late listener nor `l.sheet` can tell.
|
|
125
|
+
*/
|
|
126
|
+
const WAIT_CSS = `while(!window.__pfc)await new Promise(r=>setTimeout(r,50));if(window.__pfc!==1){document.body.insertAdjacentHTML('beforeend','<p>This check needs the page stylesheet. Reload, or buy a pass.</p>');return}
|
|
122
127
|
`;
|
|
123
128
|
|
|
124
129
|
/** The script that finds the nonce, 256 digests at a time, then sets the cookie and reloads. */
|
|
@@ -134,7 +139,7 @@ export function challengePage({ token, cookie, buyUrl, work = 0, cssPath = null
|
|
|
134
139
|
const script = work || css
|
|
135
140
|
? solver(token, cookie, work, css)
|
|
136
141
|
: `document.cookie=${JSON.stringify(`${cookie}=${token}; Max-Age=172800; Path=/; SameSite=Lax; Secure`)};location.reload();`;
|
|
137
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title>${css ? `<link id="pfc" rel="stylesheet" href="${cssPath}${token}">` : ''}</head><body>
|
|
142
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>One moment</title>${css ? `<link id="pfc" rel="stylesheet" href="${cssPath}${token}" onload="window.__pfc=1" onerror="window.__pfc=2">` : ''}</head><body>
|
|
138
143
|
${work || css ? '<p>Checking your browser. This takes about a second, once a day.</p>\n' : ''}<script>${script}</script>
|
|
139
144
|
<noscript><p>This page needs JavaScript. Automated access is sold by the day: <a href="${buyUrl}">${buyUrl}</a></p></noscript>
|
|
140
145
|
</body></html>`;
|
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
|
-
|
|
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})`);
|