openpay-x402-sdk 0.3.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,22 +56,312 @@ 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,
57
166
  signer = null,
167
+ signerAddress = signer?.address ?? null,
168
+ spendStore = null,
58
169
  fetchImpl = fetch,
170
+ lookup,
171
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
59
172
  nowSec = () => Math.floor(Date.now() / 1000),
173
+ now = () => new Date(),
60
174
  resolveCatalogListings = async () => null,
175
+ resolveReceiptSigner = async () => null,
61
176
  }) {
177
+ const dailyLimitEnabled =
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
+ }
196
+
197
+ async function loadDailySpend() {
198
+ const key = currentDailyKey();
199
+ if (key === null) return { key: null, spentAtomic: null };
200
+ try {
201
+ const stored = await spendStore.load(key);
202
+ if (typeof stored !== 'string' || !/^[0-9]+$/.test(stored)) {
203
+ return { key, spentAtomic: null };
204
+ }
205
+ return { key, spentAtomic: BigInt(stored) };
206
+ } catch {
207
+ return { key, spentAtomic: null };
208
+ }
209
+ }
210
+
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.
254
+ try {
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 };
277
+ } catch {
278
+ return { ok: false, reason: 'unavailable' };
279
+ }
280
+ }
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
+
62
353
  async function quoteImpl(url) {
63
354
  if (typeof url !== 'string') throw new Error('url is required');
64
- const response = await fetchImpl(url, {
65
- 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',
66
365
  });
67
366
  const body = await readJson(response);
68
367
  const accept = firstAccept(body);
@@ -74,13 +373,17 @@ export function createPaymentExecutor({
74
373
  reasons: ['expected_402_with_accepts'],
75
374
  };
76
375
  }
77
- const guard = evaluatePaymentGuards({
78
- url,
79
- accept,
80
- config,
81
- sessionSpentAtomic: session.spentAtomic,
82
- catalogListings: await resolveCatalogListings(),
83
- });
376
+ const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
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
+ );
84
387
  return quoteShape(url, response.status, guard);
85
388
  }
86
389
 
@@ -94,8 +397,16 @@ export function createPaymentExecutor({
94
397
 
95
398
  async function payImpl(url, { maxTotalJpyc } = {}) {
96
399
  if (typeof url !== 'string') throw new Error('url is required');
97
- const response = await fetchImpl(url, {
98
- 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',
99
410
  });
100
411
  const body = await readJson(response);
101
412
  const accept = firstAccept(body);
@@ -107,17 +418,23 @@ export function createPaymentExecutor({
107
418
  reasons: ['expected_402_with_accepts'],
108
419
  };
109
420
  }
110
- const guard = evaluatePaymentGuards({
111
- url,
112
- accept,
113
- config,
114
- sessionSpentAtomic: session.spentAtomic,
115
- maxTotalJpyc,
116
- requireMaxTotal: true,
117
- requireSigner: true,
118
- signerAvailable: signer !== null,
119
- catalogListings: await resolveCatalogListings(),
120
- });
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;
121
438
  if (!guard.ok) return quoteShape(url, response.status, guard);
122
439
 
123
440
  const authorization = createAuthorization(
@@ -133,18 +450,94 @@ export function createPaymentExecutor({
133
450
  authorization,
134
451
  signature,
135
452
  );
136
- const unlocked = await fetchImpl(url, {
137
- headers: {
138
- accept: 'application/json',
139
- 'X-PAYMENT': encodePaymentPayload(paymentPayload),
140
- },
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),
141
524
  });
142
525
  const unlockedBody = await readJson(unlocked);
143
526
  if (unlocked.status >= 200 && unlocked.status < 300) {
144
- recordSuccessfulPayment(session, guard.summary.totalAtomic);
527
+ confirmSessionReservation(
528
+ session,
529
+ reservation.id,
530
+ guard.summary.totalAtomic,
531
+ );
532
+ if (dailyLimitEnabled) {
533
+ await confirmDailyReservation(reservation.id);
534
+ }
145
535
  }
146
- const receipt = decodePaymentResponse(
536
+ const receipt = await safeReceipt(
147
537
  unlocked.headers.get('x-payment-response'),
538
+ reservation,
539
+ normalizedAccept,
540
+ resolveReceiptSigner,
148
541
  );
149
542
  return {
150
543
  status: unlocked.status,
@@ -153,7 +546,7 @@ export function createPaymentExecutor({
153
546
  };
154
547
  }
155
548
 
156
- // 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
157
550
  // observe the same pre-payment session total and exceed the cumulative cap.
158
551
  let payChain = Promise.resolve();
159
552
  async function pay(url, options) {