@profullstack/coinpay 0.6.10 → 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.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();