From b8e055a57ec13631cca3cc40ef88fc653c5bf679 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 18:31:33 +0000 Subject: [PATCH] 0.3.0: sell as many days as a crawler wants in one payment The gateway sold exactly one term per proof and minted every pass for `passMinutes`. A crawler that wanted a month paid every morning, and one that wanted to pay for the month up front had no way to say so. Two ways in, one rule. `?days=N` on the sales page, or on any 402'd URL, quotes N terms at N times the price, so a standard client that pays exactly what the offer asks gets N days. And the days a proof buys are read off the value it authorizes -- a whole number of day-prices, at most `maxDays` (default 30) -- so paying three times the price buys three days however it was asked for, and a value that is not a whole number of days buys nothing before CoinPay is asked. The pass expires `days * passMinutes` after the sale; a replayed proof is bounded by its own validity plus the days it bought, as before. The money decides, not the URL: `?days=7` and a proof for two days is two days, and CoinPay is asked to verify exactly the value that was signed, against the offer for that many days. The receipt carries `days` and `minutes`; the 402 body's `pass` object carries `days`, `total`, `maxDays` and a `buyDays` template; `onSale` gets `days` and `totalCents` beside the per-day `priceCents`. The page explains it and shows the CLI line for a week. New `maxDays` option; `daysPaid` and `paidValueOf` exported. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NSGS7Sy3QmEgTNZDg4eq7g --- README.md | 5 +- index.d.ts | 19 +++- package.json | 2 +- src/index.js | 131 +++++++++++++++++++++++----- src/page.js | 19 +++- test/days.test.js | 215 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 364 insertions(+), 27 deletions(-) create mode 100644 test/days.test.js diff --git a/README.md b/README.md index 1dd2485..0c533fe 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Sell crawl access to AI training crawlers, by the day, over [x402](https://x402. People read your site free. So do search engines and the retrieval crawlers behind AI answers, because they send readers back. A crawler that copies pages into a training corpus sends nobody back, so it pays: every page answers `402 Payment Required` with an x402 offer, paying the offer returns a signed pass, and the pass opens the site for a day. +A crawler that wants longer buys more days in one payment. `?days=7` on the sales page quotes seven days at the daily price, and the days a proof buys are read off the value it authorizes, so paying seven times the price — however it was asked for — returns a pass that expires seven days out. `maxDays` caps how many one proof can buy. + One middleware. No database. Runs in Node, Bun and at the edge. ``` @@ -82,7 +84,8 @@ export const GET = robotsRoute(gateway, { disallow: ['/login', '/api/'] }); | `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. | | `payTo` | | EVM address that receives the USDC, on Base, Polygon and Ethereum alike | | `priceCents` | `100` | | -| `passMinutes` | `1440` | a day | +| `passMinutes` | `1440` | a day: the term one price buys | +| `maxDays` | `30` | the most terms one proof may buy at once | | `header` | `x-crawl-pass` | where the pass goes; `Authorization: Bearer` works too | | `path` | `/crawl` | the sales page | | `openPaths` | `[]` | extra paths a refused crawler may read (`robots.txt`, the sales page, `security.txt` and `.well-known/` always are) | diff --git a/index.d.ts b/index.d.ts index e8d2bdf..72e7765 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,7 +7,12 @@ export interface Sale { token: string; expiresAt: string; userAgent: string; + /** Per term (`passMinutes`). */ priceCents: number; + /** Terms this proof bought. */ + days: number; + /** `priceCents * days`. */ + totalCents: number; currency: string; } @@ -15,8 +20,14 @@ export interface PageContext { siteName: string; siteUrl: string; buyUrl: string; + /** Per day, e.g. "1.00 USD". */ price: string; minutes: number; + /** Days this page's offer quotes (`?days=`), 1 by default. */ + days: number; + /** `price` times `days`. */ + total: string; + maxDays: number; header: string; enabled: boolean; offer: Offer; @@ -37,8 +48,10 @@ export interface GatewayOptions { /** Default 100 ($1). */ priceCents?: number; currency?: string; - /** What a payment buys. Default 1440 (a day). */ + /** What one price buys. Default 1440 (a day). */ passMinutes?: number; + /** The most terms one proof may buy at once (`?days=` and paid multiples are clamped to it). Default 30. */ + maxDays?: number; /** Request header the pass is presented in. Default 'x-crawl-pass'. */ header?: string; /** The sales page. Default '/crawl'. */ @@ -133,6 +146,10 @@ export const X402_METHODS: typeof METHODS; export function buildOffer(args: { payTo: string; priceCents: number; resource: string; description?: string; maxTimeoutSeconds?: number; methods?: typeof METHODS }): Offer; export function decodePayment(header: string | null | undefined): Record | null; export function expectedFor(payment: unknown, offer: Offer): { amount: string; resource: string; payTo: string; asset: string } | null; +/** The value a proof authorizes, in the token's smallest unit, or null. */ +export function paidValueOf(payment: unknown): bigint | null; +/** How many terms `value` buys at `unit` per term: a whole number in [1, maxDays], or 0. */ +export function daysPaid(value: bigint | null, unit: string | number | bigint, maxDays: number): number; export function verifyAndSettle( payment: unknown, expected: { amount: string; resource: string; payTo: string; asset: string }, diff --git a/package.json b/package.json index ec257b9..989663f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/x402-gateway", - "version": "0.2.2", + "version": "0.3.0", "type": "module", "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, and a robots.txt that keeps search crawlers welcome.", "keywords": [ diff --git a/src/index.js b/src/index.js index c1efcf5..b41e801 100644 --- a/src/index.js +++ b/src/index.js @@ -29,7 +29,9 @@ export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from * or as an HTML sales page if it asked for HTML -- on every path but the few it * needs to read to comply. Paying the offer, at the sales page or on any 402'd * URL, returns a signed pass good for `passMinutes`, presented in `header` on - * every request after that. + * every request after that. A crawler that wants longer buys more days at + * once: `?days=N` on the sales page quotes N terms, and a proof for N times + * the price — however it was asked for — buys a pass that lasts N terms. * * Framework-agnostic: `handle(request)` takes a Fetch `Request` and resolves to * a `Response` to send, or null to let the request through. The adapters in @@ -42,7 +44,8 @@ export { buildOffer, decodePayment, expectedFor, METHODS, verifyAndSettle } from * @param {string} [options.payTo] EVM address that receives the USDC * @param {number} [options.priceCents=100] * @param {string} [options.currency='USD'] - * @param {number} [options.passMinutes=1440] a day + * @param {number} [options.passMinutes=1440] a day: the term one payment buys + * @param {number} [options.maxDays=30] the most terms one proof may buy at once * @param {string} [options.header='x-crawl-pass'] * @param {string} [options.path='/crawl'] the sales page * @param {string[]} [options.openPaths] extra paths a refused crawler may read @@ -65,22 +68,45 @@ export function createGateway(options = {}) { const denied = compileCidrs(o.denyCidrs); const isOpen = (path) => openPaths.some((p) => (p.endsWith('/') ? path.startsWith(p) : path === p)); - const price = `${(o.priceCents / 100).toFixed(2)} ${o.currency}`; + const money = (cents) => `${(cents / 100).toFixed(2)} ${o.currency}`; + const price = money(o.priceCents); const buyUrl = `${o.siteUrl}${o.path}`; - const offer = () => + /** + * How many terms a request is asking to buy: `?days=N`, clamped to + * [1, maxDays]. Anything unparseable is one day, which is what the offer + * always meant before there was a way to ask for more. + */ + const daysFrom = (request) => { + const raw = new URL(request.url).searchParams.get('days'); + const n = Number.parseInt(raw ?? '', 10); + if (!Number.isFinite(n) || n < 1) return 1; + return Math.min(n, o.maxDays); + }; + + /** The offer for `days` terms: the same entries, `days` times the price. */ + const offer = (days = 1) => enabled ? buildOffer({ payTo: o.payTo, - priceCents: o.priceCents, + priceCents: o.priceCents * days, resource: buyUrl, - description: `${o.passMinutes} minutes of crawl access to ${o.siteUrl}`, + description: `${days * o.passMinutes} minutes of crawl access to ${o.siteUrl}${days > 1 ? ` (${days} × ${o.passMinutes})` : ''}`, }) : { x402Version: 2, accepts: [] }; - const receipt = (extra = {}) => ({ - ...offer(), - pass: { price, minutes: o.passMinutes, header: o.header, buy: buyUrl }, + const receipt = (days = 1, extra = {}) => ({ + ...offer(days), + pass: { + price, + minutes: o.passMinutes, + days, + total: money(o.priceCents * days), + maxDays: o.maxDays, + header: o.header, + buy: days > 1 ? `${buyUrl}?days=${days}` : buyUrl, + buyDays: `${buyUrl}?days=`, + }, ...extra, }); @@ -96,12 +122,15 @@ export function createGateway(options = {}) { const html = (body, status) => new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8', ...noStore } }); - const pageCtx = () => ({ + const pageCtx = (days = 1) => ({ + days, + total: money(o.priceCents * days), siteName: o.siteName, siteUrl: o.siteUrl, buyUrl, price, minutes: o.passMinutes, + maxDays: o.maxDays, header: o.header, enabled, offer: offer(), @@ -129,14 +158,35 @@ export function createGateway(options = {}) { async function sell(request) { const ua = request.headers.get('user-agent') ?? ''; const proofHeader = request.headers.get('x-payment'); + const asked = daysFrom(request); if (proofHeader) { - if (!enabled) return json(receipt({ error: 'Payments are not switched on here.' }), 402); + if (!enabled) return json(receipt(asked, { error: 'Payments are not switched on here.' }), 402); const payment = decodePayment(proofHeader); - if (!payment) return json(receipt({ error: 'X-PAYMENT is not base64 JSON.' }), 402); - const current = offer(); - const expected = expectedFor(payment, current); - if (!expected) return json(receipt({ error: 'Proof does not match an offered network.' }), 402); + if (!payment) return json(receipt(asked, { error: 'X-PAYMENT is not base64 JSON.' }), 402); + const unit = expectedFor(payment, offer(1)); + if (!unit) return json(receipt(asked, { error: 'Proof does not match an offered network.' }), 402); + + /* + * The money decides the term, not the URL. A proof is an authorization + * for an exact value, and the value the buyer signed is what CoinPay + * will move -- so the days it buys are read off the proof: a whole + * number of day-prices, at most maxDays. `?days=` shaped the offer the + * buyer read; if they then signed for a different multiple, they get + * what they paid for, and if they signed for something that is not a + * multiple they get nothing, before anyone is charged. + */ + const days = daysPaid(paidValueOf(payment), unit.amount, o.maxDays); + if (!days) { + return json( + receipt(asked, { + error: `Pay a whole number of days: ${unit.amount} per day in the token's smallest unit, up to ${o.maxDays} days. Add ?days= to ${buyUrl} for the offer.`, + }), + 402, + ); + } + const expected = expectedFor(payment, offer(days)); + const term = days * o.passMinutes * 60; const now = Math.floor(Date.now() / 1000); const coinpay = { apiKey: o.coinpay.apiKey, baseUrl: o.coinpay.baseUrl, fetch: o.fetch }; @@ -145,7 +195,7 @@ export function createGateway(options = {}) { let expiresAt = null; let replayed = false; if (result.ok) { - expiresAt = now + o.passMinutes * 60; + expiresAt = now + term; } else if (result.replay) { /* * Paid once, lost the answer, asked again with the same proof. Answered @@ -158,12 +208,12 @@ export function createGateway(options = {}) { const paid = await settleAgain(payment, coinpay); const validBefore = validBeforeOf(payment); if (paid && validBefore) { - expiresAt = Math.min(now + o.passMinutes * 60, validBefore + o.passMinutes * 60); + expiresAt = Math.min(now + term, validBefore + term); replayed = true; } } if (!expiresAt || expiresAt <= now) { - return json(receipt({ error: result.reason ?? 'Payment could not be settled.' }), 402); + return json(receipt(days, { error: result.reason ?? 'Payment could not be settled.' }), 402); } const ref = nonceOf(payment) ?? result.ref ?? null; @@ -178,6 +228,8 @@ export function createGateway(options = {}) { expiresAt: expires, userAgent: ua, priceCents: o.priceCents, + days, + totalCents: o.priceCents * days, currency: o.currency, }); } catch { @@ -189,6 +241,8 @@ export function createGateway(options = {}) { ok: true, pass: pass.token, expires_at: expires, + days, + minutes: days * o.passMinutes, header: o.header, replayed, use: `curl -H "${o.header}: ${pass.token}" ${o.siteUrl}/`, @@ -198,8 +252,8 @@ export function createGateway(options = {}) { ); } - if (wantsHtml(request.headers.get('accept'))) return html(o.page(pageCtx()), 402); - return json(receipt({ error: `Payment required for training crawlers. Read ${buyUrl} for how.` }), 402); + if (wantsHtml(request.headers.get('accept'))) return html(o.page(pageCtx(asked)), 402); + return json(receipt(asked, { error: `Payment required for training crawlers. Read ${buyUrl} for how.` }), 402); } /** @@ -253,6 +307,42 @@ export function createGateway(options = {}) { /** Whether the caller would rather read a page than a JSON offer. */ export const wantsHtml = (accept = '') => String(accept ?? '').toLowerCase().includes('text/html'); +/** The value a proof authorizes, in the token's smallest unit, or null. */ +export function paidValueOf(payment) { + const raw = payment?.payload?.authorization?.value; + if (raw === undefined || raw === null || raw === '') return null; + try { + const value = BigInt(raw); + return value > 0n ? value : null; + } catch { + return null; + } +} + +/** + * How many terms a paid value buys at `unit` per term: a whole number in + * [1, maxDays], or 0 when it is not one. Integer arithmetic on the smallest + * unit, so a price that is not a round number of cents still divides exactly. + * + * @param {bigint|null} value + * @param {string|number|bigint} unit + * @param {number} maxDays + * @returns {number} + */ +export function daysPaid(value, unit, maxDays) { + if (value === null) return 0; + let per; + try { + per = BigInt(unit); + } catch { + return 0; + } + if (per <= 0n || value % per !== 0n) return 0; + const days = value / per; + if (days < 1n || days > BigInt(maxDays)) return 0; + return Number(days); +} + function normalise(options) { const siteUrl = String(options.siteUrl ?? '').replace(/\/+$/, ''); if (!siteUrl) throw new Error('createGateway needs siteUrl'); @@ -268,6 +358,7 @@ function normalise(options) { priceCents: Number.isFinite(options.priceCents) ? options.priceCents : 100, currency: options.currency ?? 'USD', passMinutes: Number.isFinite(options.passMinutes) && options.passMinutes > 0 ? options.passMinutes : 1440, + maxDays: Number.isInteger(options.maxDays) && options.maxDays >= 1 ? options.maxDays : 30, header: String(options.header ?? 'x-crawl-pass').toLowerCase(), path: options.path ?? '/crawl', openPaths: options.openPaths ?? [], diff --git a/src/page.js b/src/page.js index 31bf0a7..0017006 100644 --- a/src/page.js +++ b/src/page.js @@ -55,6 +55,9 @@ export function renderPage(ctx) { training = [], retrieval = [], contact, + days = 1, + total = price, + maxDays = 30, } = ctx; const window = minutes === 1440 @@ -82,7 +85,12 @@ export function renderPage(ctx) {

Training crawlers pay for access here.

People read ${esc(siteName)} free. So do search engines and the retrieval crawlers behind AI answers, because they send readers back. A crawler that copies pages into a training corpus sends nobody back, so it pays for the time it spends.

-
${esc(price)} for ${esc(window)} of requests
+
${esc(days > 1 ? total : price)} for ${esc(days > 1 ? `${days} × ${window}` : window)} of requests
+${ + days > 1 + ? `

This offer is for ${days} days at ${esc(price)} a day. The plain page at ${esc(buyUrl)} quotes one.

` + : `

Want longer? Add ?days=<n> to this URL for an offer of up to ${maxDays} days at ${esc(price)} a day, or simply pay a whole multiple of the price: the pass lasts as many days as you paid for.

` +} ${ enabled ? '' @@ -93,13 +101,15 @@ ${
  1. Any page you fetch answers 402 Payment Required. This page, fetched with Accept: application/json, returns the x402 offer: USDC, exact scheme, on ${esc(networks || 'Base, Polygon or Ethereum')}.
  2. Sign the payment and retry with the proof in an X-PAYMENT header. The response is a JSON receipt carrying a pass.
  3. -
  4. Send the pass in ${esc(header)} on every request for the next ${esc(window)}. When it expires, buy another. The sale is the pass, not the page: fetch the page again with the pass.
  5. +
  6. Send the pass in ${esc(header)} 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.

Pay with the CoinPay CLI

Settlement is by CoinPay: the buyer's USDC goes straight to the site's wallet and CoinPay's relayer pays the gas, so you need USDC and nothing else.

npm install -g @profullstack/coinpay
-coinpay x402 pay ${esc(buyUrl)} --output pass.json
+coinpay x402 pay ${esc(buyUrl)} --output pass.json +# or a week at once: +coinpay x402 pay "${esc(buyUrl)}?days=7" --output pass.json

The command fetches this page, reads the offer, opens a browser tab to approve the payment with the CoinPay Wallet extension or any EIP-6963 wallet (MetaMask, Rabby, Coinbase Wallet), and writes the receipt to pass.json. Then:

PASS=$(node -p "require('./pass.json').pass")
 curl -H "${esc(header)}: $PASS" ${esc(siteUrl)}/
@@ -109,7 +119,8 @@ curl -H "${esc(header)}: $PASS" ${esc(siteUrl)}/ # 402 with { "x402Version": 2, "accepts": [ ... ] } # sign an EIP-3009 transferWithAuthorization for one entry, then: curl -sS -H "X-PAYMENT: <base64 proof>" ${esc(buyUrl)} -# 200 with { "ok": true, "pass": "cp_...", "expires_at": "...", "header": "${esc(header)}" } +# 200 with { "ok": true, "pass": "cp_...", "expires_at": "...", "days": 1, "header": "${esc(header)}" } +

The days a proof buys are read off the value it authorizes: a whole multiple of the one-day amount, up to ${maxDays}. ?days=<n> only changes what the offer quotes, so a standard client that pays exactly what is asked gets n days.

The proof is x402 v2 in CoinPay's dialect: { x402Version: 2, scheme: "exact", network: "<CAIP-2>", payload: { signature, authorization } }, base64-encoded. A proof is single-use; retrying with the same one returns the same pass, not a second charge.

Who pays and who does not

diff --git a/test/days.test.js b/test/days.test.js new file mode 100644 index 0000000..2a93458 --- /dev/null +++ b/test/days.test.js @@ -0,0 +1,215 @@ +/** + * Buying more than a day at once. + * + * Two ways in, one rule: `?days=N` shapes the offer a buyer reads, and the + * days a proof buys are read off the value it authorizes. So a standard + * client that pays exactly what the N-day offer asks gets N days, a client + * that simply signs for three times the price gets three, and a value that is + * not a whole number of day-prices buys nothing before anyone is charged. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createGateway, daysPaid, paidValueOf } from '../src/index.js'; +import { METHODS } from '../src/x402.js'; + +const META = 'Mozilla/5.0 (compatible; meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler))'; +const KEY = 'cp_live_0123456789abcdef0123456789abcdef'; +const PAY_TO = '0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5'; +const DAY = 24 * 3600 * 1000; + +const toBase64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64'); + +const proof = ({ value = '1000000', nonce = '0xabc', validBefore } = {}) => + toBase64({ + x402Version: 2, + scheme: 'exact', + network: 'eip155:8453', + payload: { + signature: '0xsig', + authorization: { + from: '0xPAYER', + to: PAY_TO, + value, + validAfter: '0', + validBefore: String(validBefore ?? Math.floor(Date.now() / 1000) + 600), + nonce, + }, + }, + }); + +function fakeCoinpay(script = {}) { + const calls = []; + const fetch = async (url, init) => { + const body = JSON.parse(init.body); + calls.push({ url, body }); + const path = new URL(url).pathname; + const answer = script[path] ?? (path.endsWith('/verify') ? { valid: true, payment: { from: '0xPAYER' } } : { settled: true, txHash: '0xtx' }); + return new Response(JSON.stringify(answer), { status: answer.status ?? 200 }); + }; + return { fetch, calls }; +} + +const gatewayFor = (extra = {}, script) => { + const cp = fakeCoinpay(script); + const sales = []; + const gateway = createGateway({ + siteUrl: 'https://example.test', + coinpay: { apiKey: KEY, baseUrl: 'https://coinpay.test' }, + payTo: PAY_TO, + fetch: cp.fetch, + onSale: (s) => sales.push(s), + ...extra, + }); + return { gateway, cp, sales }; +}; + +const req = (path, { ua = META, accept = 'application/json', headers = {} } = {}) => + new Request(`https://example.test${path}`, { headers: { 'user-agent': ua, accept, ...headers } }); + +describe('the offer for more than a day', () => { + it('quotes ?days=N at N times the price, on the sales page and on any gated page', async () => { + const { gateway } = gatewayFor(); + const week = await (await gateway.handle(req('/crawl?days=7'))).json(); + assert.equal(week.accepts[0].amount, '7000000'); + assert.equal(week.accepts[1].amount, '7000000'); + assert.match(week.accepts[0].description, /10080 minutes .* \(7 × 1440\)/); + assert.equal(week.pass.days, 7); + assert.equal(week.pass.total, '7.00 USD'); + assert.equal(week.pass.price, '1.00 USD'); + assert.equal(week.pass.maxDays, 30); + assert.equal(week.pass.buy, 'https://example.test/crawl?days=7'); + assert.equal(week.pass.buyDays, 'https://example.test/crawl?days='); + + const page = await (await gateway.handle(req('/events/1?days=3'))).json(); + assert.equal(page.accepts[0].amount, '3000000'); + assert.equal(page.pass.days, 3); + }); + + it('a plain request still quotes one day, and says how to get more', async () => { + const { gateway } = gatewayFor(); + const one = await (await gateway.handle(req('/crawl'))).json(); + assert.equal(one.accepts[0].amount, '1000000'); + assert.equal(one.pass.days, 1); + assert.equal(one.pass.buy, 'https://example.test/crawl'); + const html = await (await gateway.handle(req('/crawl', { accept: 'text/html' }))).text(); + assert.match(html, /\?days=/); + assert.match(html, /up to 30 days/); + }); + + it('clamps days to [1, maxDays] and shrugs at nonsense', async () => { + const { gateway } = gatewayFor({ maxDays: 10 }); + for (const [q, days] of [['?days=0', 1], ['?days=-4', 1], ['?days=abc', 1], ['?days=2.9', 2], ['?days=99', 10], ['?days=10', 10]]) { + const body = await (await gateway.handle(req(`/crawl${q}`))).json(); + assert.equal(body.pass.days, days, q); + assert.equal(body.accepts[0].amount, String(days * 1000000), q); + } + const html = await (await gateway.handle(req('/crawl?days=5', { accept: 'text/html' }))).text(); + assert.match(html, /5\.00 USD/); + assert.match(html, /5 × one day/); + }); +}); + +describe('what a proof buys', () => { + it('a proof for three days buys a pass that lasts three days', async () => { + const { gateway, cp, sales } = gatewayFor(); + const res = await gateway.handle(req('/events/1', { headers: { 'x-payment': proof({ value: '3000000', nonce: '0xd3' }) } })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.days, 3); + assert.equal(body.minutes, 3 * 1440); + const exp = Date.parse(body.expires_at) - Date.now(); + assert.ok(exp > 2.99 * DAY && exp <= 3 * DAY, String(exp)); + + // CoinPay was asked to verify exactly what was signed, against the 3-day offer. + assert.equal(cp.calls[0].body.expected.amount, '3000000'); + assert.equal(cp.calls[0].body.expected.asset, METHODS[0].asset); + assert.equal(sales.length, 1); + assert.equal(sales[0].days, 3); + assert.equal(sales[0].priceCents, 100); + assert.equal(sales[0].totalCents, 300); + + // The pass opens the site. + assert.equal(await gateway.handle(req('/events/2', { headers: { 'x-crawl-pass': body.pass } })), null); + }); + + it('the money decides, not the URL', async () => { + const { gateway, cp } = gatewayFor(); + // Asked for seven, signed for two: two. + const res = await gateway.handle(req('/crawl?days=7', { headers: { 'x-payment': proof({ value: '2000000', nonce: '0xd2' }) } })); + const body = await res.json(); + assert.equal(res.status, 200); + assert.equal(body.days, 2); + assert.equal(cp.calls[0].body.expected.amount, '2000000'); + }); + + it('a value that is not a whole number of days buys nothing, before anyone is charged', async () => { + const { gateway, cp } = gatewayFor(); + for (const value of ['1500000', '999999', '0', '-1000000', 'lots']) { + const res = await gateway.handle(req('/x', { headers: { 'x-payment': proof({ value }) } })); + assert.equal(res.status, 402, value); + const body = await res.json(); + assert.match(body.error, /whole number of days/); + assert.match(body.error, /1000000 per day/); + } + assert.equal(cp.calls.length, 0, 'CoinPay was never asked'); + }); + + it('refuses more than maxDays in one proof', async () => { + const { gateway, cp } = gatewayFor({ maxDays: 5 }); + const res = await gateway.handle(req('/x', { headers: { 'x-payment': proof({ value: '6000000' }) } })); + assert.equal(res.status, 402); + assert.match((await res.json()).error, /up to 5 days/); + assert.equal(cp.calls.length, 0); + + const ok = await gateway.handle(req('/x', { headers: { 'x-payment': proof({ value: '5000000', nonce: '0xd5' }) } })); + assert.equal(ok.status, 200); + assert.equal((await ok.json()).days, 5); + }); + + it('a replayed multi-day proof is bounded by its own validity plus the days it bought', async () => { + const validBefore = Math.floor(Date.now() / 1000) - 3600; + const { gateway } = gatewayFor({}, { + '/api/x402/verify': { valid: false, error: 'Proof already used', status: 400 }, + '/api/x402/settle': { settled: false, error: 'already settled', status: 409 }, + }); + const res = await gateway.handle(req('/x', { headers: { 'x-payment': proof({ value: '4000000', validBefore }) } })); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.replayed, true); + assert.equal(body.days, 4); + assert.equal(Math.round(Date.parse(body.expires_at) / 1000), validBefore + 4 * 1440 * 60); + }); + + it('a custom price divides exactly in the smallest unit', async () => { + // 0.35 USD a day: 350000 units. Three days is 1050000, and 1000000 is not a day. + const { gateway } = gatewayFor({ priceCents: 35 }); + const three = await gateway.handle(req('/x', { headers: { 'x-payment': proof({ value: '1050000', nonce: '0xc3' }) } })); + assert.equal((await three.json()).days, 3); + const off = await gateway.handle(req('/x', { headers: { 'x-payment': proof({ value: '1000000' }) } })); + assert.equal(off.status, 402); + }); +}); + +describe('the arithmetic', () => { + it('daysPaid', () => { + assert.equal(daysPaid(1000000n, '1000000', 30), 1); + assert.equal(daysPaid(30000000n, '1000000', 30), 30); + assert.equal(daysPaid(31000000n, '1000000', 30), 0); + assert.equal(daysPaid(1500000n, '1000000', 30), 0); + assert.equal(daysPaid(0n, '1000000', 30), 0); + assert.equal(daysPaid(null, '1000000', 30), 0); + assert.equal(daysPaid(1000000n, '0', 30), 0); + assert.equal(daysPaid(1000000n, 'x', 30), 0); + }); + it('paidValueOf', () => { + assert.equal(paidValueOf({ payload: { authorization: { value: '42' } } }), 42n); + assert.equal(paidValueOf({ payload: { authorization: { value: '0' } } }), null); + assert.equal(paidValueOf({ payload: { authorization: { value: '-1' } } }), null); + assert.equal(paidValueOf({ payload: { authorization: { value: 'nope' } } }), null); + assert.equal(paidValueOf({ payload: {} }), null); + assert.equal(paidValueOf(null), null); + }); +});