@profullstack/coinpay 0.6.11 → 0.8.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/src/x402-v2.js ADDED
@@ -0,0 +1,416 @@
1
+ /**
2
+ * x402 v2 protocol primitives.
3
+ *
4
+ * `x402.js` speaks a dialect only CoinPayPortal understands: `x402Version: 1`,
5
+ * bare network names like `"base"`, `maxAmountRequired` instead of `amount`,
6
+ * and an EIP-712 domain of our own invention (`name: 'x402'`). Nothing else in
7
+ * the ecosystem can read a 402 we emit, and no proof from a standard client
8
+ * verifies against us.
9
+ *
10
+ * That is survivable while CoinPay Wallet is the only payer. It stops being
11
+ * survivable the moment MetaMask or Phantom is expected to pay, because those
12
+ * wallets sign standard structures and nothing else: MetaMask will happily
13
+ * sign our bespoke domain, but the resulting signature authorises nothing
14
+ * on-chain and only we could ever interpret it.
15
+ *
16
+ * This module is the real thing, and is shared by the browser payer and the
17
+ * facilitator so the two cannot drift.
18
+ *
19
+ * Field shapes here were taken from live `/supported` and
20
+ * `/discovery/resources` responses on facilitator.goplausible.xyz and
21
+ * Coinbase's facilitator, not from memory of the spec.
22
+ *
23
+ * @module x402-v2
24
+ */
25
+
26
+ /** The protocol version the ecosystem is actually on. */
27
+ export const X402_VERSION = 2;
28
+
29
+ /**
30
+ * CAIP-2 chain identifiers.
31
+ *
32
+ * The ecosystem addresses chains as `namespace:reference`, never as a bare
33
+ * name — `"base"` is meaningless to another facilitator, `eip155:8453` is not.
34
+ *
35
+ * `eip155:*` and `solana:*` are verified against live facilitator responses.
36
+ * Solana's reference is the first 32 characters of the genesis hash, per
37
+ * CAIP-30.
38
+ */
39
+ export const CAIP2 = {
40
+ ethereum: 'eip155:1',
41
+ polygon: 'eip155:137',
42
+ base: 'eip155:8453',
43
+ 'base-sepolia': 'eip155:84532',
44
+ solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
45
+ // CAIP-2 namespace for Bitcoin-family chains is `bip122`, referenced by the
46
+ // genesis block hash. Bitcoin Cash shares Bitcoin's genesis block, so the two
47
+ // are not distinguishable by CAIP-2 alone; BCH is left out rather than given
48
+ // an identifier that collides with Bitcoin's.
49
+ bitcoin: 'bip122:000000000019d6689c085ae165831e93',
50
+ };
51
+
52
+ /**
53
+ * Rails CoinPayPortal supports that have no CAIP-2 identity.
54
+ *
55
+ * Nobody else's facilitator accepts these, so there is no interoperable name
56
+ * to use. They keep their legacy identifiers and are flagged here so callers
57
+ * can tell "not yet mapped" from "deliberately outside CAIP-2".
58
+ */
59
+ export const NON_CAIP2_NETWORKS = new Set(['bitcoin-cash', 'lightning', 'stripe']);
60
+
61
+ /** Legacy bare name -> CAIP-2, for translating our own v1 records. */
62
+ export function toCaip2(network) {
63
+ if (!network) return '';
64
+ if (network.includes(':')) return network; // already CAIP-2
65
+ return CAIP2[network] ?? network;
66
+ }
67
+
68
+ /** CAIP-2 -> legacy bare name, for code that still keys on the old names. */
69
+ export function fromCaip2(caip2) {
70
+ if (!caip2) return '';
71
+ if (!caip2.includes(':')) return caip2; // already a bare name
72
+ const match = Object.entries(CAIP2).find(([, id]) => id === caip2);
73
+ return match ? match[0] : caip2;
74
+ }
75
+
76
+ /** Numeric EVM chain id from a CAIP-2 id, or null if it is not an eip155 chain. */
77
+ export function evmChainId(network) {
78
+ const caip2 = toCaip2(network);
79
+ const match = /^eip155:(\d+)$/.exec(caip2);
80
+ return match ? Number(match[1]) : null;
81
+ }
82
+
83
+ /**
84
+ * EIP-712 domains for the tokens we quote, keyed by `chainId:address`.
85
+ *
86
+ * A payer cannot sign without `name` and `version`, and they are not derivable:
87
+ * `version()` is not part of ERC-20 and tokens disagree about the value. So a
88
+ * v2 `accepts` entry has to carry them, which means the quoting side has to
89
+ * know them.
90
+ *
91
+ * Every entry below was read from the deployed contract — `name()`,
92
+ * `version()` and `DOMAIN_SEPARATOR()` over JSON-RPC — not copied from
93
+ * documentation. The Base values additionally match what GoPlausible's
94
+ * discovery index publishes for the same token.
95
+ *
96
+ * A token that is not in this table cannot be quoted for v2, because guessing
97
+ * its domain produces signatures that recover to the wrong address and fail
98
+ * verification with no useful diagnostic.
99
+ */
100
+ export const TOKEN_DOMAINS = {
101
+ // USDC. All three deployments report the same domain.
102
+ 'eip155:1:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { name: 'USD Coin', version: '2' },
103
+ 'eip155:137:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': { name: 'USD Coin', version: '2' },
104
+ 'eip155:8453:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': { name: 'USD Coin', version: '2' },
105
+ };
106
+
107
+ /** The published EIP-712 domain for a token, or null if we do not know it. */
108
+ export function tokenDomain(network, asset) {
109
+ if (!asset) return null;
110
+ const caip2 = toCaip2(network);
111
+ const exact = TOKEN_DOMAINS[`${caip2}:${asset}`];
112
+ if (exact) return exact;
113
+
114
+ // Addresses are case-insensitive, so a caller passing a differently-cased
115
+ // address must not silently miss.
116
+ const wanted = `${caip2}:${asset}`.toLowerCase();
117
+ const match = Object.entries(TOKEN_DOMAINS).find(([key]) => key.toLowerCase() === wanted);
118
+ return match ? match[1] : null;
119
+ }
120
+
121
+ /**
122
+ * The v2-quotable tokens, as `{ methodKey: {network, asset, decimals} }`.
123
+ *
124
+ * Only tokens: EIP-3009 is an ERC-20 extension, so native ETH or POL cannot be
125
+ * paid under the `exact` scheme at all — there is no contract to authorise
126
+ * against. Quoting native currency as `exact` would advertise something no
127
+ * wallet can sign.
128
+ */
129
+ export const V2_METHODS = {
130
+ usdc_eth: {
131
+ network: 'eip155:1',
132
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
133
+ decimals: 6,
134
+ label: 'USDC on Ethereum',
135
+ },
136
+ usdc_polygon: {
137
+ network: 'eip155:137',
138
+ asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
139
+ decimals: 6,
140
+ label: 'USDC on Polygon',
141
+ },
142
+ usdc_base: {
143
+ network: 'eip155:8453',
144
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
145
+ decimals: 6,
146
+ label: 'USDC on Base',
147
+ },
148
+ };
149
+
150
+ /**
151
+ * EIP-3009 `TransferWithAuthorization` type.
152
+ *
153
+ * This — not a bespoke `Payment` struct — is what x402's `exact` scheme signs
154
+ * on EVM. The signature IS the payment: whoever holds it can call
155
+ * `transferWithAuthorization` on the token and move the funds, which is why
156
+ * the facilitator can broadcast and pay the gas while the payer needs no
157
+ * native currency at all.
158
+ */
159
+ export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
160
+ TransferWithAuthorization: [
161
+ { name: 'from', type: 'address' },
162
+ { name: 'to', type: 'address' },
163
+ { name: 'value', type: 'uint256' },
164
+ { name: 'validAfter', type: 'uint256' },
165
+ { name: 'validBefore', type: 'uint256' },
166
+ { name: 'nonce', type: 'bytes32' },
167
+ ],
168
+ };
169
+
170
+ /**
171
+ * Build the EIP-712 domain for an EIP-3009 authorization.
172
+ *
173
+ * The domain belongs to the TOKEN, not to x402: `verifyingContract` is the
174
+ * token's own address, and `name`/`version` are the values that token returns
175
+ * from its own EIP-712 domain. Tokens disagree about `version` ("1" vs "2")
176
+ * and it is not derivable, which is why a v2 `accepts` entry carries it in
177
+ * `extra` — that is what `extra: { name: 'USD Coin', version: '2' }` in live
178
+ * discovery data is for.
179
+ *
180
+ * Getting this wrong is not a soft failure: the recovered signer is simply
181
+ * some other address, so the authorization is void.
182
+ *
183
+ * @param {object} args
184
+ * @param {string} args.network CAIP-2 id or legacy name
185
+ * @param {string} args.asset token contract address
186
+ * @param {string} args.name token's EIP-712 domain name
187
+ * @param {string} args.version token's EIP-712 domain version
188
+ */
189
+ export function buildEip3009Domain({ network, asset, name, version }) {
190
+ const chainId = evmChainId(network);
191
+ if (chainId === null) {
192
+ throw new Error(`Not an EVM network, cannot build an EIP-3009 domain: ${network}`);
193
+ }
194
+ if (!asset) {
195
+ throw new Error('EIP-3009 requires the token contract address as verifyingContract');
196
+ }
197
+ if (!name || !version) {
198
+ throw new Error(
199
+ 'EIP-3009 requires the token EIP-712 domain name and version; ' +
200
+ 'a v2 `accepts` entry supplies them in `extra.name` / `extra.version`',
201
+ );
202
+ }
203
+
204
+ return { name, version, chainId, verifyingContract: asset };
205
+ }
206
+
207
+ /**
208
+ * A random 32-byte nonce, hex encoded.
209
+ *
210
+ * EIP-3009 nonces are arbitrary bytes32 rather than a sequence — the token
211
+ * tracks used ones per authorizer, so they need only be unpredictable and not
212
+ * repeated. Uses WebCrypto, which is present in browsers, Workers and Node 18+.
213
+ */
214
+ export function randomNonce() {
215
+ const bytes = new Uint8Array(32);
216
+ globalThis.crypto.getRandomValues(bytes);
217
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
218
+ }
219
+
220
+ /**
221
+ * Build the authorization message to be signed.
222
+ *
223
+ * `validAfter` is 0 rather than "now": clocks differ between the payer and the
224
+ * chain, and a validAfter in the payer's near future makes the authorization
225
+ * briefly unusable for no benefit.
226
+ *
227
+ * @param {object} args
228
+ * @param {string} args.from payer address
229
+ * @param {string} args.to payee address (`payTo` from `accepts`)
230
+ * @param {string|bigint} args.value amount in the token's smallest unit
231
+ * @param {number} [args.validForSeconds=600]
232
+ * @param {string} [args.nonce] defaults to a fresh random nonce
233
+ */
234
+ export function buildAuthorization({ from, to, value, validForSeconds = 600, nonce }) {
235
+ if (!from) throw new Error('buildAuthorization requires `from`');
236
+ if (!to) throw new Error('buildAuthorization requires `to`');
237
+ if (value === undefined || value === null || value === '') {
238
+ throw new Error('buildAuthorization requires `value`');
239
+ }
240
+
241
+ const now = Math.floor(Date.now() / 1000);
242
+
243
+ return {
244
+ from,
245
+ to,
246
+ value: String(value),
247
+ validAfter: '0',
248
+ validBefore: String(now + validForSeconds),
249
+ nonce: nonce ?? randomNonce(),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Assemble the payment payload that goes in the `X-PAYMENT` header.
255
+ *
256
+ * @param {object} args
257
+ * @param {string} args.network CAIP-2 id
258
+ * @param {string} args.signature 65-byte EIP-712 signature, hex
259
+ * @param {object} args.authorization from `buildAuthorization`
260
+ * @param {string} [args.scheme='exact']
261
+ */
262
+ export function buildExactEvmPayment({ network, signature, authorization, scheme = 'exact' }) {
263
+ return {
264
+ x402Version: X402_VERSION,
265
+ scheme,
266
+ network: toCaip2(network),
267
+ payload: { signature, authorization },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * base64 encoding of a payment payload, as the `X-PAYMENT` header carries it.
273
+ *
274
+ * Uses base64url-safe output only where the runtime gives it; the header is
275
+ * standard base64 in the spec, so plain base64 is what is produced.
276
+ */
277
+ export function encodePaymentHeader(payment) {
278
+ const json = JSON.stringify(payment);
279
+ if (typeof globalThis.btoa === 'function') {
280
+ // Browsers: btoa is latin1-only, so UTF-8 must be widened first.
281
+ const bytes = new TextEncoder().encode(json);
282
+ let binary = '';
283
+ for (const byte of bytes) binary += String.fromCharCode(byte);
284
+ return globalThis.btoa(binary);
285
+ }
286
+ return Buffer.from(json, 'utf-8').toString('base64');
287
+ }
288
+
289
+ /** Inverse of {@link encodePaymentHeader}. */
290
+ export function decodePaymentHeader(header) {
291
+ let json;
292
+ if (typeof globalThis.atob === 'function') {
293
+ const binary = globalThis.atob(header);
294
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
295
+ json = new TextDecoder().decode(bytes);
296
+ } else {
297
+ json = Buffer.from(header, 'base64').toString('utf-8');
298
+ }
299
+ return JSON.parse(json);
300
+ }
301
+
302
+ /**
303
+ * Pick the best `accepts` entry a given set of wallets can actually pay.
304
+ *
305
+ * A 402 lists several options and the payer must choose one it can sign. The
306
+ * order of `accepts` is the merchant's preference, so it is honoured rather
307
+ * than re-ranked — the first entry whose network is supported wins.
308
+ *
309
+ * @param {object[]} accepts the 402's `accepts` array
310
+ * @param {string[]} capabilities CAIP-2 ids (or bare names) the payer can sign
311
+ * @returns {object|null}
312
+ */
313
+ export function selectAcceptEntry(accepts, capabilities) {
314
+ if (!Array.isArray(accepts) || accepts.length === 0) return null;
315
+ const supported = new Set(capabilities.map(toCaip2));
316
+ return accepts.find((entry) => supported.has(toCaip2(entry?.network))) ?? null;
317
+ }
318
+
319
+ /**
320
+ * Build a v2 `402 Payment Required` body.
321
+ *
322
+ * `buildPaymentRequired` in x402.js emits our v1 dialect — `x402Version: 1`,
323
+ * bare network names, `maxAmountRequired` — which no standard payer can read.
324
+ * A facilitator that verifies v2 but only ever quotes v1 still cannot be paid
325
+ * by anyone, so this is the other half of speaking the protocol.
326
+ *
327
+ * Only tokens with a known EIP-712 domain are quoted. An entry a payer cannot
328
+ * sign is worse than an absent one: it looks payable, fails at signing time,
329
+ * and the failure surfaces as an opaque signature error.
330
+ *
331
+ * @param {object} options
332
+ * @param {string|Record<string,string>} options.payTo merchant address, or per-network map
333
+ * @param {number} [options.amountUsd] price in USD (USDC is quoted 1:1)
334
+ * @param {string} [options.amount] explicit smallest-unit amount, overrides amountUsd
335
+ * @param {string[]} [options.methods] keys from V2_METHODS; defaults to all
336
+ * @param {string} options.resource the URL being sold
337
+ * @param {string} [options.description]
338
+ * @param {string} [options.mimeType='application/json']
339
+ * @param {number} [options.maxTimeoutSeconds=300]
340
+ */
341
+ export function buildPaymentRequiredV2({
342
+ payTo,
343
+ amountUsd,
344
+ amount,
345
+ methods,
346
+ resource,
347
+ description = 'Payment required',
348
+ mimeType = 'application/json',
349
+ maxTimeoutSeconds = 300,
350
+ } = {}) {
351
+ if (!payTo) throw new Error('buildPaymentRequiredV2 requires `payTo`');
352
+ if (!resource) throw new Error('buildPaymentRequiredV2 requires `resource`');
353
+ if (amount === undefined && amountUsd === undefined) {
354
+ throw new Error('buildPaymentRequiredV2 requires `amount` or `amountUsd`');
355
+ }
356
+
357
+ const keys = methods ?? Object.keys(V2_METHODS);
358
+ const accepts = [];
359
+
360
+ for (const key of keys) {
361
+ const method = V2_METHODS[key];
362
+ if (!method) continue;
363
+
364
+ const domain = tokenDomain(method.network, method.asset);
365
+ if (!domain) continue; // unsignable — leave it out rather than advertise it
366
+
367
+ const address =
368
+ typeof payTo === 'object' ? (payTo[method.network] ?? payTo[fromCaip2(method.network)] ?? payTo[key]) : payTo;
369
+ if (!address) continue; // merchant has no address on this chain
370
+
371
+ // USDC is a dollar stablecoin with 6 decimals, so a USD price converts
372
+ // exactly. Rounding up rather than down: rounding a fraction of a cent
373
+ // down would quote less than the asking price and verify as underpayment.
374
+ const smallestUnit =
375
+ amount !== undefined
376
+ ? String(amount)
377
+ : String(Math.ceil(amountUsd * 10 ** method.decimals));
378
+
379
+ accepts.push({
380
+ scheme: 'exact',
381
+ network: method.network,
382
+ amount: smallestUnit,
383
+ asset: method.asset,
384
+ payTo: address,
385
+ resource,
386
+ description,
387
+ mimeType,
388
+ maxTimeoutSeconds,
389
+ // The token's EIP-712 domain. Without this the payer cannot construct a
390
+ // signature at all — it is the whole reason `extra` exists on a v2 entry.
391
+ extra: { name: domain.name, version: domain.version },
392
+ });
393
+ }
394
+
395
+ if (accepts.length === 0) {
396
+ throw new Error(
397
+ 'No payable options could be built. Check `payTo` covers at least one supported chain.',
398
+ );
399
+ }
400
+
401
+ return { x402Version: X402_VERSION, accepts };
402
+ }
403
+
404
+ /**
405
+ * The amount an `accepts` entry asks for, in the asset's smallest unit.
406
+ *
407
+ * v2 names this `amount`; our own v1 emitted `maxAmountRequired`. Both are
408
+ * read so a payer can pay a CoinPayPortal 402 that has not been migrated yet.
409
+ */
410
+ export function requiredAmount(entry) {
411
+ const raw = entry?.amount ?? entry?.maxAmountRequired;
412
+ if (raw === undefined || raw === null || raw === '') {
413
+ throw new Error('`accepts` entry has neither `amount` nor `maxAmountRequired`');
414
+ }
415
+ return String(raw);
416
+ }
package/src/x402.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * @module x402
9
9
  */
10
10
 
11
+ import { buildPaymentRequiredV2 } from './x402-v2.js';
12
+
11
13
  /**
12
14
  * All supported payment methods with their network/asset metadata.
13
15
  *
@@ -331,6 +333,82 @@ function _networkToMethodKey(network) {
331
333
  return map[network] || network;
332
334
  }
333
335
 
336
+ /**
337
+ * Decode an X-PAYMENT header without verifying it.
338
+ *
339
+ * Returns null on anything malformed — the caller treats that as "no usable
340
+ * proof", and the facilitator remains the only thing that decides validity.
341
+ */
342
+ function decodePaymentHeader(paymentHeader) {
343
+ try {
344
+ return JSON.parse(Buffer.from(paymentHeader, 'base64').toString('utf-8'));
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Work out what this proof was supposed to pay, by finding the offer entry for
352
+ * the method the payer chose.
353
+ *
354
+ * The price comes from the offer the server just built — never from the proof
355
+ * itself, which is the payer's word for what they owe.
356
+ *
357
+ * @returns {{amount: string, resource: string, payTo?: string, asset?: string}|null}
358
+ * null if the proof matches no advertised method, in which case there is no
359
+ * price to hold it to.
360
+ */
361
+ export function expectedForProof(paymentHeader, offer, resource) {
362
+ const payment = decodePaymentHeader(paymentHeader);
363
+ if (!payment?.payload) return null;
364
+
365
+ // A v2 proof describes itself differently: the network sits at the top level
366
+ // and the payload holds a signed authorization rather than loose fields.
367
+ // Matching it against the offer the same way as v1 would find nothing and
368
+ // report "no price to check against" for a perfectly good payment.
369
+ const authorization = payment.payload.authorization;
370
+ if (authorization) {
371
+ const entries = offer?.accepts || [];
372
+ const match =
373
+ entries.find((e) => e.network === payment.network && e.payTo === authorization.to) ||
374
+ entries.find((e) => e.network === payment.network);
375
+
376
+ if (!match) return null;
377
+
378
+ return {
379
+ amount: match.amount ?? match.maxAmountRequired,
380
+ resource,
381
+ // v2 verification needs both: an EIP-3009 signature says nothing about
382
+ // which token it is denominated in, so without the asset there is no
383
+ // domain to check it against.
384
+ payTo: match.payTo,
385
+ asset: match.asset,
386
+ };
387
+ }
388
+
389
+ const { network, asset } = payment.payload;
390
+ const methodKey = payment.payload.methodKey || payment.payload.extra?.methodKey;
391
+
392
+ const entries = offer?.accepts || [];
393
+
394
+ // Prefer an exact method match: a network alone is ambiguous, since e.g.
395
+ // ethereum offers both ETH and USDC at very different smallest-unit prices.
396
+ let match = methodKey
397
+ ? entries.find((e) => e.extra?.methodKey === methodKey)
398
+ : undefined;
399
+
400
+ if (!match && network) {
401
+ const sameNetwork = entries.filter((e) => e.network === network);
402
+ match =
403
+ sameNetwork.find((e) => e.asset === asset) ||
404
+ (sameNetwork.length === 1 ? sameNetwork[0] : undefined);
405
+ }
406
+
407
+ if (!match) return null;
408
+
409
+ return { amount: match.maxAmountRequired, resource };
410
+ }
411
+
334
412
  /**
335
413
  * Create Express/Next.js middleware that gates routes behind x402 payments.
336
414
  *
@@ -373,9 +451,23 @@ export function createX402Middleware(globalOptions) {
373
451
  description = 'Payment required',
374
452
  facilitatorUrl = DEFAULT_FACILITATOR_URL,
375
453
  apiBaseUrl = 'https://coinpayportal.com',
454
+ allowPendingConfirmation: globalAllowPendingConfirmation = false,
455
+ /**
456
+ * Which protocol version to quote in the 402.
457
+ *
458
+ * Defaults to 1 so existing integrations keep the offer they have today —
459
+ * their payers only understand our dialect, and switching the quote out
460
+ * from under them would break every one at once. Set to 2 to advertise the
461
+ * real protocol, which is what any standard wallet (MetaMask, Phantom) can
462
+ * actually pay.
463
+ */
464
+ x402Version: protocolVersion = 1,
376
465
  } = globalOptions;
377
466
 
378
467
  if (!apiKey) throw new Error('x402 middleware requires an apiKey');
468
+ if (protocolVersion !== 1 && protocolVersion !== 2) {
469
+ throw new Error(`Unsupported x402Version: ${protocolVersion} (expected 1 or 2)`);
470
+ }
379
471
  if (!payTo) throw new Error('x402 middleware requires a payTo address');
380
472
 
381
473
  // Mutable rates cache — can be updated externally or via ratesEndpoint
@@ -408,6 +500,8 @@ export function createX402Middleware(globalOptions) {
408
500
  const routeAmount = routeOptions.amount;
409
501
  const routeDescription = routeOptions.description || description;
410
502
  const routeMethods = routeOptions.methods || methods;
503
+ const allowPendingConfirmation =
504
+ routeOptions.allowPendingConfirmation ?? globalAllowPendingConfirmation;
411
505
 
412
506
  if (!routeAmountUsd && !routeAmount) {
413
507
  throw new Error('x402 route requires amountUsd or amount');
@@ -415,32 +509,58 @@ export function createX402Middleware(globalOptions) {
415
509
 
416
510
  return async function x402Middleware(req, res, next) {
417
511
  const paymentHeader = req.headers['x-payment'] || req.headers['X-Payment'];
512
+ const resource = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
513
+
514
+ // Build the offer regardless of branch: on a 402 it is the response body,
515
+ // and on a paid request it is the price the proof has to actually cover.
516
+ let offer;
517
+ try {
518
+ offer =
519
+ protocolVersion === 2
520
+ ? buildPaymentRequiredV2({
521
+ payTo,
522
+ amountUsd: routeAmountUsd,
523
+ amount: routeAmount,
524
+ methods: routeMethods,
525
+ resource,
526
+ description: routeDescription,
527
+ })
528
+ : buildPaymentRequired({
529
+ payTo,
530
+ amountUsd: routeAmountUsd,
531
+ amount: routeAmount,
532
+ network: routeOptions.network,
533
+ rates: currentRates,
534
+ methods: routeMethods,
535
+ resource,
536
+ description: routeDescription,
537
+ facilitatorUrl,
538
+ });
539
+ } catch (err) {
540
+ return res.status(500).json({ error: 'Failed to build payment options', details: err.message });
541
+ }
418
542
 
419
543
  if (!paymentHeader) {
420
- const resource = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
421
- try {
422
- const body = buildPaymentRequired({
423
- payTo,
424
- amountUsd: routeAmountUsd,
425
- amount: routeAmount,
426
- network: routeOptions.network,
427
- rates: currentRates,
428
- methods: routeMethods,
429
- resource,
430
- description: routeDescription,
431
- facilitatorUrl,
432
- });
433
- return res.status(402).json(body);
434
- } catch (err) {
435
- return res.status(500).json({ error: 'Failed to build payment options', details: err.message });
436
- }
544
+ return res.status(402).json(offer);
437
545
  }
438
546
 
439
547
  // Verify the payment
440
548
  try {
549
+ // Find the advertised price for the method the payer actually used.
550
+ // Without this the facilitator has nothing to compare the proof
551
+ // against, and any proof unlocks any priced route.
552
+ const expected = expectedForProof(paymentHeader, offer, resource);
553
+ if (!expected) {
554
+ return res.status(402).json({
555
+ error: 'Invalid payment proof',
556
+ details: 'Proof does not correspond to any offered payment method',
557
+ });
558
+ }
559
+
441
560
  const result = await verifyX402Payment(paymentHeader, {
442
561
  apiKey,
443
562
  apiBaseUrl,
563
+ expected,
444
564
  });
445
565
 
446
566
  if (!result.valid) {
@@ -450,6 +570,17 @@ export function createX402Middleware(globalOptions) {
450
570
  });
451
571
  }
452
572
 
573
+ // On non-EVM rails the paid amount is self-reported until settlement
574
+ // confirms it on-chain, so serving now means serving on trust.
575
+ if (result.payment?.pendingConfirmation && !allowPendingConfirmation) {
576
+ return res.status(402).json({
577
+ error: 'Payment not confirmed',
578
+ details:
579
+ 'This payment cannot be confirmed until it settles on-chain. ' +
580
+ 'Set allowPendingConfirmation: true to serve on unconfirmed proofs.',
581
+ });
582
+ }
583
+
453
584
  // Attach payment info to request for downstream use
454
585
  req.x402Payment = result.payment;
455
586
  next();
@@ -472,10 +603,13 @@ export function createX402Middleware(globalOptions) {
472
603
  * @param {Object} options
473
604
  * @param {string} options.apiKey - CoinPayPortal API key
474
605
  * @param {string} [options.apiBaseUrl='https://coinpayportal.com'] - API base URL
606
+ * @param {{amount: string, resource: string}} options.expected - What this
607
+ * request is charging, and for what. Required: the facilitator refuses to
608
+ * verify a proof it cannot hold to a price.
475
609
  * @returns {Promise<{valid: boolean, payment?: Object, reason?: string}>}
476
610
  */
477
611
  export async function verifyX402Payment(paymentHeader, options = {}) {
478
- const { apiKey, apiBaseUrl = 'https://coinpayportal.com' } = options;
612
+ const { apiKey, apiBaseUrl = 'https://coinpayportal.com', expected } = options;
479
613
 
480
614
  if (!paymentHeader) {
481
615
  return { valid: false, reason: 'Missing payment header' };
@@ -495,6 +629,7 @@ export async function verifyX402Payment(paymentHeader, options = {}) {
495
629
  return { valid: false, reason: 'Missing scheme, signature, or payload in payment proof' };
496
630
  }
497
631
 
632
+
498
633
  // Check expiry if present
499
634
  if (payment.payload?.expiresAt) {
500
635
  const expiresAt = typeof payment.payload.expiresAt === 'number'
@@ -505,6 +640,16 @@ export async function verifyX402Payment(paymentHeader, options = {}) {
505
640
  }
506
641
  }
507
642
 
643
+ // A proof is only meaningful against a price. Without one there is nothing
644
+ // to check it covers, which is exactly how a $0.01 proof used to unlock a
645
+ // $5.00 resource.
646
+ if (!expected?.amount || !expected?.resource) {
647
+ return {
648
+ valid: false,
649
+ reason: 'Missing expected amount/resource — refusing to verify a proof with no price to check it against',
650
+ };
651
+ }
652
+
508
653
  // Call CoinPayPortal facilitator to verify
509
654
  try {
510
655
  const response = await fetch(`${apiBaseUrl}/api/x402/verify`, {
@@ -513,7 +658,7 @@ export async function verifyX402Payment(paymentHeader, options = {}) {
513
658
  'Content-Type': 'application/json',
514
659
  ...(apiKey ? { 'X-API-Key': apiKey } : {}),
515
660
  },
516
- body: JSON.stringify({ payment }),
661
+ body: JSON.stringify({ payment, expected }),
517
662
  });
518
663
 
519
664
  const data = await response.json();