@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/CHANGELOG.md +7 -0
- package/README.md +64 -0
- package/bin/coinpay.js +1208 -59
- package/package.json +20 -3
- package/src/auth.js +8 -1
- package/src/client.d.ts +52 -0
- package/src/client.js +138 -7
- package/src/index.d.ts +34 -1
- package/src/index.js +32 -0
- package/src/invoices.d.ts +77 -0
- package/src/invoices.js +237 -0
- package/src/lightning.js +101 -39
- package/src/tokens.d.ts +14 -0
- package/src/tokens.js +34 -0
- package/src/wallet.d.ts +11 -0
- package/src/wallet.js +229 -1
- package/src/webhooks.js +37 -3
- package/src/x402-browser.js +318 -0
- package/src/x402-pay-server.js +272 -0
- package/src/x402-v2.js +416 -0
- package/src/x402.js +164 -19
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
|
-
|
|
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();
|