openpay-x402-sdk 0.4.0 → 0.5.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/executor.mjs CHANGED
@@ -7,9 +7,18 @@ import {
7
7
  } from './payment.mjs';
8
8
  import {
9
9
  evaluatePaymentGuards,
10
+ REASONS,
10
11
  recordSuccessfulPayment,
11
12
  safeErrorMessage,
12
13
  } from './guards.mjs';
14
+ import {
15
+ DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
16
+ fetchPaymentTarget,
17
+ parseSafePaymentUrl,
18
+ } from './network.mjs';
19
+ import { verifyBoundPaymentResponse } from './receipt.mjs';
20
+
21
+ const sessionReservations = new WeakMap();
13
22
 
14
23
  function isObject(value) {
15
24
  return typeof value === 'object' && value !== null;
@@ -47,10 +56,110 @@ function quoteShape(url, status, guard) {
47
56
  };
48
57
  }
49
58
 
59
+ function rejectedQuote(url, status, guard, reason) {
60
+ return quoteShape(url, status, {
61
+ ...guard,
62
+ ok: false,
63
+ reasons: [...new Set([...guard.reasons, reason])],
64
+ });
65
+ }
66
+
50
67
  function sanitizedError(error, config) {
51
68
  return new Error(safeErrorMessage(error, config));
52
69
  }
53
70
 
71
+ function reservationLedger(session) {
72
+ let ledger = sessionReservations.get(session);
73
+ if (ledger === undefined) {
74
+ ledger = new Map();
75
+ sessionReservations.set(session, ledger);
76
+ }
77
+ return ledger;
78
+ }
79
+
80
+ function sessionPendingAtomic(session) {
81
+ let total = 0n;
82
+ for (const reservation of reservationLedger(session).values()) {
83
+ total += reservation.amountAtomic;
84
+ }
85
+ return total;
86
+ }
87
+
88
+ function reserveSession(session, reservation, maxSessionAtomic) {
89
+ const ledger = reservationLedger(session);
90
+ const existing = ledger.get(reservation.id);
91
+ if (existing !== undefined) {
92
+ return (
93
+ existing.amountAtomic === reservation.amountAtomic &&
94
+ existing.validBefore === reservation.validBefore
95
+ );
96
+ }
97
+ if (
98
+ session.spentAtomic +
99
+ sessionPendingAtomic(session) +
100
+ reservation.amountAtomic >
101
+ maxSessionAtomic
102
+ ) {
103
+ return false;
104
+ }
105
+ ledger.set(reservation.id, reservation);
106
+ return true;
107
+ }
108
+
109
+ function cancelUnsentSessionReservation(session, id) {
110
+ reservationLedger(session).delete(id);
111
+ }
112
+
113
+ function confirmSessionReservation(session, id, amountAtomic) {
114
+ recordSuccessfulPayment(session, amountAtomic);
115
+ reservationLedger(session).delete(id);
116
+ }
117
+
118
+ function catalogAcceptForUrl(parsedUrl, catalogListings) {
119
+ if (!(catalogListings instanceof Map)) return undefined;
120
+ return catalogListings.get(parsedUrl.toString());
121
+ }
122
+
123
+ function utcDate(value) {
124
+ const instant = value instanceof Date ? value : new Date(value);
125
+ const timestamp = instant.getTime();
126
+ if (!Number.isFinite(timestamp)) return null;
127
+ return instant.toISOString().slice(0, 10);
128
+ }
129
+
130
+ async function safeReceipt(
131
+ raw,
132
+ reservation,
133
+ normalizedAccept,
134
+ resolveReceiptSigner,
135
+ ) {
136
+ try {
137
+ const decoded = decodePaymentResponse(raw);
138
+ if (decoded === null) return null;
139
+ const expectedSigner = await resolveReceiptSigner();
140
+ if (
141
+ expectedSigner === null ||
142
+ !(await verifyBoundPaymentResponse(decoded, {
143
+ expectedSigner,
144
+ payer: reservation.payer,
145
+ network: reservation.network,
146
+ asset: reservation.asset,
147
+ chainId: normalizedAccept.chainId,
148
+ merchant: normalizedAccept.extra.openpay.merchant,
149
+ merchantValue: normalizedAccept.extra.openpay.merchantValue,
150
+ feeValue: normalizedAccept.extra.openpay.feeValue,
151
+ nonce: reservation.id,
152
+ }))
153
+ ) {
154
+ return null;
155
+ }
156
+ return decoded;
157
+ } catch {
158
+ // A seller-controlled receipt header must not erase an unlocked body and invite a duplicate payment.
159
+ return null;
160
+ }
161
+ }
162
+
54
163
  export function createPaymentExecutor({
55
164
  config,
56
165
  session,
@@ -58,28 +167,36 @@ export function createPaymentExecutor({
58
167
  signerAddress = signer?.address ?? null,
59
168
  spendStore = null,
60
169
  fetchImpl = fetch,
170
+ lookup,
171
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
61
172
  nowSec = () => Math.floor(Date.now() / 1000),
62
173
  now = () => new Date(),
63
174
  resolveCatalogListings = async () => null,
175
+ resolveReceiptSigner = async () => null,
64
176
  }) {
65
177
  const dailyLimitEnabled =
66
- config.maxDailyAtomic !== null &&
67
- config.maxDailyAtomic !== undefined &&
68
- spendStore !== null;
69
- const guardConfig =
70
- !dailyLimitEnabled && config.maxDailyAtomic != null
71
- ? { ...config, maxDailyAtomic: null }
72
- : config;
178
+ config.maxDailyAtomic !== null && config.maxDailyAtomic !== undefined;
179
+ if (dailyLimitEnabled && spendStore === null) {
180
+ throw new Error(
181
+ 'spendStore is required when maxDailyAtomic is configured',
182
+ );
183
+ }
184
+
185
+ function currentSessionExposure() {
186
+ return session.spentAtomic + sessionPendingAtomic(session);
187
+ }
188
+
189
+ function currentDailyKey() {
190
+ if (!dailyLimitEnabled || typeof signerAddress !== 'string') return null;
191
+ const date = utcDate(now());
192
+ return date === null
193
+ ? null
194
+ : `${signerAddress.toLowerCase()}:${date}`;
195
+ }
73
196
 
74
197
  async function loadDailySpend() {
75
- if (!dailyLimitEnabled || typeof signerAddress !== 'string') {
76
- return { key: null, spentAtomic: null };
77
- }
78
- const current = now();
79
- const date = (current instanceof Date ? current : new Date(current))
80
- .toISOString()
81
- .slice(0, 10);
82
- const key = `${signerAddress.toLowerCase()}:${date}`;
198
+ const key = currentDailyKey();
199
+ if (key === null) return { key: null, spentAtomic: null };
83
200
  try {
84
201
  const stored = await spendStore.load(key);
85
202
  if (typeof stored !== 'string' || !/^[0-9]+$/.test(stored)) {
@@ -91,22 +208,160 @@ export function createPaymentExecutor({
91
208
  }
92
209
  }
93
210
 
94
- async function saveDailySpend(dailySpend, amountAtomic) {
95
- if (dailySpend.key === null || dailySpend.spentAtomic === null) return;
211
+ async function reserveDailySpend(dailySpend, amountAtomic, reservation) {
212
+ if (dailySpend.key === null || dailySpend.spentAtomic === null) {
213
+ return { ok: false, reason: 'unavailable' };
214
+ }
215
+ if (typeof spendStore.reserve === 'function') {
216
+ try {
217
+ const result = await spendStore.reserve(
218
+ dailySpend.key,
219
+ amountAtomic.toString(),
220
+ config.maxDailyAtomic.toString(),
221
+ reservation,
222
+ );
223
+ if (
224
+ !isObject(result) ||
225
+ (result.ok !== true && result.ok !== false)
226
+ ) {
227
+ return { ok: false, reason: 'unavailable' };
228
+ }
229
+ if (result.ok === false) {
230
+ return result.reason === 'limit_exceeded'
231
+ ? result
232
+ : { ok: false, reason: 'unavailable' };
233
+ }
234
+ if (
235
+ typeof result.totalAtomic !== 'string' ||
236
+ !/^[0-9]+$/.test(result.totalAtomic)
237
+ ) {
238
+ return { ok: false, reason: 'unavailable' };
239
+ }
240
+ const totalAtomic = BigInt(result.totalAtomic);
241
+ if (
242
+ totalAtomic < dailySpend.spentAtomic + amountAtomic ||
243
+ totalAtomic > config.maxDailyAtomic
244
+ ) {
245
+ return { ok: false, reason: 'unavailable' };
246
+ }
247
+ return result;
248
+ } catch {
249
+ return { ok: false, reason: 'unavailable' };
250
+ }
251
+ }
252
+
253
+ // Legacy custom stores remain callable, but a write is verified before exposing the signature.
96
254
  try {
97
- await spendStore.save(
98
- dailySpend.key,
99
- (dailySpend.spentAtomic + amountAtomic).toString(),
100
- );
255
+ const fresh = await spendStore.load(dailySpend.key);
256
+ if (typeof fresh !== 'string' || !/^[0-9]+$/.test(fresh)) {
257
+ return { ok: false, reason: 'unavailable' };
258
+ }
259
+ const next = BigInt(fresh) + amountAtomic;
260
+ if (next > config.maxDailyAtomic) {
261
+ return {
262
+ ok: false,
263
+ reason: 'limit_exceeded',
264
+ totalAtomic: fresh,
265
+ };
266
+ }
267
+ await spendStore.save(dailySpend.key, next.toString());
268
+ const persisted = await spendStore.load(dailySpend.key);
269
+ if (
270
+ typeof persisted !== 'string' ||
271
+ !/^[0-9]+$/.test(persisted) ||
272
+ BigInt(persisted) < next
273
+ ) {
274
+ return { ok: false, reason: 'unavailable' };
275
+ }
276
+ return { ok: true, totalAtomic: persisted };
101
277
  } catch {
102
- // The unlock already succeeded; store failure must not replace the payment response.
278
+ return { ok: false, reason: 'unavailable' };
103
279
  }
104
280
  }
105
281
 
282
+ async function confirmDailyReservation(id) {
283
+ if (typeof spendStore?.confirm !== 'function') return;
284
+ try {
285
+ await spendStore.confirm(id);
286
+ } catch {
287
+ // Confirmation metadata is ancillary; its failure must not replace a completed unlock response.
288
+ }
289
+ }
290
+
291
+ async function targetAccess(url) {
292
+ const parsedUrl = parseSafePaymentUrl(url);
293
+ if (parsedUrl === null) {
294
+ return {
295
+ ok: false,
296
+ parsedUrl: null,
297
+ catalogListings: null,
298
+ reasons: [REASONS.invalidUrl],
299
+ };
300
+ }
301
+ const hostAllowed = config.allowedHosts.includes(
302
+ parsedUrl.hostname.toLowerCase(),
303
+ );
304
+ let catalogListings = null;
305
+ let catalogListed = false;
306
+ if (!hostAllowed && config.catalogTrust) {
307
+ catalogListings = await resolveCatalogListings();
308
+ catalogListed =
309
+ catalogAcceptForUrl(parsedUrl, catalogListings) !== undefined;
310
+ }
311
+ const reasons = [];
312
+ if (!hostAllowed && !catalogListed) reasons.push(REASONS.hostNotAllowed);
313
+ if (parsedUrl.protocol !== 'https:') {
314
+ reasons.push(REASONS.unsupportedScheme);
315
+ }
316
+ return {
317
+ ok: reasons.length === 0,
318
+ parsedUrl,
319
+ catalogListings,
320
+ reasons,
321
+ };
322
+ }
323
+
324
+ function fetchTarget(url, headers) {
325
+ return fetchPaymentTarget(url, {
326
+ fetchImpl,
327
+ headers,
328
+ lookup,
329
+ timeoutMs: requestTimeoutMs,
330
+ });
331
+ }
332
+
333
+ async function withForwarderCatalogFallback(guardInput, catalogListings) {
334
+ let guard = evaluatePaymentGuards({
335
+ ...guardInput,
336
+ catalogListings,
337
+ });
338
+ if (
339
+ catalogListings === null &&
340
+ config.catalogTrust &&
341
+ guard.reasons.includes(REASONS.invalidOpenpayForwarder)
342
+ ) {
343
+ const resolved = await resolveCatalogListings();
344
+ guard = evaluatePaymentGuards({
345
+ ...guardInput,
346
+ catalogListings: resolved,
347
+ });
348
+ return { guard, catalogListings: resolved };
349
+ }
350
+ return { guard, catalogListings };
351
+ }
352
+
106
353
  async function quoteImpl(url) {
107
354
  if (typeof url !== 'string') throw new Error('url is required');
108
- const response = await fetchImpl(url, {
109
- headers: { accept: 'application/json' },
355
+ const access = await targetAccess(url);
356
+ if (!access.ok) {
357
+ return quoteShape(url, 0, {
358
+ ok: false,
359
+ reasons: access.reasons,
360
+ summary: null,
361
+ });
362
+ }
363
+ const response = await fetchTarget(url, {
364
+ accept: 'application/json',
110
365
  });
111
366
  const body = await readJson(response);
112
367
  const accept = firstAccept(body);
@@ -119,14 +374,16 @@ export function createPaymentExecutor({
119
374
  };
120
375
  }
121
376
  const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
122
- const guard = evaluatePaymentGuards({
123
- url,
124
- accept,
125
- config: guardConfig,
126
- sessionSpentAtomic: session.spentAtomic,
127
- dailySpentAtomic: dailySpend?.spentAtomic ?? null,
128
- catalogListings: await resolveCatalogListings(),
129
- });
377
+ const { guard } = await withForwarderCatalogFallback(
378
+ {
379
+ url,
380
+ accept,
381
+ config,
382
+ sessionSpentAtomic: currentSessionExposure(),
383
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
384
+ },
385
+ access.catalogListings,
386
+ );
130
387
  return quoteShape(url, response.status, guard);
131
388
  }
132
389
 
@@ -140,8 +397,16 @@ export function createPaymentExecutor({
140
397
 
141
398
  async function payImpl(url, { maxTotalJpyc } = {}) {
142
399
  if (typeof url !== 'string') throw new Error('url is required');
143
- const response = await fetchImpl(url, {
144
- headers: { accept: 'application/json' },
400
+ const access = await targetAccess(url);
401
+ if (!access.ok) {
402
+ return quoteShape(url, 0, {
403
+ ok: false,
404
+ reasons: access.reasons,
405
+ summary: null,
406
+ });
407
+ }
408
+ const response = await fetchTarget(url, {
409
+ accept: 'application/json',
145
410
  });
146
411
  const body = await readJson(response);
147
412
  const accept = firstAccept(body);
@@ -153,19 +418,23 @@ export function createPaymentExecutor({
153
418
  reasons: ['expected_402_with_accepts'],
154
419
  };
155
420
  }
156
- const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
157
- const guard = evaluatePaymentGuards({
158
- url,
159
- accept,
160
- config: guardConfig,
161
- sessionSpentAtomic: session.spentAtomic,
162
- dailySpentAtomic: dailySpend?.spentAtomic ?? null,
163
- maxTotalJpyc,
164
- requireMaxTotal: true,
165
- requireSigner: true,
166
- signerAvailable: signer !== null,
167
- catalogListings: await resolveCatalogListings(),
168
- });
421
+ let dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
422
+ const initialGuard = await withForwarderCatalogFallback(
423
+ {
424
+ url,
425
+ accept,
426
+ config,
427
+ sessionSpentAtomic: currentSessionExposure(),
428
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
429
+ maxTotalJpyc,
430
+ requireMaxTotal: true,
431
+ requireSigner: true,
432
+ signerAvailable: signer !== null,
433
+ },
434
+ access.catalogListings,
435
+ );
436
+ let guard = initialGuard.guard;
437
+ const catalogListings = initialGuard.catalogListings;
169
438
  if (!guard.ok) return quoteShape(url, response.status, guard);
170
439
 
171
440
  const authorization = createAuthorization(
@@ -181,21 +450,94 @@ export function createPaymentExecutor({
181
450
  authorization,
182
451
  signature,
183
452
  );
184
- const unlocked = await fetchImpl(url, {
185
- headers: {
186
- accept: 'application/json',
187
- 'X-PAYMENT': encodePaymentPayload(paymentPayload),
188
- },
453
+ const reservation = {
454
+ id: typedData.message.nonce,
455
+ amountAtomic: guard.summary.totalAtomic,
456
+ payer: typedData.message.from,
457
+ network: normalizedAccept.network,
458
+ asset: normalizedAccept.asset,
459
+ validBefore: authorization.validBefore,
460
+ };
461
+
462
+ // Re-read all mutable counters after the last pre-sign await. A UTC rollover or
463
+ // another process reservation must be visible before this authorization leaves.
464
+ dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
465
+ guard = evaluatePaymentGuards({
466
+ url,
467
+ accept,
468
+ config,
469
+ sessionSpentAtomic: currentSessionExposure(),
470
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
471
+ maxTotalJpyc,
472
+ requireMaxTotal: true,
473
+ requireSigner: true,
474
+ signerAvailable: signer !== null,
475
+ catalogListings,
476
+ });
477
+ if (!guard.ok) return quoteShape(url, response.status, guard);
478
+
479
+ if (
480
+ dailyLimitEnabled &&
481
+ (dailySpend?.key === null ||
482
+ utcDate(Number(authorization.validBefore) * 1000) !==
483
+ dailySpend.key.slice(-10))
484
+ ) {
485
+ return rejectedQuote(
486
+ url,
487
+ response.status,
488
+ guard,
489
+ REASONS.dailyAuthorizationCrossesUtcDay,
490
+ );
491
+ }
492
+
493
+ if (!reserveSession(session, reservation, config.maxSessionAtomic)) {
494
+ return rejectedQuote(
495
+ url,
496
+ response.status,
497
+ guard,
498
+ REASONS.sessionLimitExceeded,
499
+ );
500
+ }
501
+ if (dailyLimitEnabled) {
502
+ const reserved = await reserveDailySpend(
503
+ dailySpend,
504
+ guard.summary.totalAtomic,
505
+ reservation,
506
+ );
507
+ if (!reserved.ok) {
508
+ // This signature has not crossed a process boundary, so only this unsent reservation is safe to release.
509
+ cancelUnsentSessionReservation(session, reservation.id);
510
+ return rejectedQuote(
511
+ url,
512
+ response.status,
513
+ guard,
514
+ reserved.reason === 'limit_exceeded'
515
+ ? REASONS.dailyLimitExceeded
516
+ : REASONS.dailySpendUnavailable,
517
+ );
518
+ }
519
+ }
520
+
521
+ const unlocked = await fetchTarget(url, {
522
+ accept: 'application/json',
523
+ 'X-PAYMENT': encodePaymentPayload(paymentPayload),
189
524
  });
190
525
  const unlockedBody = await readJson(unlocked);
191
526
  if (unlocked.status >= 200 && unlocked.status < 300) {
192
- recordSuccessfulPayment(session, guard.summary.totalAtomic);
193
- if (dailySpend !== null) {
194
- await saveDailySpend(dailySpend, guard.summary.totalAtomic);
527
+ confirmSessionReservation(
528
+ session,
529
+ reservation.id,
530
+ guard.summary.totalAtomic,
531
+ );
532
+ if (dailyLimitEnabled) {
533
+ await confirmDailyReservation(reservation.id);
195
534
  }
196
535
  }
197
- const receipt = decodePaymentResponse(
536
+ const receipt = await safeReceipt(
198
537
  unlocked.headers.get('x-payment-response'),
538
+ reservation,
539
+ normalizedAccept,
540
+ resolveReceiptSigner,
199
541
  );
200
542
  return {
201
543
  status: unlocked.status,
@@ -204,7 +546,7 @@ export function createPaymentExecutor({
204
546
  };
205
547
  }
206
548
 
207
- // Serialize the full read → sign → unlock → record path so concurrent calls cannot
549
+ // Serialize the full read → sign → reserve → unlock → record path so concurrent calls cannot
208
550
  // observe the same pre-payment session total and exceed the cumulative cap.
209
551
  let payChain = Promise.resolve();
210
552
  async function pay(url, options) {