@profullstack/coinpay 0.6.8 → 0.6.11
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/bin/coinpay.js +381 -0
- package/package.json +1 -1
- package/src/auth.js +6 -0
- package/src/client.js +90 -0
- package/src/index.js +28 -0
- package/src/lightning.js +218 -0
- package/src/x402.js +585 -0
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
|
+
}
|