@profullstack/x402-gateway 0.10.2 → 0.11.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
@@ -102,7 +102,11 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] });
102
102
  | `secret` | the CoinPay key | pass signing secret |
103
103
  | `page` | built in | `(ctx) => html` |
104
104
  | `contact` | | mailto: or URL for bulk deals |
105
- | `onSale` | | `({ payer, ref, token, expiresAt, userAgent, priceCents, currency }) => …`, for accounting |
105
+ | `onSale` | | `({ payer, ref, token, expiresAt, userAgent, priceCents, days, totalCents, currency, property, resourcePath, ipHash, source, ... }) => …`, for accounting. `source` is `'x402'` or `'checkout'` (which adds `email`, `accountId`, `checkoutSessionId`); a checkout sale can be reported by both the return page and the webhook, so dedupe on `ref` |
106
+ | `property` | the siteUrl hostname | the site a sale is recorded for, sent to CoinPay with every settle. A function `(request) => string` reads it per request, e.g. from `Host` |
107
+ | `ipSalt` | the pass secret | key for the address hash recorded with a sale (the address itself is never sent) |
108
+ | `checkout` | on, with `coinpay.apiKey` | the human "Buy a day pass" button: `{ apiKey?, baseUrl?, account?: (request) => userId, expiresInMinutes? }`. `false` hides it. Shown only while CoinPay answers `/api/checkout/sessions` |
109
+ | `passCookie` | `crawl_pass` | cookie a pass is also read from (the checkout return page sets it) |
106
110
 
107
111
  | `denyCidrs` | `[]` | IPv4 ranges answered with a tiny `403` before anything else. For a VPS fleet that spoofs a browser: hosting ranges serve no readers. |
108
112
  | `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. |
@@ -181,10 +185,28 @@ The lists catch crawlers that name themselves. Two do not: a VPS fleet wearing a
181
185
 
182
186
  ## How the money moves
183
187
 
184
- The offer is x402 v2 in CoinPay's dialect: USDC under the `exact` scheme on Base, Polygon or Ethereum, EIP-3009 `transferWithAuthorization`. The buyer signs, the gateway sends the proof to CoinPay's `/api/x402/verify` and `/api/x402/settle`, and CoinPay's relayer broadcasts the transfer, paying the gas. The USDC goes straight to `payTo`.
188
+ The offer is x402 v2 in CoinPay's dialect (and in the spec's `PAYMENT-REQUIRED` header): USDC under the `exact` scheme on Base, Polygon or Ethereum, EIP-3009 `transferWithAuthorization`. The buyer signs, the gateway sends the proof to CoinPay's `/api/x402/verify` and `/api/x402/settle`, and CoinPay's relayer broadcasts the transfer, paying the gas. The USDC goes straight to `payTo`.
185
189
 
186
190
  A pass is `cp_<payload>.<hmac>`: its own expiry and the payment's nonce, signed with HMAC-SHA256. Verifying one is a hash, not a query. A proof is single-use; retrying with the same proof returns a pass bounded by the proof's own validity window, so a lost response is not a lost dollar and a replayed header is not a free day.
187
191
 
192
+ ## The x402 v2 headers
193
+
194
+ Every 402 that carries an offer also carries it in `PAYMENT-REQUIRED`: the spec's `PaymentRequired` object, base64 JSON, with the resource at the top level. A proof is read from `PAYMENT-SIGNATURE` first and `X-PAYMENT` second, in either CoinPay's shape (`scheme` and `network` at the top) or the spec's (`accepted: { scheme, network, ... }`). A sale answers with `PAYMENT-RESPONSE` (`{ success, transaction, network, payer }`) beside the JSON receipt. Nothing old was removed: the body offer, `X-PAYMENT` and the receipt are unchanged.
195
+
196
+ ## Who paid, and where
197
+
198
+ Every settle call sends CoinPay `metadata: { property, resource_path, payer, user_agent, ip_hash, source }`, which CoinPay keeps on the `x402_payments` row. `resource_path` is the page actually asked for: the gated URL itself, or `?from=` on the sales page (the buy button carries it). `ip_hash` is an HMAC of the address keyed by `ipSalt`, never the address. `onSale` gets the same fields.
199
+
200
+ ## People: Buy a day pass
201
+
202
+ The sales page shows a **Buy a day pass** form (email, days) whenever CoinPay serves hosted checkout sessions. It posts to `/crawl/checkout`, which creates a session (`POST {coinpay}/api/checkout/sessions` with `amount`, `currency: 'USD'`, `customer_email`, `account_id`, `property`, `metadata`, `success_url`, `cancel_url`) and redirects to CoinPay, where the buyer pays with any coin the business takes or PayPal. The session id rides back in a signed cookie to `/crawl/checkout/return`, which reads the session from CoinPay, and once it is paid mints the same `cp_...` pass the x402 path mints, valid from when it was paid, and shows it with how to send it (header, bearer, or the `crawl_pass` cookie it also sets). Until CoinPay serves the endpoint (a 404 on the probe, rechecked every few minutes) the form is simply not shown.
203
+
204
+ `account_id` is the buyer's email unless `checkout.account(request)` returns the signed-in user. CoinPay webhooks for checkout sessions can go to `/crawl/checkout/webhook`, or to a site's own route via `gateway.checkoutWebhook(request)` / `gateway.checkoutPaid(body)`: the body is only a hint, the session is re-read from CoinPay before anything is minted.
205
+
206
+ ## Which coins x402 offers
207
+
208
+ 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
+
188
210
  ## Who is on which list
189
211
 
190
212
  `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
@@ -14,6 +14,31 @@ export interface Sale {
14
14
  /** `priceCents * days`. */
15
15
  totalCents: number;
16
16
  currency: string;
17
+ /** The site the sale is for (option `property`, default the siteUrl hostname). */
18
+ property?: string;
19
+ /** The page bought: the gated URL's path, or `?from=` on the sales page. */
20
+ resourcePath?: string | null;
21
+ /** Keyed hash of the buyer's address (never the address). */
22
+ ipHash?: string | null;
23
+ /** 'x402' for a proof, 'checkout' for a CoinPay hosted checkout. */
24
+ source?: 'x402' | 'checkout';
25
+ network?: string | null;
26
+ txHash?: string | null;
27
+ /** Checkout only. */
28
+ email?: string | null;
29
+ accountId?: string | null;
30
+ checkoutSessionId?: string;
31
+ /** What was sent to CoinPay with the sale. */
32
+ metadata?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface CheckoutOptions {
36
+ /** Key for POST /api/checkout/sessions; defaults to `coinpay.apiKey`. */
37
+ apiKey?: string;
38
+ baseUrl?: string;
39
+ /** The signed-in site user, recorded as `account_id`; the buyer's email otherwise. */
40
+ account?: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
41
+ expiresInMinutes?: number;
17
42
  }
18
43
 
19
44
  export interface PageContext {
@@ -36,6 +61,8 @@ export interface PageContext {
36
61
  contact: string;
37
62
  /** Where a Power Key is bought and the header it goes in, or null when they are switched off. */
38
63
  powerKey?: { shop: string; header: string } | null;
64
+ /** The "Buy a day pass" form, or null when CoinPay's hosted checkout is not available. */
65
+ checkout?: { action: string; from: string; days: number } | null;
39
66
  }
40
67
 
41
68
  export interface GatewayOptions {
@@ -93,6 +120,14 @@ export interface GatewayOptions {
93
120
  contact?: string;
94
121
  /** Awaited before the buyer's receipt is sent, and its errors are swallowed: a sale is recorded, and a recording failure never costs the buyer the pass. */
95
122
  onSale?: (sale: Sale) => void | Promise<void>;
123
+ /** The site a sale is recorded for; defaults to the siteUrl hostname. A function reads it per request (e.g. from Host). */
124
+ property?: string | ((request: Request) => string | null | undefined);
125
+ /** Key for the recorded address hash; defaults to the pass secret. */
126
+ ipSalt?: string;
127
+ /** The human checkout (CoinPay hosted, any coin or PayPal). On by default; `false` hides it. */
128
+ checkout?: CheckoutOptions | false;
129
+ /** Cookie a pass is also read from. Default 'crawl_pass'. */
130
+ passCookie?: string;
96
131
  /**
97
132
  * A free allowance for ordinary callers, after which the gateway answers 402
98
133
  * with the offer instead of letting the request through. A bare number means
@@ -196,7 +231,9 @@ export interface Gateway {
196
231
  handle: Handle;
197
232
  sell: (request: Request, context?: SellContext) => Promise<Response>;
198
233
  enabled: boolean;
199
- options: Required<Omit<GatewayOptions, 'onSale' | 'fetch' | 'page' | 'isPaidAgent'>> & {
234
+ options: Required<Omit<GatewayOptions, 'onSale' | 'fetch' | 'page' | 'isPaidAgent' | 'property' | 'checkout'>> & {
235
+ property: GatewayOptions['property'] | null;
236
+ checkout: CheckoutOptions | null;
200
237
  onSale: GatewayOptions['onSale'] | null;
201
238
  fetch: typeof fetch;
202
239
  page: (ctx: PageContext) => string;
@@ -216,6 +253,19 @@ export interface Gateway {
216
253
  offer: (days?: number) => Offer;
217
254
  /** The full 402 body around that offer: the offer plus the pass terms. */
218
255
  receipt: (days?: number, extra?: Record<string, unknown>) => Offer & Record<string, unknown>;
256
+ /** The PAYMENT-REQUIRED header value (base64 JSON) for `days`, or null when payments are off. */
257
+ paymentRequired: (days?: number, error?: string) => string | null;
258
+ /** Who and where a request would be recorded as. */
259
+ saleContext: (request: Request, source: 'x402' | 'checkout') => Promise<{ property: string; resource_path: string; user_agent: string; ip_hash: string | null; source: string }>;
260
+ /** Whether the checkout button shows. */
261
+ checkoutAvailable: () => Promise<boolean>;
262
+ /** A webhook handler for CoinPay checkout events: re-reads the session from CoinPay and mints its pass. */
263
+ checkoutWebhook: (request: Request) => Promise<Response>;
264
+ /** Mint the pass for a paid session, from a webhook body or a session id. Null unless CoinPay says it is paid. */
265
+ checkoutPaid: (
266
+ bodyOrId: unknown,
267
+ request?: Request,
268
+ ) => Promise<{ token: string; expiresAt: string; days: number; email: string | null; accountId: string | null; ref: string } | null>;
219
269
  }
220
270
 
221
271
  export function createGateway(options: GatewayOptions): Gateway;
@@ -261,6 +311,13 @@ export const METHODS: Array<{ key: string; network: string; asset: string; label
261
311
  export const X402_METHODS: typeof METHODS;
262
312
  export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS }): Offer;
263
313
  export function decodePayment(header: string | null | undefined): Record<string, unknown> | null;
314
+ export function decodeHeader(header: string | null | undefined): Record<string, unknown> | null;
315
+ export function encodeHeader(value: unknown): string;
316
+ export function paymentRequiredHeader(offer: Offer, args?: { url?: string; description?: string; error?: string }): string;
317
+ export function paymentResponseHeader(args: { transaction?: string | null; network?: string | null; payer?: string | null }): string;
318
+ export function renderNotice(ctx: { siteName: string; siteUrl: string; buyUrl: string; title: string; body: string }): string;
319
+ export function renderPassPage(ctx: { siteName: string; siteUrl: string; buyUrl: string; token: string; expiresAt: string; days: number; header: string; cookie: string; email?: string | null }): string;
320
+ export function sessionIdFromWebhook(body: unknown): string | null;
264
321
  export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset: string } | null;
265
322
  /** The value a proof authorizes, in the token's smallest unit, or null. */
266
323
  export function paidValueOf(payment: unknown): bigint | null;
@@ -269,8 +326,8 @@ export function daysPaid(value: bigint | null, unit: string | number | bigint, m
269
326
  export function verifyAndSettle(
270
327
  payment: unknown,
271
328
  expected: { amount: string; resource: string; payTo: string; asset: string },
272
- coinpay: { apiKey: string; baseUrl: string; fetch?: typeof fetch },
273
- ): Promise<{ ok: true; payer: string | null; ref: string | null } | { ok: false; reason: string; replay: boolean }>;
329
+ coinpay: { apiKey: string; baseUrl: string; fetch?: typeof fetch; metadata?: Record<string, unknown> | null },
330
+ ): Promise<{ ok: true; payer: string | null; ref: string | null; txHash: string | null; network: string | null } | { ok: false; reason: string; replay: boolean }>;
274
331
 
275
332
  /** ./hono */
276
333
  export function x402Gateway(gatewayOrOptions: Gateway | GatewayOptions): (c: { req: { raw: Request } }, next: () => Promise<void>) => Promise<Response | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/x402-gateway",
3
- "version": "0.10.2",
3
+ "version": "0.11.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": [
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The human way in: a day pass bought through CoinPay's hosted checkout.
3
+ *
4
+ * An x402 proof needs a wallet that signs EIP-3009, which is a program's
5
+ * tool. A person who landed on the sales page wants a button: pay with any
6
+ * coin CoinPay takes, or PayPal, and get the same pass the x402 path mints.
7
+ *
8
+ * CoinPay hosts the payment (`POST /api/checkout/sessions` -> `{ id, url }`),
9
+ * and the gateway stays stateless: the session id rides in a signed cookie to
10
+ * the return page, the return page asks CoinPay whether that session is paid,
11
+ * and the pass is minted from what CoinPay answers. Nothing the buyer's
12
+ * browser says is believed about money.
13
+ *
14
+ * The endpoint is new, so its absence is expected: `available()` probes it
15
+ * and the sales page shows the button only when it answers.
16
+ */
17
+
18
+ const PAID = new Set(['paid', 'complete', 'completed', 'succeeded', 'success', 'confirmed', 'settled', 'forwarded']);
19
+ const DEAD = new Set(['expired', 'cancelled', 'canceled', 'failed', 'void', 'voided', 'refunded']);
20
+
21
+ /** How long a probe answer is believed, by outcome. */
22
+ const PROBE_TTL_UP = 10 * 60 * 1000;
23
+ const PROBE_TTL_DOWN = 5 * 60 * 1000;
24
+
25
+ /** Probe answers shared by every gateway in the process that talks to one CoinPay. */
26
+ const probes = new Map();
27
+
28
+ /** A CoinPay checkout session id as the gateway will accept one: short, URL-safe. */
29
+ export const isSessionId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]{6,128}$/.test(id);
30
+
31
+ /** Whether a string looks like an email address, enough to send a receipt to. */
32
+ export const isEmail = (s) => typeof s === 'string' && s.length <= 254 && /^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(s);
33
+
34
+ /**
35
+ * @param {object} args
36
+ * @param {string} args.apiKey
37
+ * @param {string} args.baseUrl
38
+ * @param {typeof fetch} [args.fetch]
39
+ */
40
+ export function checkoutClient({ apiKey, baseUrl, fetch: f = globalThis.fetch }) {
41
+ const root = `${baseUrl}/api/checkout/sessions`;
42
+ const headers = { authorization: `Bearer ${apiKey}`, accept: 'application/json' };
43
+
44
+ const readJson = async (res) => {
45
+ try {
46
+ return JSON.parse(await res.text());
47
+ } catch {
48
+ return {};
49
+ }
50
+ };
51
+
52
+ /** Mark the endpoint as missing, so the button hides until the next probe. */
53
+ const markDown = () => probes.set(root, { ok: false, at: Date.now() });
54
+
55
+ return {
56
+ /**
57
+ * Whether CoinPay serves checkout sessions yet. A 404 means the route is
58
+ * not deployed; any other answer below 500 (401, 405, 200) means it is.
59
+ * Errors and timeouts read as "not now" without being cached for long.
60
+ */
61
+ async available() {
62
+ const hit = probes.get(root);
63
+ const now = Date.now();
64
+ if (hit && now - hit.at < (hit.ok ? PROBE_TTL_UP : PROBE_TTL_DOWN)) return hit.ok;
65
+ let ok = false;
66
+ try {
67
+ const res = await f(root, { method: 'GET', headers, signal: AbortSignal.timeout(3000) });
68
+ ok = res.status !== 404 && res.status < 500;
69
+ if (res.status >= 500) {
70
+ // Up but unwell: believe it for a minute, not ten.
71
+ probes.set(root, { ok: false, at: now - PROBE_TTL_DOWN + 60_000 });
72
+ return false;
73
+ }
74
+ } catch {
75
+ probes.set(root, { ok: false, at: now - PROBE_TTL_DOWN + 60_000 });
76
+ return false;
77
+ }
78
+ probes.set(root, { ok, at: now });
79
+ return ok;
80
+ },
81
+
82
+ /** The last probe answer without asking again: for a renderer that cannot wait. */
83
+ known() {
84
+ return probes.get(root)?.ok ?? false;
85
+ },
86
+
87
+ /**
88
+ * Create a session. Resolves to `{ ok: true, id, url }`, or
89
+ * `{ ok: false, status, missing, reason }` where `missing` means the
90
+ * endpoint is not deployed.
91
+ */
92
+ async create(body) {
93
+ let res;
94
+ try {
95
+ res = await f(root, {
96
+ method: 'POST',
97
+ headers: { ...headers, 'content-type': 'application/json' },
98
+ body: JSON.stringify(body),
99
+ signal: AbortSignal.timeout(15000),
100
+ });
101
+ } catch (err) {
102
+ return { ok: false, status: 0, missing: false, reason: String(err?.message ?? err) };
103
+ }
104
+ const json = await readJson(res);
105
+ if (res.status === 404) {
106
+ markDown();
107
+ return { ok: false, status: 404, missing: true, reason: 'checkout sessions are not available' };
108
+ }
109
+ const s = json?.session ?? json?.data ?? json;
110
+ const id = s?.id ?? s?.session_id ?? s?.checkout_session_id;
111
+ const url = s?.url ?? s?.checkout_url ?? s?.hosted_url;
112
+ if (!res.ok || !isSessionId(String(id ?? '')) || !/^https:\/\//.test(String(url ?? ''))) {
113
+ return { ok: false, status: res.status, missing: false, reason: String(json?.error ?? `checkout failed (${res.status})`) };
114
+ }
115
+ return { ok: true, id: String(id), url: String(url) };
116
+ },
117
+
118
+ /** Read a session back from CoinPay. Null when it cannot be read. */
119
+ async get(id) {
120
+ if (!isSessionId(id)) return null;
121
+ try {
122
+ const res = await f(`${root}/${encodeURIComponent(id)}`, {
123
+ method: 'GET',
124
+ headers,
125
+ signal: AbortSignal.timeout(15000),
126
+ });
127
+ if (!res.ok) return null;
128
+ const json = await readJson(res);
129
+ const s = json?.session ?? json?.data ?? json;
130
+ return s && typeof s === 'object' ? s : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ },
135
+ };
136
+ }
137
+
138
+ const toSeconds = (v) => {
139
+ if (v === undefined || v === null || v === '') return null;
140
+ const n = typeof v === 'number' ? v : Date.parse(String(v));
141
+ if (!Number.isFinite(n)) return null;
142
+ // A number below 10^11 is already seconds; anything larger is milliseconds.
143
+ return Math.floor(n < 1e11 ? n : n / 1000);
144
+ };
145
+
146
+ /**
147
+ * What a session says, normalised: whether it is paid, still open, or dead,
148
+ * how much it was for, when it was paid, and who and where it was for.
149
+ *
150
+ * `amount` is read in dollars (the unit the session was created in) unless
151
+ * the session also names `amount_cents`.
152
+ */
153
+ export function sessionState(session) {
154
+ const status = String(session?.status ?? session?.payment_status ?? '').toLowerCase();
155
+ const paid = session?.paid === true || PAID.has(status);
156
+ const dead = !paid && DEAD.has(status);
157
+ const metadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
158
+ let cents = null;
159
+ if (Number.isFinite(Number(session?.amount_cents))) cents = Math.round(Number(session.amount_cents));
160
+ else if (session?.amount !== undefined && Number.isFinite(Number(session.amount))) cents = Math.round(Number(session.amount) * 100);
161
+ const currency = String(session?.currency ?? 'USD').toUpperCase();
162
+ const paidAt =
163
+ toSeconds(session?.paid_at) ??
164
+ toSeconds(session?.completed_at) ??
165
+ toSeconds(session?.confirmed_at) ??
166
+ toSeconds(session?.updated_at) ??
167
+ toSeconds(session?.created_at);
168
+ return {
169
+ id: session?.id ? String(session.id) : null,
170
+ status,
171
+ paid,
172
+ dead,
173
+ cents,
174
+ currency,
175
+ paidAt,
176
+ property: session?.property ?? metadata.property ?? null,
177
+ email: session?.customer_email ?? metadata.customer_email ?? null,
178
+ accountId: session?.account_id ?? metadata.account_id ?? null,
179
+ metadata,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The session id a CoinPay webhook is about. Webhook bodies are nested
185
+ * (`{ id: <event>, type, data: { ... } }`), so the top-level `id` is the
186
+ * EVENT and is never taken for the session.
187
+ */
188
+ export function sessionIdFromWebhook(body) {
189
+ const candidates = [
190
+ body?.checkout_session_id,
191
+ body?.data?.checkout_session_id,
192
+ body?.data?.session_id,
193
+ body?.data?.object?.checkout_session_id,
194
+ body?.data?.object?.id,
195
+ body?.data?.id,
196
+ ];
197
+ for (const c of candidates) if (isSessionId(String(c ?? ''))) return String(c);
198
+ return null;
199
+ }
package/src/index.js CHANGED
@@ -2,8 +2,9 @@ import { HEADER as POWER_KEY_HEADER, isPowerKey, keyFrom, PROFULLSTACK_KEYS, SHO
2
2
  import { HEADLESS_AGENTS, isTrainingAgent, RETRIEVAL_AGENTS, SCRAPER_AGENTS, TRAINING_AGENTS, VERIFIED_RETRIEVAL, WELCOME_CRAWLERS } from './agents.js';
3
3
  import { clientIp, compileCidrs, impostorCrawler, inCidrs, isSpoofedBrowser, isStaleBrowser, welcomeCrawler } from './edge.js';
4
4
  import { memoryQuotaStore, meters, normaliseQuota, quotaHeaders, spend } from './quota.js';
5
- import { challengePage, challengeToken, passedChallenge } from './challenge.js';
6
- import { renderPage } from './page.js';
5
+ import { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
6
+ import { checkoutClient, isEmail, isSessionId, sessionIdFromWebhook, sessionState } from './checkout.js';
7
+ import { renderNotice, renderPage, renderPassPage } from './page.js';
7
8
  import { mintPass, readPass } from './pass.js';
8
9
  import { robotsTxt } from './robots.js';
9
10
  import {
@@ -12,6 +13,8 @@ import {
12
13
  expectedFor,
13
14
  METHODS,
14
15
  nonceOf,
16
+ paymentRequiredHeader,
17
+ paymentResponseHeader,
15
18
  settleAgain,
16
19
  validBeforeOf,
17
20
  verifyAndSettle,
@@ -39,10 +42,21 @@ export {
39
42
  welcomeCrawler,
40
43
  } from './edge.js';
41
44
  export { challengePage, challengeToken, cookieValue, passedChallenge } from './challenge.js';
42
- export { renderPage } from './page.js';
45
+ export { renderNotice, renderPage, renderPassPage } from './page.js';
46
+ export { checkoutClient, sessionIdFromWebhook, sessionState } from './checkout.js';
43
47
  export { mintPass, readPass } from './pass.js';
44
48
  export { robotsTxt } from './robots.js';
45
- export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from './x402.js';
49
+ export {
50
+ buildOffer,
51
+ decodeHeader,
52
+ decodePayment,
53
+ encodeHeader,
54
+ expectedFor,
55
+ METHODS,
56
+ paymentRequiredHeader,
57
+ paymentResponseHeader,
58
+ verifyAndSettle,
59
+ } from './x402.js';
46
60
  export { memoryQuotaStore, normaliseQuota, quotaHeaders, spend } from './quota.js';
47
61
  export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack/keys';
48
62
 
@@ -91,7 +105,11 @@ export { isPowerKey, keyFrom, PROFULLSTACK_KEYS, verifyKey } from '@profullstack
91
105
  * @param {Iterable<string>} [options.revokedKeys] Power Key subjects no longer honoured
92
106
  * @param {(ctx: object) => string} [options.page] custom sales page renderer
93
107
  * @param {string} [options.contact] mailto: or URL for bulk deals
94
- * @param {(sale: object) => void|Promise<void>} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed
108
+ * @param {(sale: object) => void|Promise<void>} [options.onSale] accounting hook; awaited before the receipt goes out, and its errors are swallowed. Carries `property`, `resourcePath`, `payer`, `userAgent`, `ipHash` and `source` ('x402' or 'checkout'); a checkout sale may be reported more than once (return page and webhook), so dedupe on `ref`
109
+ * @param {string|((request: Request) => string)} [options.property] the site this sale is for, recorded with CoinPay; defaults to the siteUrl hostname
110
+ * @param {string} [options.ipSalt] key for the address hash recorded with a sale; defaults to the pass secret
111
+ * @param {{apiKey?: string, baseUrl?: string, account?: (request: Request) => string|null|Promise<string|null>, expiresInMinutes?: number}|false} [options.checkout] the human "Buy a day pass" button (CoinPay hosted checkout, any coin or PayPal); on by default with the CoinPay key, shown only once CoinPay serves checkout sessions
112
+ * @param {string} [options.passCookie='crawl_pass'] cookie a pass is also read from
95
113
  * @param {typeof fetch} [options.fetch] for tests
96
114
  */
97
115
  export function createGateway(options = {}) {
@@ -114,6 +132,68 @@ export function createGateway(options = {}) {
114
132
  */
115
133
  const howUrl = `${buyUrl}?format=html`;
116
134
 
135
+ /** The site a sale is for: config, or what the host app says per request. */
136
+ const propertyOf = (request) => {
137
+ if (typeof o.property === 'function') {
138
+ try {
139
+ const p = o.property(request);
140
+ if (p) return String(p);
141
+ } catch {}
142
+ } else if (o.property) {
143
+ return o.property;
144
+ }
145
+ return new URL(o.siteUrl).hostname;
146
+ };
147
+
148
+ /**
149
+ * The page a sale bought. On a gated URL that is the URL itself; on the
150
+ * sales page it is whatever `?from=` says (the button and the 402 carry it),
151
+ * since "/crawl" names the shop rather than the page anyone wanted.
152
+ */
153
+ const resourcePathOf = (request) => {
154
+ const url = new URL(request.url);
155
+ const from = url.searchParams.get('from');
156
+ if (url.pathname === o.path || url.pathname.startsWith(`${o.path}/`)) {
157
+ if (from && from.startsWith('/') && !from.startsWith('//') && from.length <= 1024) return from;
158
+ return url.pathname;
159
+ }
160
+ const params = new URLSearchParams(url.search);
161
+ for (const k of ['days', 'format', 'from']) params.delete(k);
162
+ const q = params.toString();
163
+ return `${url.pathname}${q ? `?${q}` : ''}`.slice(0, 1024);
164
+ };
165
+
166
+ /** A keyed hash of the caller's address: enough to tell repeat buyers apart, never the address. */
167
+ const ipHashOf = async (request) => {
168
+ const ip = clientIp(request);
169
+ const key = o.ipSalt || secret;
170
+ if (!ip || !key) return null;
171
+ const k = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(key)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
172
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', k, new TextEncoder().encode(`ip:${ip}`)));
173
+ return Array.from(sig.slice(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
174
+ };
175
+
176
+ /** Who paid and where, as CoinPay's ledger and onSale both get it. */
177
+ const saleContext = async (request, source) => ({
178
+ property: propertyOf(request),
179
+ resource_path: resourcePathOf(request),
180
+ user_agent: (request.headers.get('user-agent') ?? '').slice(0, 512),
181
+ ip_hash: await ipHashOf(request),
182
+ source,
183
+ });
184
+
185
+ /*
186
+ * The human checkout. On whenever there is a key to create sessions with
187
+ * and a secret to sign the session cookie; shown only once CoinPay answers.
188
+ */
189
+ const checkoutPath = `${o.path}/checkout`;
190
+ const checkoutKey = o.checkout ? o.checkout.apiKey || o.coinpay.apiKey : '';
191
+ const cpCheckout =
192
+ o.checkout && checkoutKey && secret
193
+ ? checkoutClient({ apiKey: checkoutKey, baseUrl: o.checkout.baseUrl || o.coinpay.baseUrl, fetch: o.fetch })
194
+ : null;
195
+ const checkoutCookie = 'x402_checkout';
196
+
117
197
  /**
118
198
  * How many terms a request is asking to buy: `?days=N`, clamped to
119
199
  * [1, maxDays]. Anything unparseable is one day, which is what the offer
@@ -161,8 +241,24 @@ export function createGateway(options = {}) {
161
241
 
162
242
  const noStore = {
163
243
  'cache-control': 'no-store',
164
- vary: 'Accept, User-Agent, X-Payment',
244
+ vary: 'Accept, User-Agent, X-Payment, Payment-Signature',
245
+ 'access-control-expose-headers': 'PAYMENT-REQUIRED, PAYMENT-RESPONSE',
165
246
  };
247
+
248
+ /**
249
+ * The spec's PAYMENT-REQUIRED header for a 402: the same offer as the body,
250
+ * base64 JSON. Absent when payments are off, since an empty offer is no
251
+ * offer.
252
+ */
253
+ const required = (days = 1, error) =>
254
+ enabled
255
+ ? {
256
+ 'payment-required': paymentRequiredHeader(offer(days), {
257
+ url: days > 1 ? `${buyUrl}?days=${days}` : buyUrl,
258
+ error,
259
+ }),
260
+ }
261
+ : {};
166
262
  const json = (body, status, headers = {}) =>
167
263
  new Response(JSON.stringify(body, null, 2), {
168
264
  status,
@@ -200,8 +296,18 @@ export function createGateway(options = {}) {
200
296
  retrieval: o.retrieval,
201
297
  contact: o.contact,
202
298
  powerKey: o.powerKeys ? { shop: SHOP_URL, header: POWER_KEY_HEADER } : null,
299
+ checkout: null,
203
300
  });
204
301
 
302
+ /** The page context with the checkout button, when CoinPay serves sessions. */
303
+ const pageCtxFor = async (request, days, usage, quota) => {
304
+ const ctx = pageCtx(days, usage, quota);
305
+ if (cpCheckout && (await cpCheckout.available())) {
306
+ ctx.checkout = { action: checkoutPath, from: resourcePathOf(request), days };
307
+ }
308
+ return ctx;
309
+ };
310
+
205
311
  /**
206
312
  * The pass a request presents: a day pass from the named header or a bearer
207
313
  * token, or a Power Key wherever @profullstack/keys reads one.
@@ -211,7 +317,10 @@ export function createGateway(options = {}) {
211
317
  if (direct) return direct.trim();
212
318
  const m = /^Bearer\s+(cp_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get('authorization') ?? '');
213
319
  if (m) return m[1];
214
- return o.powerKeys ? keyFrom(request) : null;
320
+ const pk = o.powerKeys ? keyFrom(request) : null;
321
+ if (pk) return pk;
322
+ const cookie = cookieValue(request, o.passCookie);
323
+ return cookie && cookie.startsWith('cp_') ? cookie : null;
215
324
  };
216
325
 
217
326
  /**
@@ -274,7 +383,8 @@ export function createGateway(options = {}) {
274
383
  */
275
384
  async function sell(request, context = {}) {
276
385
  const ua = request.headers.get('user-agent') ?? '';
277
- const proofHeader = request.headers.get('x-payment');
386
+ // The spec's header first, then the one CoinPay's payer has always sent.
387
+ const proofHeader = request.headers.get('payment-signature') || request.headers.get('x-payment');
278
388
  const asked = daysFrom(request);
279
389
  // Present when the free allowance is what stopped this request, rather than
280
390
  // the crawler lists. It changes what the 402 says, not what it costs.
@@ -291,10 +401,11 @@ export function createGateway(options = {}) {
291
401
 
292
402
  if (proofHeader) {
293
403
  if (!enabled) return json(receipt(asked, { error: 'Payments are not switched on here.' }), 402);
404
+ const refuse = (days, error) => json(receipt(days, { error }), 402, required(days, error));
294
405
  const payment = decodePayment(proofHeader);
295
- if (!payment) return json(receipt(asked, { error: 'X-PAYMENT is not base64 JSON.' }), 402);
406
+ if (!payment) return refuse(asked, 'The payment proof (PAYMENT-SIGNATURE or X-PAYMENT) is not base64 JSON.');
296
407
  const unit = expectedFor(payment, offer(1));
297
- if (!unit) return json(receipt(asked, { error: 'Proof does not match an offered network.' }), 402);
408
+ if (!unit) return refuse(asked, 'Proof does not match an offered network.');
298
409
 
299
410
  /*
300
411
  * The money decides the term, not the URL. A proof is an authorization
@@ -307,11 +418,9 @@ export function createGateway(options = {}) {
307
418
  */
308
419
  const days = daysPaid(paidValueOf(payment), unit.amount, o.maxDays);
309
420
  if (!days) {
310
- return json(
311
- receipt(asked, {
312
- error: `Pay a whole number of days: ${unit.amount} per day in the token's smallest unit, up to ${o.maxDays} days. Add ?days=<n> to ${buyUrl} for the offer.`,
313
- }),
314
- 402,
421
+ return refuse(
422
+ asked,
423
+ `Pay a whole number of days: ${unit.amount} per day in the token's smallest unit, up to ${o.maxDays} days. Add ?days=<n> to ${buyUrl} for the offer.`,
315
424
  );
316
425
  }
317
426
  const expected = expectedFor(payment, offer(days));
@@ -319,7 +428,8 @@ export function createGateway(options = {}) {
319
428
 
320
429
  const now = Math.floor(Date.now() / 1000);
321
430
  const coinpay = { apiKey: o.coinpay.apiKey, baseUrl: o.coinpay.baseUrl, fetch: o.fetch };
322
- const result = await verifyAndSettle(payment, expected, coinpay);
431
+ const sale = await saleContext(request, 'x402');
432
+ const result = await verifyAndSettle(payment, expected, { ...coinpay, metadata: sale });
323
433
 
324
434
  let expiresAt = null;
325
435
  let replayed = false;
@@ -342,7 +452,7 @@ export function createGateway(options = {}) {
342
452
  }
343
453
  }
344
454
  if (!expiresAt || expiresAt <= now) {
345
- return json(receipt(days, { error: result.reason ?? 'Payment could not be settled.' }), 402);
455
+ return refuse(days, result.reason ?? 'Payment could not be settled.');
346
456
  }
347
457
 
348
458
  const ref = nonceOf(payment) ?? result.ref ?? null;
@@ -360,6 +470,13 @@ export function createGateway(options = {}) {
360
470
  days,
361
471
  totalCents: o.priceCents * days,
362
472
  currency: o.currency,
473
+ property: sale.property,
474
+ resourcePath: sale.resource_path,
475
+ ipHash: sale.ip_hash,
476
+ source: 'x402',
477
+ network: result.network ?? payment.network ?? null,
478
+ txHash: result.txHash ?? null,
479
+ metadata: { ...sale, ...(result.payer ? { payer: result.payer } : {}) },
363
480
  });
364
481
  } catch {
365
482
  // Accounting must never cost a buyer the pass it paid for.
@@ -377,12 +494,20 @@ export function createGateway(options = {}) {
377
494
  use: `curl -H "${o.header}: ${pass.token}" ${o.siteUrl}/`,
378
495
  },
379
496
  200,
380
- { [o.header]: pass.token, [`${o.header}-expires`]: expires },
497
+ {
498
+ [o.header]: pass.token,
499
+ [`${o.header}-expires`]: expires,
500
+ 'payment-response': paymentResponseHeader({
501
+ transaction: result.txHash ?? ref,
502
+ network: result.network ?? payment.network,
503
+ payer: result.payer,
504
+ }),
505
+ },
381
506
  );
382
507
  }
383
508
 
384
509
  if (wantsHtml(request.headers.get('accept')) || new URL(request.url).searchParams.get('format') === 'html') {
385
- return html(o.page(pageCtx(asked, usage, quota)), 402, rateHeaders);
510
+ return html(o.page(await pageCtxFor(request, asked, usage, quota)), 402, { ...rateHeaders, ...required(asked) });
386
511
  }
387
512
 
388
513
  if (usage && quota) {
@@ -404,11 +529,237 @@ export function createGateway(options = {}) {
404
529
  ...(o.benefits ? { unlocks: o.benefits } : {}),
405
530
  }),
406
531
  402,
407
- rateHeaders,
532
+ { ...rateHeaders, ...required(asked) },
408
533
  );
409
534
  }
410
535
 
411
- return json(receipt(asked, { error: `Payment required for training crawlers. Read ${howUrl} for how.` }), 402);
536
+ return json(receipt(asked, { error: `Payment required for training crawlers. Read ${howUrl} for how.` }), 402, required(asked));
537
+ }
538
+
539
+ /* ------------------------------------------------------------ checkout -- */
540
+
541
+ /** Sign and read the cookie that carries a session id to the return page. */
542
+ const signId = async (id) => {
543
+ const k = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
544
+ const sig = new Uint8Array(await crypto.subtle.sign('HMAC', k, new TextEncoder().encode(`checkout:${id}`)));
545
+ return Array.from(sig.slice(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
546
+ };
547
+ const sessionFromCookie = async (request) => {
548
+ const raw = cookieValue(request, checkoutCookie);
549
+ if (!raw) return null;
550
+ const dot = raw.lastIndexOf('.');
551
+ if (dot < 0) return null;
552
+ const id = decodeURIComponent(raw.slice(0, dot));
553
+ if (!isSessionId(id)) return null;
554
+ return (await signId(id)) === raw.slice(dot + 1) ? id : null;
555
+ };
556
+
557
+ const notice = (status, title, body, headers = {}) =>
558
+ html(o.renderNotice({ siteName: o.siteName, siteUrl: o.siteUrl, buyUrl: howUrl, title, body }), status, headers);
559
+
560
+ /** Read a form or JSON body, whichever the buyer sent. */
561
+ const readForm = async (request) => {
562
+ const type = (request.headers.get('content-type') ?? '').toLowerCase();
563
+ try {
564
+ if (type.includes('application/json')) {
565
+ const j = await request.json();
566
+ return j && typeof j === 'object' ? j : {};
567
+ }
568
+ const text = await request.text();
569
+ return Object.fromEntries(new URLSearchParams(text.slice(0, 8192)));
570
+ } catch {
571
+ return {};
572
+ }
573
+ };
574
+
575
+ /** POST /crawl/checkout: create a CoinPay session for a pass and send the buyer to it. */
576
+ async function startCheckout(request) {
577
+ if (!cpCheckout || !enabled) return notice(404, 'Checkout is not available here', 'This site does not sell passes through checkout yet.');
578
+ if (request.method !== 'POST') return notice(405, 'Use the button', `Open <a href="${howUrl}">the pass page</a> and press Buy.`, { allow: 'POST' });
579
+ const form = await readForm(request);
580
+ const email = String(form.email ?? '').trim().toLowerCase();
581
+ if (!isEmail(email)) return notice(400, 'An email address is needed', `CoinPay sends the receipt there. <a href="${howUrl}">Go back</a> and enter one.`);
582
+ const n = Number.parseInt(String(form.days ?? '1'), 10);
583
+ const days = Number.isFinite(n) && n >= 1 ? Math.min(n, o.maxDays) : 1;
584
+ const fromRaw = String(form.from ?? '');
585
+ const from = fromRaw.startsWith('/') && !fromRaw.startsWith('//') ? fromRaw.slice(0, 1024) : o.path;
586
+
587
+ let account = null;
588
+ if (o.checkout.account) {
589
+ try {
590
+ account = (await o.checkout.account(request)) || null;
591
+ } catch {}
592
+ }
593
+ const property = propertyOf(request);
594
+ const ctx = await saleContext(request, 'checkout');
595
+ const cents = o.priceCents * days;
596
+ const minutes = o.checkout.expiresInMinutes ?? 60;
597
+ const created = await cpCheckout.create({
598
+ amount: Number((cents / 100).toFixed(2)),
599
+ currency: 'USD',
600
+ description: `${days === 1 ? 'Day pass' : `${days}-day pass`} for ${o.siteName}`,
601
+ customer_email: email,
602
+ account_id: account ? String(account) : email,
603
+ property,
604
+ metadata: {
605
+ kind: 'crawl_pass',
606
+ source: 'x402-gateway',
607
+ property,
608
+ resource_path: from,
609
+ days,
610
+ price_cents: o.priceCents,
611
+ user_agent: ctx.user_agent,
612
+ ip_hash: ctx.ip_hash,
613
+ },
614
+ success_url: `${o.siteUrl}${checkoutPath}/return?session_id={CHECKOUT_SESSION_ID}`,
615
+ cancel_url: howUrl,
616
+ expires_in_minutes: minutes,
617
+ });
618
+ if (!created.ok) {
619
+ return created.missing
620
+ ? notice(503, 'Checkout is not open yet', `Card-free checkout is still being switched on. Meanwhile a pass can be bought over x402: see <a href="${howUrl}">the pass page</a>.`)
621
+ : notice(502, 'Checkout could not start', `CoinPay did not create the payment (${esc(created.reason)}). Try again in a minute, or <a href="${howUrl}">go back</a>.`);
622
+ }
623
+ const cookie = `${checkoutCookie}=${encodeURIComponent(created.id)}.${await signId(created.id)}; Path=${checkoutPath}; Max-Age=${(minutes + 60) * 60}; HttpOnly; Secure; SameSite=Lax`;
624
+ if ((request.headers.get('accept') ?? '').includes('application/json')) {
625
+ return json({ id: created.id, url: created.url }, 200, { 'set-cookie': cookie });
626
+ }
627
+ return new Response(null, { status: 303, headers: { location: created.url, 'set-cookie': cookie, ...noStore } });
628
+ }
629
+
630
+ /**
631
+ * Mint the pass a paid session bought, from what CoinPay says about it.
632
+ * Resolves to `{ ok: true, token, expiresAt, days, state }`, or
633
+ * `{ ok: false, pending|dead, state }`.
634
+ *
635
+ * The pass runs from when the session was paid, so reloading the return
636
+ * page re-mints the same pass rather than a longer one.
637
+ */
638
+ async function passForSession(id) {
639
+ if (!cpCheckout || !isSessionId(id)) return { ok: false, state: null };
640
+ const session = await cpCheckout.get(id);
641
+ if (!session) return { ok: false, state: null };
642
+ const state = sessionState(session);
643
+ if (state.id && state.id !== id) return { ok: false, state };
644
+ if (!state.paid) return { ok: false, pending: !state.dead, dead: state.dead, state };
645
+ // A session for another property of the same business opens nothing here.
646
+ const property = o.property && typeof o.property === 'string' ? o.property : new URL(o.siteUrl).hostname;
647
+ if (state.property && typeof o.property !== 'function' && String(state.property).toLowerCase() !== property.toLowerCase()) {
648
+ return { ok: false, foreign: true, state };
649
+ }
650
+ if (state.metadata?.kind && state.metadata.kind !== 'crawl_pass') return { ok: false, foreign: true, state };
651
+ if (state.currency !== 'USD' || !Number.isFinite(state.cents) || state.cents < o.priceCents) return { ok: false, state };
652
+ const days = Math.min(o.maxDays, Math.floor(state.cents / o.priceCents));
653
+ const now = Math.floor(Date.now() / 1000);
654
+ const from = state.paidAt && state.paidAt <= now ? state.paidAt : now;
655
+ const expiresAt = from + days * o.passMinutes * 60;
656
+ if (expiresAt <= now) return { ok: false, expired: true, state };
657
+ const pass = await mintPass({ secret, ref: `checkout:${id}`, expiresAt: expiresAt, now: Math.min(from, now) });
658
+ return { ok: true, token: pass.token, expiresAt: pass.expiresAt, days, state };
659
+ }
660
+
661
+ /** Tell onSale about a checkout sale. Swallows the hook's errors, like the x402 path. */
662
+ const reportCheckout = async (id, minted, request) => {
663
+ if (!o.onSale) return;
664
+ const st = minted.state;
665
+ try {
666
+ await o.onSale({
667
+ payer: st.email ?? null,
668
+ ref: `checkout:${id}`,
669
+ token: minted.token,
670
+ expiresAt: new Date(minted.expiresAt * 1000).toISOString(),
671
+ userAgent: st.metadata?.user_agent ?? request?.headers.get('user-agent') ?? '',
672
+ priceCents: o.priceCents,
673
+ days: minted.days,
674
+ totalCents: st.cents,
675
+ currency: 'USD',
676
+ property: st.property ?? propertyOf(request ?? new Request(o.siteUrl)),
677
+ resourcePath: st.metadata?.resource_path ?? null,
678
+ ipHash: st.metadata?.ip_hash ?? null,
679
+ source: 'checkout',
680
+ email: st.email ?? null,
681
+ accountId: st.accountId ?? null,
682
+ checkoutSessionId: id,
683
+ metadata: st.metadata,
684
+ });
685
+ } catch {}
686
+ };
687
+
688
+ /** GET /crawl/checkout/return: the buyer is back from CoinPay; show the pass. */
689
+ async function checkoutReturn(request) {
690
+ if (!cpCheckout) return notice(404, 'Checkout is not available here', 'This site does not sell passes through checkout yet.');
691
+ const fromCookie = await sessionFromCookie(request);
692
+ const q = new URL(request.url).searchParams;
693
+ const fromQuery = ['session_id', 'checkout_session_id', 'session'].map((k) => q.get(k)).find((v) => isSessionId(v ?? ''));
694
+ const id = fromCookie ?? fromQuery ?? null;
695
+ if (!id) return notice(400, 'No checkout to show', `This page opens after paying at CoinPay. <a href="${howUrl}">Buy a pass</a>.`);
696
+ const minted = await passForSession(id);
697
+ if (!minted.ok) {
698
+ if (minted.pending) {
699
+ return notice(
700
+ 202,
701
+ 'Waiting for the payment to confirm',
702
+ 'CoinPay has not marked this payment paid yet. Crypto payments need a block or two; this page checks again every 15 seconds.',
703
+ { refresh: '15' },
704
+ );
705
+ }
706
+ if (minted.dead) return notice(410, 'This checkout closed unpaid', `Nothing was charged. <a href="${howUrl}">Start again</a>.`);
707
+ if (minted.expired) return notice(410, 'This pass has already run out', `<a href="${howUrl}">Buy another</a>.`);
708
+ return notice(502, 'Could not read this checkout', 'CoinPay did not answer for this payment. Reload in a minute; a paid pass is not lost.');
709
+ }
710
+ // Report the sale once per browser: the cookie is cleared on the way out.
711
+ if (fromCookie) await reportCheckout(id, minted, request);
712
+ const expires = new Date(minted.expiresAt * 1000).toISOString();
713
+ const maxAge = Math.max(0, minted.expiresAt - Math.floor(Date.now() / 1000));
714
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8', ...noStore, [o.header]: minted.token, [`${o.header}-expires`]: expires });
715
+ headers.append('set-cookie', `${o.passCookie}=${minted.token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`);
716
+ headers.append('set-cookie', `${checkoutCookie}=; Path=${checkoutPath}; Max-Age=0; HttpOnly; Secure; SameSite=Lax`);
717
+ const body = o.renderPassPage({
718
+ siteName: o.siteName,
719
+ siteUrl: o.siteUrl,
720
+ buyUrl: howUrl,
721
+ token: minted.token,
722
+ expiresAt: expires,
723
+ days: minted.days,
724
+ header: o.header,
725
+ cookie: o.passCookie,
726
+ email: minted.state.email,
727
+ });
728
+ return new Response(body, { status: 200, headers });
729
+ }
730
+
731
+ /**
732
+ * A CoinPay webhook about a checkout session. The body is only a hint: the
733
+ * session is read back from CoinPay with this site's key, so a forged call
734
+ * mints nothing. Resolves to the minted pass (for a site that emails it) or
735
+ * null. Mount it on a site's existing webhook route, or let the gateway
736
+ * answer POST /crawl/checkout/webhook itself.
737
+ */
738
+ async function checkoutPaid(bodyOrId, request) {
739
+ const id = typeof bodyOrId === 'string' ? bodyOrId : sessionIdFromWebhook(bodyOrId);
740
+ if (!id) return null;
741
+ const minted = await passForSession(id);
742
+ if (!minted.ok) return null;
743
+ await reportCheckout(id, minted, request);
744
+ return { token: minted.token, expiresAt: new Date(minted.expiresAt * 1000).toISOString(), days: minted.days, email: minted.state.email, accountId: minted.state.accountId, ref: `checkout:${id}` };
745
+ }
746
+
747
+ async function checkoutWebhook(request) {
748
+ if (request.method !== 'POST') return json({ ok: false, error: 'POST only' }, 405, { allow: 'POST' });
749
+ let body = {};
750
+ try {
751
+ body = await request.json();
752
+ } catch {}
753
+ const minted = await checkoutPaid(body, request);
754
+ return json({ ok: true, minted: Boolean(minted) }, 200);
755
+ }
756
+
757
+ /** The routes under the sales page. Null for anything else. */
758
+ async function checkoutRoute(request, path) {
759
+ if (path === checkoutPath) return startCheckout(request);
760
+ if (path === `${checkoutPath}/return`) return checkoutReturn(request);
761
+ if (path === `${checkoutPath}/webhook`) return checkoutWebhook(request);
762
+ return null;
412
763
  }
413
764
 
414
765
  /**
@@ -434,6 +785,10 @@ export function createGateway(options = {}) {
434
785
 
435
786
  const path = new URL(request.url).pathname;
436
787
  if (path === o.path) return sell(request);
788
+ if (path.startsWith(`${checkoutPath}`) && (path === checkoutPath || path.startsWith(`${checkoutPath}/`))) {
789
+ const answer = await checkoutRoute(request, path);
790
+ if (answer) return answer;
791
+ }
437
792
  if (o.exempt && o.exempt(request)) return null;
438
793
 
439
794
  /*
@@ -482,10 +837,11 @@ export function createGateway(options = {}) {
482
837
  error: `${path} is read in a browser (it sets a cookie with one line of script) or with a pass. Read ${howUrl} for how.`,
483
838
  }),
484
839
  402,
840
+ required(1),
485
841
  );
486
842
  }
487
843
  const token = await challengeToken(secret, ip);
488
- return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402);
844
+ return html(challengePage({ token, cookie: o.challenge.cookie, buyUrl }), 402, required(1));
489
845
  }
490
846
  }
491
847
 
@@ -558,10 +914,31 @@ export function createGateway(options = {}) {
558
914
  ...extra,
559
915
  }),
560
916
  /** The sales page as HTML, for a site that mounts it on a route of its own. */
561
- page: () => o.page(pageCtx()),
917
+ page: () => {
918
+ const ctx = pageCtx();
919
+ if (cpCheckout && cpCheckout.known()) ctx.checkout = { action: checkoutPath, from: o.path, days: 1 };
920
+ return o.page(ctx);
921
+ },
922
+ /** The PAYMENT-REQUIRED header value for `days`, for a site that answers its own 402. */
923
+ paymentRequired: (days = 1, error) => required(days, error)['payment-required'] ?? null,
924
+ /** The sale context (property, page, UA, address hash) a request would be recorded with. */
925
+ saleContext,
926
+ /** Whether the checkout button shows: CoinPay serves checkout sessions and a key is set. */
927
+ checkoutAvailable: async () => Boolean(cpCheckout && (await cpCheckout.available())),
928
+ /** A CoinPay webhook handler (Fetch Request -> Response) that mints a paid session's pass. */
929
+ checkoutWebhook,
930
+ /** Mint the pass for a paid checkout session, from a webhook body or a session id. */
931
+ checkoutPaid,
562
932
  };
563
933
  }
564
934
 
935
+ const esc = (s) =>
936
+ String(s ?? '')
937
+ .replace(/&/g, '&amp;')
938
+ .replace(/</g, '&lt;')
939
+ .replace(/>/g, '&gt;')
940
+ .replace(/"/g, '&quot;');
941
+
565
942
  /** Whether the caller would rather read a page than a JSON offer. */
566
943
  export const wantsHtml = (accept = '') => String(accept ?? '').toLowerCase().includes('text/html');
567
944
 
@@ -649,6 +1026,12 @@ function normalise(options) {
649
1026
  page: options.page ?? renderPage,
650
1027
  contact: options.contact ?? '',
651
1028
  onSale: options.onSale ?? null,
1029
+ property: typeof options.property === 'function' || typeof options.property === 'string' ? options.property : null,
1030
+ ipSalt: options.ipSalt ?? '',
1031
+ checkout: options.checkout === false ? null : { ...(options.checkout ?? {}) },
1032
+ passCookie: String(options.passCookie ?? 'crawl_pass'),
1033
+ renderNotice: options.renderNotice ?? renderNotice,
1034
+ renderPassPage: options.renderPassPage ?? renderPassPage,
652
1035
  freeQuota: normaliseQuota(options.freeQuota),
653
1036
  benefits: Array.isArray(options.benefits) ? options.benefits : null,
654
1037
  powerKeys: options.powerKeys === false ? null : (options.powerKeys ?? PROFULLSTACK_KEYS),
package/src/page.js CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Plain HTML with no script, because the reader is a program that renders
5
5
  * nothing, or a person who was sent the link by one. Every number on it comes
6
- * from the gateway's options; nothing is typed in twice.
6
+ * from the gateway's options; nothing is typed in twice. The one form on it,
7
+ * the checkout button, is a plain POST and works with scripts off.
7
8
  */
8
9
 
9
10
  const esc = (s) =>
@@ -24,6 +25,12 @@ pre{background:var(--code);border:1px solid var(--line);border-radius:6px;paddin
24
25
  code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em}
25
26
  ol,ul{padding-left:1.3rem}li{margin:.3rem 0}
26
27
  .price{font-size:2.2rem;font-weight:700;color:var(--acc);margin:.25rem 0}
28
+ .buy{border:1px solid var(--line);border-radius:8px;padding:1rem 1.1rem;margin:1.25rem 0}
29
+ .buy h2{margin-top:0}.buy form{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin:.6rem 0 .2rem}
30
+ .buy input{font:inherit;padding:.5rem .6rem;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);min-width:0}
31
+ .buy input[type=email]{flex:1 1 14rem}.buy input[type=number]{width:5rem}
32
+ .buy button{font:inherit;font-weight:600;padding:.55rem 1rem;border:0;border-radius:6px;background:var(--acc);color:#fff;cursor:pointer}
33
+ .token{word-break:break-all}
27
34
  table{border-collapse:collapse;margin:.5rem 0;font-size:.95rem}td,th{border-bottom:1px solid var(--line);padding:.35rem .6rem;text-align:left}
28
35
  footer{margin-top:3rem;color:var(--mut);font-size:.85rem}
29
36
  `;
@@ -61,6 +68,7 @@ export function renderPage(ctx) {
61
68
  quota = null,
62
69
  benefits = null,
63
70
  powerKey = null,
71
+ checkout = null,
64
72
  } = ctx;
65
73
 
66
74
  /*
@@ -112,6 +120,26 @@ export function renderPage(ctx) {
112
120
  : `${minutes} minutes`;
113
121
  const networks = (offer?.accepts ?? []).map((a) => a.network).join(', ');
114
122
 
123
+ /*
124
+ * The button for people. Shown only when CoinPay serves hosted checkout, so
125
+ * a site never offers a payment it cannot take.
126
+ */
127
+ const buyBox = checkout
128
+ ? `<section class="buy">
129
+ <h2>Buy a day pass</h2>
130
+ <p>Pay ${esc(price)} a day with any coin CoinPay takes or with PayPal. The pass appears on the next page and works with any HTTP client.</p>
131
+ <form method="post" action="${esc(checkout.action)}">
132
+ <input type="hidden" name="from" value="${esc(checkout.from ?? '')}">
133
+ <label class="mut" for="pass-email">Email for the receipt</label>
134
+ <input id="pass-email" type="email" name="email" required autocomplete="email" placeholder="you@example.com">
135
+ <label class="mut" for="pass-days">Days</label>
136
+ <input id="pass-days" type="number" name="days" min="1" max="${esc(String(maxDays))}" value="${esc(String(checkout.days ?? days ?? 1))}">
137
+ <button type="submit">Buy a day pass</button>
138
+ </form>
139
+ <p class="mut">Checkout is hosted by CoinPay. Up to ${esc(String(maxDays))} days at once.</p>
140
+ </section>`
141
+ : '';
142
+
115
143
  return `<!doctype html>
116
144
  <html lang="en">
117
145
  <head>
@@ -137,6 +165,7 @@ ${
137
165
  ? ''
138
166
  : '<p><strong>Payments are not switched on here yet.</strong> The offer below is empty until the operator configures a payout address, so for now this crawler is simply refused.</p>'
139
167
  }
168
+ ${enabled ? buyBox : ''}
140
169
 
141
170
  ${unlocks}
142
171
  ${powerKeyNote}
@@ -144,9 +173,9 @@ ${arithmetic}
144
173
 
145
174
  <h2>How it works</h2>
146
175
  <ol>
147
- <li>Any page you fetch answers <code>402 Payment Required</code>. This page, fetched with <code>Accept: application/json</code>, returns the x402 offer: USDC, <code>exact</code> scheme, on ${esc(networks || 'Base, Polygon or Ethereum')}.</li>
148
- <li>Sign the payment and retry with the proof in an <code>X-PAYMENT</code> header. The response is a JSON receipt carrying a pass.</li>
149
- <li>Send the pass in <code>${esc(header)}</code> on every request until it expires — ${esc(window)} per day paid, so a proof for three times the price buys three. When it expires, buy another. The sale is the pass, not the page: fetch the page again with the pass.</li>
176
+ <li>Any page you fetch answers <code>402 Payment Required</code> with the x402 offer in a <code>PAYMENT-REQUIRED</code> header (base64 JSON). This page, fetched with <code>Accept: application/json</code>, also returns it as the body: USDC, <code>exact</code> scheme, on ${esc(networks || 'Base, Polygon or Ethereum')}.</li>
177
+ <li>Sign the payment and retry with the proof in a <code>PAYMENT-SIGNATURE</code> (or <code>X-PAYMENT</code>) header. The response is a JSON receipt carrying a pass, with the settlement in <code>PAYMENT-RESPONSE</code>.</li>
178
+ <li>Send the pass in <code>${esc(header)}</code> (or <code>Authorization: Bearer</code>) on every request until it expires: ${esc(window)} per day paid, so a proof for three times the price buys three. When it expires, buy another. The sale is the pass, not the page: fetch the page again with the pass.</li>
150
179
  </ol>
151
180
 
152
181
  <h2>Pay with the CoinPay CLI</h2>
@@ -187,3 +216,69 @@ curl -sS -H "X-PAYMENT: &lt;base64 proof&gt;" ${esc(buyUrl)}
187
216
  </html>
188
217
  `;
189
218
  }
219
+
220
+ const shell = (siteName, title, inner) => `<!doctype html>
221
+ <html lang="en">
222
+ <head>
223
+ <meta charset="utf-8">
224
+ <meta name="viewport" content="width=device-width,initial-scale=1">
225
+ <meta name="robots" content="noindex">
226
+ <title>${esc(title)} · ${esc(siteName)}</title>
227
+ <style>${CSS}</style>
228
+ </head>
229
+ <body>
230
+ <main>
231
+ ${inner}
232
+ <footer>Served by @profullstack/x402-gateway.</footer>
233
+ </main>
234
+ </body>
235
+ </html>
236
+ `;
237
+
238
+ /**
239
+ * A short status page for the checkout routes: waiting, closed, refused.
240
+ * `body` is HTML the gateway wrote; nothing in it comes from the request.
241
+ *
242
+ * @param {{siteName: string, siteUrl: string, buyUrl: string, title: string, body: string}} ctx
243
+ */
244
+ export function renderNotice({ siteName, title, body }) {
245
+ return shell(siteName, title, `<h1>${esc(title)}</h1>\n<p>${body}</p>`);
246
+ }
247
+
248
+ /**
249
+ * The page a buyer lands on after paying at CoinPay: the pass, and the three
250
+ * ways to send it.
251
+ *
252
+ * @param {object} ctx
253
+ * @param {string} ctx.siteName
254
+ * @param {string} ctx.siteUrl
255
+ * @param {string} ctx.buyUrl
256
+ * @param {string} ctx.token
257
+ * @param {string} ctx.expiresAt ISO time
258
+ * @param {number} ctx.days
259
+ * @param {string} ctx.header request header the pass goes in
260
+ * @param {string} ctx.cookie cookie the pass is also read from
261
+ * @param {string|null} [ctx.email]
262
+ */
263
+ export function renderPassPage({ siteName, siteUrl, token, expiresAt, days, header, cookie, email }) {
264
+ const term = days === 1 ? 'one day' : `${days} days`;
265
+ return shell(
266
+ siteName,
267
+ 'Your pass',
268
+ `<h1>Paid. Here is your pass.</h1>
269
+ <p class="mut">Good for ${esc(term)} on <a href="${esc(siteUrl)}">${esc(siteName)}</a>, until <strong>${esc(expiresAt)}</strong>.${email ? ` The receipt went to ${esc(email)}.` : ''} Copy it now: this page is the only place it is shown, and the pass is all a crawler needs.</p>
270
+ <pre class="token"><code>${esc(token)}</code></pre>
271
+
272
+ <h2>How to use it</h2>
273
+ <p>Send it on every request, in whichever of these your client supports:</p>
274
+ <pre><code># a header
275
+ curl -H "${esc(header)}: ${esc(token)}" ${esc(siteUrl)}/
276
+
277
+ # a bearer token
278
+ curl -H "Authorization: Bearer ${esc(token)}" ${esc(siteUrl)}/
279
+
280
+ # a cookie (this browser already has it)
281
+ curl --cookie "${esc(cookie)}=${esc(token)}" ${esc(siteUrl)}/</code></pre>
282
+ <p class="mut">When it runs out, buy another at the same page. Every Profullstack site sells its own pass; a Power Key opens them all.</p>`,
283
+ );
284
+ }
package/src/x402.js CHANGED
@@ -1,11 +1,17 @@
1
1
  /**
2
- * x402 v2, in CoinPayPortal's dialect.
2
+ * x402 v2, in CoinPayPortal's dialect, plus the spec's transport headers.
3
3
  *
4
4
  * Checked against @profullstack/coinpay/x402-v2 rather than remembered: the 402
5
5
  * body is JSON `{ x402Version: 2, accepts: [...] }`, the proof arrives as base64
6
- * JSON in an `X-PAYMENT` request header, and CoinPay's /api/x402/verify then
7
- * /api/x402/settle do the cryptography and the on-chain transfer (EIP-3009,
8
- * with CoinPay's relayer paying the gas, so the buyer needs no ETH).
6
+ * JSON, and CoinPay's /api/x402/verify then /api/x402/settle do the
7
+ * cryptography and the on-chain transfer (EIP-3009, with CoinPay's relayer
8
+ * paying the gas, so the buyer needs no ETH).
9
+ *
10
+ * The x402 v2 transport spec moves all three messages into headers:
11
+ * `PAYMENT-REQUIRED` (the offer, base64 JSON) on the 402, `PAYMENT-SIGNATURE`
12
+ * (the proof) on the retry, and `PAYMENT-RESPONSE` (the settlement) on the
13
+ * 200. The gateway speaks both: the body and `X-PAYMENT` stay for the clients
14
+ * that already pay this way, and the headers are there for spec-strict ones.
9
15
  *
10
16
  * Reimplemented here in a few dozen lines because the SDK also carries the
11
17
  * CLI's interactive-prompt dependency, which a web server has no business
@@ -22,6 +28,13 @@
22
28
  *
23
29
  * Base first. Merchant order is the payer's preference order, and Base is where
24
30
  * the relayer's gas is cheapest.
31
+ *
32
+ * Why not more: CoinPay's v2 settle broadcasts `transferWithAuthorization`
33
+ * only on chains its relayer has an RPC and a funded wallet for, which are
34
+ * exactly 1, 137 and 8453, and USDC is the only EIP-3009 dollar token on them
35
+ * (USDT has no EIP-3009). Advertising anything else would quote a payment
36
+ * that verifies and then never settles. Buyers who want another coin, or
37
+ * PayPal, use the hosted checkout on the sales page instead.
25
38
  */
26
39
  export const METHODS = [
27
40
  {
@@ -88,8 +101,16 @@ function fromBase64(s) {
88
101
  return new TextDecoder().decode(bytes);
89
102
  }
90
103
 
91
- /** The proof out of an X-PAYMENT header. Null if it is not base64 JSON. */
92
- export function decodePayment(header) {
104
+ /** utf8 JSON -> base64, without Buffer, for the PAYMENT-* headers. */
105
+ export function encodeHeader(value) {
106
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
107
+ let bin = '';
108
+ for (const b of bytes) bin += String.fromCharCode(b);
109
+ return btoa(bin);
110
+ }
111
+
112
+ /** A PAYMENT-* header back into an object. Null if it is not base64 JSON. */
113
+ export function decodeHeader(header) {
93
114
  if (!header) return null;
94
115
  try {
95
116
  const parsed = JSON.parse(fromBase64(header));
@@ -99,6 +120,65 @@ export function decodePayment(header) {
99
120
  }
100
121
  }
101
122
 
123
+ /**
124
+ * The proof out of a PAYMENT-SIGNATURE or X-PAYMENT header. Null if it is not
125
+ * base64 JSON.
126
+ *
127
+ * Two shapes arrive. CoinPay's payer puts `scheme` and `network` at the top
128
+ * level; a spec v2 client nests them in `accepted`, the requirement it chose.
129
+ * Both carry the same `payload`, so the spec shape is lifted into CoinPay's,
130
+ * which is what CoinPay's verify and settle read.
131
+ */
132
+ export function decodePayment(header) {
133
+ const parsed = decodeHeader(header);
134
+ if (!parsed) return null;
135
+ const chosen = parsed.accepted && typeof parsed.accepted === 'object' ? parsed.accepted : null;
136
+ if (chosen) {
137
+ if (!parsed.network && chosen.network) parsed.network = chosen.network;
138
+ if (!parsed.scheme && chosen.scheme) parsed.scheme = chosen.scheme;
139
+ if (parsed.x402Version === undefined) parsed.x402Version = 2;
140
+ }
141
+ return parsed;
142
+ }
143
+
144
+ /**
145
+ * The PAYMENT-REQUIRED header for an offer: the spec's PaymentRequired object,
146
+ * base64 JSON. The resource moves to the top level, as the spec has it, and
147
+ * each requirement keeps only the spec's own fields, so a strict schema has
148
+ * nothing to choke on.
149
+ */
150
+ export function paymentRequiredHeader(offer, { url, description, error } = {}) {
151
+ const first = offer?.accepts?.[0];
152
+ return encodeHeader({
153
+ x402Version: 2,
154
+ ...(error ? { error: String(error) } : {}),
155
+ resource: {
156
+ url: url ?? first?.resource ?? '',
157
+ description: description ?? first?.description ?? '',
158
+ mimeType: first?.mimeType ?? 'application/json',
159
+ },
160
+ accepts: (offer?.accepts ?? []).map((a) => ({
161
+ scheme: a.scheme,
162
+ network: a.network,
163
+ amount: a.amount,
164
+ asset: a.asset,
165
+ payTo: a.payTo,
166
+ maxTimeoutSeconds: a.maxTimeoutSeconds,
167
+ extra: a.extra,
168
+ })),
169
+ });
170
+ }
171
+
172
+ /** The PAYMENT-RESPONSE header for a settled payment: the spec's SettlementResponse. */
173
+ export function paymentResponseHeader({ transaction, network, payer }) {
174
+ return encodeHeader({
175
+ success: true,
176
+ transaction: transaction ?? '',
177
+ network: network ?? '',
178
+ ...(payer ? { payer } : {}),
179
+ });
180
+ }
181
+
102
182
  /**
103
183
  * What CoinPay must hold the proof to.
104
184
  *
@@ -132,7 +212,7 @@ export const validBeforeOf = (payment) => {
132
212
  * gateway answers that with a pass rather than a second charge, bounded by the
133
213
  * proof's own validity window so the same header cannot buy hour after hour.
134
214
  */
135
- export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetch: f = globalThis.fetch }) {
215
+ export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetch: f = globalThis.fetch, metadata = null }) {
136
216
  const call = async (path, body) => {
137
217
  const res = await f(`${baseUrl}${path}`, {
138
218
  method: 'POST',
@@ -153,12 +233,20 @@ export async function verifyAndSettle(payment, expected, { apiKey, baseUrl, fetc
153
233
  const reason = String(v.json?.error ?? v.json?.reason ?? `verify failed (${v.status})`);
154
234
  return { ok: false, reason, replay: /already used|replay/i.test(reason) };
155
235
  }
156
- const s = await call('/api/x402/settle', { payment });
236
+ const payer = v.json.payment?.from ?? null;
237
+ /*
238
+ * Who paid and where, for CoinPay's ledger: the property, the page, the
239
+ * payer, the user agent and a hash of the address. CoinPay keeps it on the
240
+ * x402_payments row (and ignores it where it does not), so a sale can be
241
+ * traced to the site and page it bought rather than only to the business.
242
+ */
243
+ const settleBody = metadata ? { payment, metadata: { ...metadata, ...(payer ? { payer } : {}) } } : { payment };
244
+ const s = await call('/api/x402/settle', settleBody);
157
245
  if (!s.json?.settled) {
158
246
  const reason = String(s.json?.error ?? `settle failed (${s.status})`);
159
247
  return { ok: false, reason, replay: /already settled|already being settled/i.test(reason) };
160
248
  }
161
- return { ok: true, payer: v.json.payment?.from ?? null, ref: s.json.txHash ?? nonceOf(payment) };
249
+ return { ok: true, payer, ref: s.json.txHash ?? nonceOf(payment), txHash: s.json.txHash ?? null, network: s.json.network ?? payment?.network ?? null };
162
250
  }
163
251
 
164
252
  /** Whether the proof has already been paid, when a settle is asked about twice. */