@profullstack/coinpay 0.6.8 → 0.6.10

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/bin/coinpay.js CHANGED
@@ -2615,6 +2615,10 @@ async function main() {
2615
2615
  case 'reputation':
2616
2616
  await handleReputation(subcommand, args, flags);
2617
2617
  break;
2618
+
2619
+ case 'x402':
2620
+ await handleX402(subcommand, args, flags);
2621
+ break;
2618
2622
 
2619
2623
  default:
2620
2624
  print.error(`Unknown command: ${command}`);
@@ -2630,4 +2634,119 @@ async function main() {
2630
2634
  }
2631
2635
  }
2632
2636
 
2637
+ /**
2638
+ * Handle x402 commands
2639
+ */
2640
+ async function handleX402(subcommand, args, flags) {
2641
+ const config = loadConfig();
2642
+
2643
+ if (!subcommand) {
2644
+ console.log(`
2645
+ ${colors.bright}x402 Payment Protocol${colors.reset}
2646
+
2647
+ Usage: coinpay x402 <command> [options]
2648
+
2649
+ Commands:
2650
+ status Check x402 facilitator status
2651
+ test Test x402 flow against an endpoint
2652
+
2653
+ Options:
2654
+ --url <url> Endpoint URL for testing
2655
+ --network <net> Network: base, ethereum, polygon (default: base)
2656
+ --amount <amt> Amount in USDC smallest unit (default: 1000000 = 1 USDC)
2657
+ `);
2658
+ return;
2659
+ }
2660
+
2661
+ switch (subcommand) {
2662
+ case 'status': {
2663
+ const apiKey = flags['api-key'] || config.apiKey;
2664
+ const baseUrl = flags['base-url'] || config.baseUrl || 'https://coinpayportal.com';
2665
+
2666
+ print.info('Checking x402 facilitator status...');
2667
+
2668
+ try {
2669
+ const response = await fetch(`${baseUrl}/api/x402/verify`, {
2670
+ method: 'POST',
2671
+ headers: {
2672
+ 'Content-Type': 'application/json',
2673
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
2674
+ },
2675
+ body: JSON.stringify({ payment: { test: true } }),
2676
+ });
2677
+
2678
+ if (response.status === 401) {
2679
+ print.error('API key required or invalid. Set with: coinpay config set apiKey <key>');
2680
+ } else if (response.status === 400) {
2681
+ print.success('x402 facilitator is reachable and responding');
2682
+ print.info(`Facilitator URL: ${baseUrl}/api/x402`);
2683
+ print.info('Networks: Base, Ethereum, Polygon (Solana coming soon)');
2684
+ } else {
2685
+ print.warn(`Unexpected response: HTTP ${response.status}`);
2686
+ }
2687
+ } catch (err) {
2688
+ print.error(`Cannot reach facilitator at ${baseUrl}: ${err.message}`);
2689
+ }
2690
+ break;
2691
+ }
2692
+
2693
+ case 'test': {
2694
+ const url = flags.url || args[0];
2695
+ if (!url) {
2696
+ print.error('URL required: coinpay x402 test --url http://localhost:3000/api/premium');
2697
+ return;
2698
+ }
2699
+
2700
+ const network = flags.network || 'base';
2701
+ const amount = flags.amount || '1000000';
2702
+
2703
+ print.info(`Testing x402 flow against ${url}`);
2704
+
2705
+ // Step 1: Request without payment
2706
+ try {
2707
+ print.info('Step 1: Requesting resource without payment...');
2708
+ const response = await fetch(url);
2709
+
2710
+ if (response.status === 402) {
2711
+ const body = await response.json();
2712
+ print.success('Received HTTP 402 Payment Required');
2713
+
2714
+ if (body.x402Version) {
2715
+ print.success(`x402 version: ${body.x402Version}`);
2716
+ }
2717
+
2718
+ if (body.accepts && body.accepts.length > 0) {
2719
+ print.success(`${body.accepts.length} payment method(s) offered:`);
2720
+ for (const accept of body.accepts) {
2721
+ const label = accept.extra?.label || `${accept.asset} on ${accept.network}`;
2722
+ const symbol = accept.extra?.assetSymbol || accept.asset || '';
2723
+ print.info(` • ${label}`);
2724
+ print.info(` Amount: ${accept.maxAmountRequired} ${symbol}`);
2725
+ print.info(` Pay to: ${accept.payTo || 'not specified'}`);
2726
+ }
2727
+ const facilitator = body.accepts[0]?.extra?.facilitator;
2728
+ if (facilitator) {
2729
+ print.info(` Facilitator: ${facilitator}`);
2730
+ }
2731
+ }
2732
+
2733
+ print.success('x402 endpoint is correctly configured!');
2734
+ print.warn('Note: Full payment test requires a funded wallet. Use the dashboard for end-to-end testing.');
2735
+ } else if (response.status === 200) {
2736
+ print.warn('Endpoint returned 200 OK — no payment required (is x402 middleware active?)');
2737
+ } else {
2738
+ print.warn(`Unexpected status: HTTP ${response.status}`);
2739
+ }
2740
+ } catch (err) {
2741
+ print.error(`Failed to reach ${url}: ${err.message}`);
2742
+ }
2743
+ break;
2744
+ }
2745
+
2746
+ default:
2747
+ print.error(`Unknown x402 command: ${subcommand}`);
2748
+ print.info('Available: status, test');
2749
+ }
2750
+ }
2751
+
2633
2752
  main().then(() => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "CoinPay SDK & CLI — Accept cryptocurrency payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet and swap support",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/client.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * Main client class for interacting with the CoinPay API
4
4
  */
5
5
 
6
+ import { LightningClient } from './lightning.js';
7
+
6
8
  const DEFAULT_BASE_URL = 'https://coinpayportal.com/api';
7
9
 
8
10
  /**
@@ -24,6 +26,9 @@ export class CoinPayClient {
24
26
  this.#apiKey = apiKey;
25
27
  this.#baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
26
28
  this.#timeout = timeout;
29
+
30
+ /** @type {LightningClient} Lightning/BOLT12 operations */
31
+ this.lightning = new LightningClient(this);
27
32
  }
28
33
 
29
34
  /**
package/src/index.js CHANGED
@@ -147,6 +147,21 @@ import {
147
147
  SubscriptionStatus,
148
148
  } from './subscriptions.js';
149
149
 
150
+ // Lightning
151
+ import { LightningClient } from './lightning.js';
152
+
153
+ // x402 Protocol
154
+ import {
155
+ createX402Middleware,
156
+ verifyX402Payment,
157
+ settleX402Payment,
158
+ buildPaymentRequired,
159
+ convertUsdToAssetAmount,
160
+ PAYMENT_METHODS,
161
+ USDC_CONTRACTS,
162
+ CHAIN_IDS,
163
+ } from './x402.js';
164
+
150
165
  export {
151
166
  // Client
152
167
  CoinPayClient,
@@ -235,11 +250,24 @@ export {
235
250
  linkDid,
236
251
  getMyDid,
237
252
 
253
+ // Lightning
254
+ LightningClient,
255
+
238
256
  // Auth
239
257
  registerMerchant,
240
258
  loginMerchant,
241
259
  getMe,
242
260
 
261
+ // x402 Protocol
262
+ createX402Middleware,
263
+ verifyX402Payment,
264
+ settleX402Payment,
265
+ buildPaymentRequired,
266
+ convertUsdToAssetAmount,
267
+ PAYMENT_METHODS,
268
+ USDC_CONTRACTS,
269
+ CHAIN_IDS,
270
+
243
271
  // Webhook utilities
244
272
  verifyWebhookSignature,
245
273
  generateWebhookSignature,
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Lightning Module for CoinPay SDK
3
+ *
4
+ * Manages BOLT12 Lightning Network operations: node provisioning,
5
+ * offer creation, and payment tracking.
6
+ */
7
+
8
+ const DEFAULT_BASE_URL = 'https://coinpayportal.com/api';
9
+
10
+ /**
11
+ * Lightning client for BOLT12 operations
12
+ */
13
+ export class LightningClient {
14
+ #client;
15
+
16
+ /**
17
+ * @param {import('./client.js').CoinPayClient} client - Parent CoinPay client
18
+ */
19
+ constructor(client) {
20
+ this.#client = client;
21
+ }
22
+
23
+ // ──────────────────────────────────────────────
24
+ // Nodes
25
+ // ──────────────────────────────────────────────
26
+
27
+ /**
28
+ * Provision a Greenlight CLN node for a wallet.
29
+ * @param {Object} params
30
+ * @param {string} params.wallet_id - Wallet UUID
31
+ * @param {string} params.mnemonic - BIP39 mnemonic (used server-side to derive LN keys)
32
+ * @param {string} [params.business_id] - Optional business UUID
33
+ * @returns {Promise<Object>} The provisioned node
34
+ */
35
+ async provisionNode({ wallet_id, mnemonic, business_id }) {
36
+ return this.#client.request('/lightning/nodes', {
37
+ method: 'POST',
38
+ body: JSON.stringify({ wallet_id, mnemonic, business_id }),
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Get node status by ID.
44
+ * @param {string} nodeId
45
+ * @returns {Promise<Object>}
46
+ */
47
+ async getNode(nodeId) {
48
+ return this.#client.request(`/lightning/nodes/${nodeId}`);
49
+ }
50
+
51
+ /**
52
+ * Get node by wallet ID.
53
+ * @param {string} walletId - Wallet UUID
54
+ * @returns {Promise<Object>}
55
+ */
56
+ async getNodeByWallet(walletId) {
57
+ return this.#client.request(`/lightning/nodes?wallet_id=${walletId}`);
58
+ }
59
+
60
+ // ──────────────────────────────────────────────
61
+ // Offers
62
+ // ──────────────────────────────────────────────
63
+
64
+ /**
65
+ * Create a BOLT12 offer.
66
+ * @param {Object} params
67
+ * @param {string} params.node_id - Node UUID
68
+ * @param {string} params.description - Human-readable description
69
+ * @param {number} [params.amount_msat] - Fixed amount in millisatoshis (omit for any-amount)
70
+ * @param {string} [params.currency] - Currency code (default: "BTC")
71
+ * @param {string} [params.business_id] - Optional business UUID
72
+ * @returns {Promise<Object>}
73
+ */
74
+ async createOffer({ node_id, description, amount_msat, currency, business_id }) {
75
+ return this.#client.request('/lightning/offers', {
76
+ method: 'POST',
77
+ body: JSON.stringify({ node_id, description, amount_msat, currency, business_id }),
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Get offer by ID.
83
+ * @param {string} offerId
84
+ * @returns {Promise<Object>} Includes offer and qr_uri
85
+ */
86
+ async getOffer(offerId) {
87
+ return this.#client.request(`/lightning/offers/${offerId}`);
88
+ }
89
+
90
+ /**
91
+ * List offers with optional filters.
92
+ * @param {Object} [params]
93
+ * @param {string} [params.business_id]
94
+ * @param {string} [params.node_id]
95
+ * @param {string} [params.status] - "active" | "disabled" | "archived"
96
+ * @param {number} [params.limit] - Default 20
97
+ * @param {number} [params.offset] - Default 0
98
+ * @returns {Promise<Object>} { offers, total, limit, offset }
99
+ */
100
+ async listOffers(params = {}) {
101
+ const qs = new URLSearchParams();
102
+ for (const [k, v] of Object.entries(params)) {
103
+ if (v !== undefined && v !== null) qs.set(k, String(v));
104
+ }
105
+ return this.#client.request(`/lightning/offers?${qs}`);
106
+ }
107
+
108
+ // ──────────────────────────────────────────────
109
+ // Payments
110
+ // ──────────────────────────────────────────────
111
+
112
+ /**
113
+ * List Lightning payments.
114
+ * @param {Object} [params]
115
+ * @param {string} [params.business_id]
116
+ * @param {string} [params.node_id]
117
+ * @param {string} [params.offer_id]
118
+ * @param {string} [params.status] - "pending" | "settled" | "failed"
119
+ * @param {number} [params.limit] - Default 50
120
+ * @param {number} [params.offset] - Default 0
121
+ * @returns {Promise<Object>} { payments, total, limit, offset }
122
+ */
123
+ async listPayments(params = {}) {
124
+ const qs = new URLSearchParams();
125
+ for (const [k, v] of Object.entries(params)) {
126
+ if (v !== undefined && v !== null) qs.set(k, String(v));
127
+ }
128
+ return this.#client.request(`/lightning/payments?${qs}`);
129
+ }
130
+
131
+ /**
132
+ * Send a payment to a BOLT12 offer or invoice.
133
+ * @param {Object} params
134
+ * @param {string} params.node_id - Node UUID
135
+ * @param {string} params.bolt12 - BOLT12 offer or invoice string
136
+ * @param {number} params.amount_sats - Amount in satoshis
137
+ * @returns {Promise<Object>}
138
+ */
139
+ async sendPayment({ node_id, bolt12, amount_sats }) {
140
+ return this.#client.request('/lightning/payments', {
141
+ method: 'POST',
142
+ body: JSON.stringify({ node_id, bolt12, amount_sats }),
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Get payment status by payment hash.
148
+ * @param {string} paymentHash
149
+ * @returns {Promise<Object>}
150
+ */
151
+ async getPayment(paymentHash) {
152
+ return this.#client.request(`/lightning/payments/${paymentHash}`);
153
+ }
154
+ }
155
+
156
+ export default LightningClient;
package/src/x402.js ADDED
@@ -0,0 +1,585 @@
1
+ /**
2
+ * x402 Payment Protocol Support
3
+ *
4
+ * CoinPayPortal as the first multi-chain, multi-asset x402 facilitator.
5
+ * Supports native crypto (BTC, ETH, SOL, POL, BCH), USDC stablecoins,
6
+ * Lightning (BOLT12), and Stripe fiat — all via HTTP 402.
7
+ *
8
+ * @module x402
9
+ */
10
+
11
+ /**
12
+ * All supported payment methods with their network/asset metadata.
13
+ *
14
+ * Each entry defines a scheme the facilitator can advertise in the
15
+ * 402 `accepts` array, letting the buyer choose their preferred method.
16
+ */
17
+ export const PAYMENT_METHODS = {
18
+ // ── Native crypto ──────────────────────────────────────────────
19
+ btc: {
20
+ network: 'bitcoin',
21
+ asset: 'BTC',
22
+ scheme: 'exact',
23
+ decimals: 8,
24
+ label: 'Bitcoin',
25
+ },
26
+ bch: {
27
+ network: 'bitcoin-cash',
28
+ asset: 'BCH',
29
+ scheme: 'exact',
30
+ decimals: 8,
31
+ label: 'Bitcoin Cash',
32
+ },
33
+ eth: {
34
+ network: 'ethereum',
35
+ asset: 'ETH',
36
+ scheme: 'exact',
37
+ decimals: 18,
38
+ chainId: 1,
39
+ label: 'Ethereum',
40
+ },
41
+ pol: {
42
+ network: 'polygon',
43
+ asset: 'POL',
44
+ scheme: 'exact',
45
+ decimals: 18,
46
+ chainId: 137,
47
+ label: 'Polygon',
48
+ },
49
+ sol: {
50
+ network: 'solana',
51
+ asset: 'SOL',
52
+ scheme: 'exact',
53
+ decimals: 9,
54
+ label: 'Solana',
55
+ },
56
+
57
+ // ── USDC stablecoins ──────────────────────────────────────────
58
+ usdc_eth: {
59
+ network: 'ethereum',
60
+ asset: 'USDC',
61
+ scheme: 'exact',
62
+ decimals: 6,
63
+ chainId: 1,
64
+ contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
65
+ label: 'USDC on Ethereum',
66
+ },
67
+ usdc_polygon: {
68
+ network: 'polygon',
69
+ asset: 'USDC',
70
+ scheme: 'exact',
71
+ decimals: 6,
72
+ chainId: 137,
73
+ contractAddress: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
74
+ label: 'USDC on Polygon',
75
+ },
76
+ usdc_solana: {
77
+ network: 'solana',
78
+ asset: 'USDC',
79
+ scheme: 'exact',
80
+ decimals: 6,
81
+ contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
82
+ label: 'USDC on Solana',
83
+ },
84
+ usdc_base: {
85
+ network: 'base',
86
+ asset: 'USDC',
87
+ scheme: 'exact',
88
+ decimals: 6,
89
+ chainId: 8453,
90
+ contractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
91
+ label: 'USDC on Base',
92
+ },
93
+
94
+ // ── Lightning ──────────────────────────────────────────────────
95
+ lightning: {
96
+ network: 'lightning',
97
+ asset: 'BTC',
98
+ scheme: 'bolt12',
99
+ decimals: 0, // sats
100
+ label: 'Lightning (BOLT12)',
101
+ },
102
+
103
+ // ── Fiat via Stripe ────────────────────────────────────────────
104
+ stripe: {
105
+ network: 'stripe',
106
+ asset: 'USD',
107
+ scheme: 'stripe-checkout',
108
+ decimals: 2,
109
+ label: 'Card (Stripe)',
110
+ },
111
+ };
112
+
113
+ /**
114
+ * USDC contract addresses by network (convenience re-export)
115
+ */
116
+ export const USDC_CONTRACTS = {
117
+ base: PAYMENT_METHODS.usdc_base.contractAddress,
118
+ ethereum: PAYMENT_METHODS.usdc_eth.contractAddress,
119
+ polygon: PAYMENT_METHODS.usdc_polygon.contractAddress,
120
+ solana: PAYMENT_METHODS.usdc_solana.contractAddress,
121
+ };
122
+
123
+ /**
124
+ * Chain IDs for EVM networks
125
+ */
126
+ export const CHAIN_IDS = {
127
+ base: 8453,
128
+ ethereum: 1,
129
+ polygon: 137,
130
+ };
131
+
132
+ /**
133
+ * Default facilitator URL
134
+ */
135
+ const DEFAULT_FACILITATOR_URL = 'https://coinpayportal.com/api/x402';
136
+
137
+ /**
138
+ * x402 protocol version
139
+ */
140
+ const X402_VERSION = 1;
141
+
142
+ /**
143
+ * All payment method keys
144
+ */
145
+ const ALL_METHOD_KEYS = Object.keys(PAYMENT_METHODS);
146
+
147
+ /**
148
+ * Convert a fiat amount (USD cents or dollars) to the smallest unit for a
149
+ * given payment method, using a rates lookup.
150
+ *
151
+ * @param {number} amountUsd - Amount in USD (e.g. 1.00)
152
+ * @param {string} methodKey - Key from PAYMENT_METHODS
153
+ * @param {Object} rates - Map of asset→USD rate (e.g. { BTC: 65000, ETH: 3500 })
154
+ * @returns {string} Amount in the asset's smallest unit
155
+ */
156
+ export function convertUsdToAssetAmount(amountUsd, methodKey, rates = {}) {
157
+ const method = PAYMENT_METHODS[methodKey];
158
+ if (!method) throw new Error(`Unknown payment method: ${methodKey}`);
159
+
160
+ if (method.asset === 'USD') {
161
+ // Stripe: amount in cents
162
+ return String(Math.round(amountUsd * 100));
163
+ }
164
+ if (method.asset === 'USDC') {
165
+ // USDC: 6 decimals, 1:1 with USD
166
+ return String(Math.round(amountUsd * 1e6));
167
+ }
168
+
169
+ const rate = rates[method.asset];
170
+ if (!rate) throw new Error(`No exchange rate for ${method.asset}`);
171
+
172
+ const assetAmount = amountUsd / rate;
173
+ const smallest = Math.round(assetAmount * Math.pow(10, method.decimals));
174
+ return String(smallest);
175
+ }
176
+
177
+ /**
178
+ * Build a single `accepts` entry for a payment method.
179
+ */
180
+ function buildAcceptEntry(methodKey, { payTo, amount, resource, description, mimeType, maxTimeoutSeconds, facilitatorUrl }) {
181
+ const method = PAYMENT_METHODS[methodKey];
182
+ if (!method) throw new Error(`Unknown payment method: ${methodKey}`);
183
+
184
+ const entry = {
185
+ scheme: method.scheme,
186
+ network: method.network,
187
+ asset: method.contractAddress || method.asset,
188
+ maxAmountRequired: String(amount),
189
+ resource: resource || '',
190
+ description: description || 'Payment required',
191
+ mimeType: mimeType || 'application/json',
192
+ payTo,
193
+ maxTimeoutSeconds: maxTimeoutSeconds || 300,
194
+ extra: {
195
+ facilitator: facilitatorUrl || DEFAULT_FACILITATOR_URL,
196
+ methodKey,
197
+ assetSymbol: method.asset,
198
+ label: method.label,
199
+ },
200
+ };
201
+
202
+ if (method.chainId) {
203
+ entry.extra.chainId = method.chainId;
204
+ }
205
+
206
+ return entry;
207
+ }
208
+
209
+ /**
210
+ * Build a 402 response payload advertising multiple payment options.
211
+ *
212
+ * This is CoinPayPortal's key differentiator: the `accepts` array includes
213
+ * every supported chain and asset, letting the buyer choose.
214
+ *
215
+ * @param {Object} options
216
+ * @param {string} options.payTo - Merchant wallet address (or object mapping network→address)
217
+ * @param {number} options.amountUsd - Price in USD
218
+ * @param {Object} [options.rates] - Exchange rates { BTC: 65000, ETH: 3500, ... }
219
+ * @param {string[]} [options.methods] - Payment method keys to include (default: all)
220
+ * @param {string} [options.resource] - Resource URL
221
+ * @param {string} [options.description] - Human-readable description
222
+ * @param {string} [options.mimeType='application/json'] - Response MIME type
223
+ * @param {number} [options.maxTimeoutSeconds=300] - Payment timeout
224
+ * @param {string} [options.facilitatorUrl] - Custom facilitator URL
225
+ * @returns {Object} x402 payment required response body
226
+ *
227
+ * @example
228
+ * // Advertise all methods
229
+ * const body = buildPaymentRequired({
230
+ * payTo: { ethereum: '0x...', bitcoin: 'bc1...', solana: 'So1...' },
231
+ * amountUsd: 5.00,
232
+ * rates: { BTC: 65000, ETH: 3500, SOL: 150, POL: 0.50, BCH: 350 },
233
+ * });
234
+ *
235
+ * @example
236
+ * // Only accept USDC and Lightning
237
+ * const body = buildPaymentRequired({
238
+ * payTo: '0xMyWallet',
239
+ * amountUsd: 1.00,
240
+ * methods: ['usdc_eth', 'usdc_polygon', 'usdc_base', 'lightning'],
241
+ * });
242
+ */
243
+ export function buildPaymentRequired(options) {
244
+ const {
245
+ payTo,
246
+ amountUsd,
247
+ amount, // legacy: raw amount for a single method
248
+ network, // legacy: single network
249
+ rates = {},
250
+ methods,
251
+ resource,
252
+ description = 'Payment required',
253
+ mimeType = 'application/json',
254
+ maxTimeoutSeconds = 300,
255
+ facilitatorUrl = DEFAULT_FACILITATOR_URL,
256
+ } = options;
257
+
258
+ // Legacy single-method mode (backwards compatible)
259
+ if (network && amount && !methods) {
260
+ const methodKey = _networkToMethodKey(network);
261
+ const addr = typeof payTo === 'object' ? (payTo[network] || Object.values(payTo)[0]) : payTo;
262
+ return {
263
+ x402Version: X402_VERSION,
264
+ accepts: [buildAcceptEntry(methodKey, {
265
+ payTo: addr, amount, resource, description, mimeType, maxTimeoutSeconds, facilitatorUrl,
266
+ })],
267
+ };
268
+ }
269
+
270
+ // Multi-method mode
271
+ const methodKeys = methods || ALL_METHOD_KEYS;
272
+ const accepts = [];
273
+
274
+ for (const key of methodKeys) {
275
+ const method = PAYMENT_METHODS[key];
276
+ if (!method) continue;
277
+
278
+ // Resolve pay-to address for this network
279
+ let addr;
280
+ if (typeof payTo === 'object') {
281
+ addr = payTo[method.network] || payTo[key];
282
+ } else {
283
+ addr = payTo;
284
+ }
285
+ if (!addr) continue; // skip methods where merchant has no address
286
+
287
+ // Convert USD to asset amount
288
+ let assetAmount;
289
+ try {
290
+ if (amountUsd != null) {
291
+ assetAmount = convertUsdToAssetAmount(amountUsd, key, rates);
292
+ } else if (amount) {
293
+ assetAmount = String(amount);
294
+ } else {
295
+ continue;
296
+ }
297
+ } catch {
298
+ // No rate available for this asset — skip it
299
+ continue;
300
+ }
301
+
302
+ accepts.push(buildAcceptEntry(key, {
303
+ payTo: addr, amount: assetAmount, resource, description, mimeType, maxTimeoutSeconds, facilitatorUrl,
304
+ }));
305
+ }
306
+
307
+ if (accepts.length === 0) {
308
+ throw new Error('No payment methods could be built. Check payTo addresses and rates.');
309
+ }
310
+
311
+ return {
312
+ x402Version: X402_VERSION,
313
+ accepts,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Map a simple network name to the best-guess method key (legacy compat).
319
+ */
320
+ function _networkToMethodKey(network) {
321
+ const map = {
322
+ base: 'usdc_base',
323
+ ethereum: 'eth',
324
+ polygon: 'pol',
325
+ solana: 'sol',
326
+ bitcoin: 'btc',
327
+ 'bitcoin-cash': 'bch',
328
+ lightning: 'lightning',
329
+ stripe: 'stripe',
330
+ };
331
+ return map[network] || network;
332
+ }
333
+
334
+ /**
335
+ * Create Express/Next.js middleware that gates routes behind x402 payments.
336
+ *
337
+ * Returns a function that accepts per-route options and returns middleware.
338
+ * The 402 response advertises ALL supported payment methods by default.
339
+ *
340
+ * @param {Object} globalOptions
341
+ * @param {string} globalOptions.apiKey - CoinPayPortal API key
342
+ * @param {string|Object} globalOptions.payTo - Wallet address(es): string or { network: address }
343
+ * @param {string[]} [globalOptions.methods] - Payment methods to accept (default: all with addresses)
344
+ * @param {Object} [globalOptions.rates] - Exchange rates { BTC: 65000, ... }
345
+ * @param {string} [globalOptions.ratesEndpoint] - URL to fetch live rates (polled periodically)
346
+ * @param {string} [globalOptions.description='Payment required'] - Default description
347
+ * @param {string} [globalOptions.facilitatorUrl] - Custom facilitator URL
348
+ * @param {string} [globalOptions.apiBaseUrl='https://coinpayportal.com'] - CoinPayPortal API base
349
+ * @returns {Function} Middleware factory: (routeOptions) => middleware
350
+ *
351
+ * @example
352
+ * const x402 = createX402Middleware({
353
+ * apiKey: 'cp_live_xxxxx',
354
+ * payTo: {
355
+ * ethereum: '0xAbC...',
356
+ * bitcoin: 'bc1q...',
357
+ * solana: 'So1...',
358
+ * lightning: 'lno1...',
359
+ * stripe: 'acct_xxx',
360
+ * },
361
+ * rates: { BTC: 65000, ETH: 3500, SOL: 150, POL: 0.50, BCH: 350 },
362
+ * });
363
+ *
364
+ * // Charge $5 for premium access — buyer picks their chain
365
+ * app.get('/premium', x402({ amountUsd: 5.00 }), handler);
366
+ */
367
+ export function createX402Middleware(globalOptions) {
368
+ const {
369
+ apiKey,
370
+ payTo,
371
+ methods,
372
+ rates = {},
373
+ description = 'Payment required',
374
+ facilitatorUrl = DEFAULT_FACILITATOR_URL,
375
+ apiBaseUrl = 'https://coinpayportal.com',
376
+ } = globalOptions;
377
+
378
+ if (!apiKey) throw new Error('x402 middleware requires an apiKey');
379
+ if (!payTo) throw new Error('x402 middleware requires a payTo address');
380
+
381
+ // Mutable rates cache — can be updated externally or via ratesEndpoint
382
+ let currentRates = { ...rates };
383
+
384
+ // If ratesEndpoint provided, poll for fresh rates
385
+ if (globalOptions.ratesEndpoint) {
386
+ const fetchRates = async () => {
387
+ try {
388
+ const res = await fetch(globalOptions.ratesEndpoint);
389
+ if (res.ok) currentRates = await res.json();
390
+ } catch { /* ignore */ }
391
+ };
392
+ fetchRates();
393
+ setInterval(fetchRates, 60_000); // refresh every 60s
394
+ }
395
+
396
+ /**
397
+ * Route-level middleware factory.
398
+ *
399
+ * @param {Object} routeOptions
400
+ * @param {number} [routeOptions.amountUsd] - Price in USD (preferred)
401
+ * @param {string} [routeOptions.amount] - Raw amount (legacy, single-method)
402
+ * @param {string} [routeOptions.network] - Single network (legacy)
403
+ * @param {string[]} [routeOptions.methods] - Override accepted methods for this route
404
+ * @param {string} [routeOptions.description] - Route-specific description
405
+ */
406
+ return function x402Route(routeOptions = {}) {
407
+ const routeAmountUsd = routeOptions.amountUsd;
408
+ const routeAmount = routeOptions.amount;
409
+ const routeDescription = routeOptions.description || description;
410
+ const routeMethods = routeOptions.methods || methods;
411
+
412
+ if (!routeAmountUsd && !routeAmount) {
413
+ throw new Error('x402 route requires amountUsd or amount');
414
+ }
415
+
416
+ return async function x402Middleware(req, res, next) {
417
+ const paymentHeader = req.headers['x-payment'] || req.headers['X-Payment'];
418
+
419
+ 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
+ }
437
+ }
438
+
439
+ // Verify the payment
440
+ try {
441
+ const result = await verifyX402Payment(paymentHeader, {
442
+ apiKey,
443
+ apiBaseUrl,
444
+ });
445
+
446
+ if (!result.valid) {
447
+ return res.status(402).json({
448
+ error: 'Invalid payment proof',
449
+ details: result.reason,
450
+ });
451
+ }
452
+
453
+ // Attach payment info to request for downstream use
454
+ req.x402Payment = result.payment;
455
+ next();
456
+ } catch (err) {
457
+ return res.status(500).json({ error: 'Payment verification failed', details: err.message });
458
+ }
459
+ };
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Verify an x402 payment proof.
465
+ *
466
+ * Calls CoinPayPortal's facilitator API to validate the cryptographic
467
+ * signature and payment details in the X-PAYMENT header.
468
+ * Supports all payment schemes: EVM signatures, BTC/BCH tx proofs,
469
+ * Solana tx proofs, Lightning preimages, and Stripe payment intents.
470
+ *
471
+ * @param {string} paymentHeader - The X-PAYMENT header value (base64-encoded JSON)
472
+ * @param {Object} options
473
+ * @param {string} options.apiKey - CoinPayPortal API key
474
+ * @param {string} [options.apiBaseUrl='https://coinpayportal.com'] - API base URL
475
+ * @returns {Promise<{valid: boolean, payment?: Object, reason?: string}>}
476
+ */
477
+ export async function verifyX402Payment(paymentHeader, options = {}) {
478
+ const { apiKey, apiBaseUrl = 'https://coinpayportal.com' } = options;
479
+
480
+ if (!paymentHeader) {
481
+ return { valid: false, reason: 'Missing payment header' };
482
+ }
483
+
484
+ // Decode the payment header
485
+ let payment;
486
+ try {
487
+ const decoded = Buffer.from(paymentHeader, 'base64').toString('utf-8');
488
+ payment = JSON.parse(decoded);
489
+ } catch {
490
+ return { valid: false, reason: 'Invalid payment header encoding' };
491
+ }
492
+
493
+ // Validate required fields
494
+ if (!payment.scheme && !payment.signature && !payment.payload) {
495
+ return { valid: false, reason: 'Missing scheme, signature, or payload in payment proof' };
496
+ }
497
+
498
+ // Check expiry if present
499
+ if (payment.payload?.expiresAt) {
500
+ const expiresAt = typeof payment.payload.expiresAt === 'number'
501
+ ? new Date(payment.payload.expiresAt * 1000)
502
+ : new Date(payment.payload.expiresAt);
503
+ if (expiresAt < new Date()) {
504
+ return { valid: false, reason: 'Payment proof has expired' };
505
+ }
506
+ }
507
+
508
+ // Call CoinPayPortal facilitator to verify
509
+ try {
510
+ const response = await fetch(`${apiBaseUrl}/api/x402/verify`, {
511
+ method: 'POST',
512
+ headers: {
513
+ 'Content-Type': 'application/json',
514
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
515
+ },
516
+ body: JSON.stringify({ payment }),
517
+ });
518
+
519
+ const data = await response.json();
520
+
521
+ if (!response.ok) {
522
+ return { valid: false, reason: data.error || 'Verification failed' };
523
+ }
524
+
525
+ return { valid: true, payment: data.payment };
526
+ } catch (err) {
527
+ return { valid: false, reason: `Facilitator error: ${err.message}` };
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Settle an x402 payment on-chain (or via the appropriate payment rail).
533
+ *
534
+ * For crypto: claims the payment on-chain.
535
+ * For Lightning: confirms the preimage.
536
+ * For Stripe: captures the payment intent.
537
+ *
538
+ * @param {string} paymentHeader - The X-PAYMENT header value (base64-encoded JSON)
539
+ * @param {Object} options
540
+ * @param {string} options.apiKey - CoinPayPortal API key
541
+ * @param {string} [options.apiBaseUrl='https://coinpayportal.com'] - API base URL
542
+ * @returns {Promise<{settled: boolean, txHash?: string, network?: string, error?: string}>}
543
+ */
544
+ export async function settleX402Payment(paymentHeader, options = {}) {
545
+ const { apiKey, apiBaseUrl = 'https://coinpayportal.com' } = options;
546
+
547
+ if (!paymentHeader) {
548
+ return { settled: false, error: 'Missing payment header' };
549
+ }
550
+
551
+ let payment;
552
+ try {
553
+ const decoded = Buffer.from(paymentHeader, 'base64').toString('utf-8');
554
+ payment = JSON.parse(decoded);
555
+ } catch {
556
+ return { settled: false, error: 'Invalid payment header encoding' };
557
+ }
558
+
559
+ try {
560
+ const response = await fetch(`${apiBaseUrl}/api/x402/settle`, {
561
+ method: 'POST',
562
+ headers: {
563
+ 'Content-Type': 'application/json',
564
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
565
+ },
566
+ body: JSON.stringify({ payment }),
567
+ });
568
+
569
+ const data = await response.json();
570
+
571
+ if (!response.ok) {
572
+ return { settled: false, error: data.error || 'Settlement failed' };
573
+ }
574
+
575
+ return {
576
+ settled: true,
577
+ txHash: data.txHash,
578
+ network: data.network,
579
+ asset: data.asset,
580
+ method: data.method,
581
+ };
582
+ } catch (err) {
583
+ return { settled: false, error: `Settlement error: ${err.message}` };
584
+ }
585
+ }