openpay-x402-sdk 0.4.0 → 0.6.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,127 @@ 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
+ // Settlement truth for one paid call. HTTP 200 only means the seller returned a body; it is not
131
+ // evidence that the facilitator settled on chain, and an LLM reading `status: 200` will assume it is.
132
+ // 'verified' — a receipt header was present and the facilitator signature bound it to this payment
133
+ // 'unverified' — a receipt header was present but unsigned, malformed, forged, or mismatched
134
+ // 'receipt_unavailable' — no receipt header, or the facilitator signer could not be resolved
135
+ export const SETTLEMENT = Object.freeze({
136
+ verified: 'verified',
137
+ unverified: 'unverified',
138
+ unavailable: 'receipt_unavailable',
139
+ });
140
+
141
+ async function settlementOutcome(
142
+ raw,
143
+ reservation,
144
+ normalizedAccept,
145
+ resolveReceiptSigner,
146
+ ) {
147
+ if (typeof raw !== 'string' || raw.length === 0) {
148
+ return { receipt: null, settlement: SETTLEMENT.unavailable };
149
+ }
150
+ try {
151
+ const decoded = decodePaymentResponse(raw);
152
+ // A header that cannot be decoded is a seller claim we could not check, not an absent claim.
153
+ if (decoded === null) {
154
+ return { receipt: null, settlement: SETTLEMENT.unverified };
155
+ }
156
+ const expectedSigner = await resolveReceiptSigner();
157
+ if (expectedSigner === null) {
158
+ return { receipt: null, settlement: SETTLEMENT.unavailable };
159
+ }
160
+ const bound = await verifyBoundPaymentResponse(decoded, {
161
+ expectedSigner,
162
+ payer: reservation.payer,
163
+ network: reservation.network,
164
+ asset: reservation.asset,
165
+ chainId: normalizedAccept.chainId,
166
+ merchant: normalizedAccept.extra.openpay.merchant,
167
+ merchantValue: normalizedAccept.extra.openpay.merchantValue,
168
+ feeValue: normalizedAccept.extra.openpay.feeValue,
169
+ nonce: reservation.id,
170
+ });
171
+ return bound
172
+ ? { receipt: decoded, settlement: SETTLEMENT.verified }
173
+ : { receipt: null, settlement: SETTLEMENT.unverified };
174
+ } catch {
175
+ // A seller-controlled receipt header must not erase an unlocked body and invite a duplicate payment.
176
+ return { receipt: null, settlement: SETTLEMENT.unverified };
177
+ }
178
+ }
179
+
54
180
  export function createPaymentExecutor({
55
181
  config,
56
182
  session,
@@ -58,28 +184,36 @@ export function createPaymentExecutor({
58
184
  signerAddress = signer?.address ?? null,
59
185
  spendStore = null,
60
186
  fetchImpl = fetch,
187
+ lookup,
188
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
61
189
  nowSec = () => Math.floor(Date.now() / 1000),
62
190
  now = () => new Date(),
63
191
  resolveCatalogListings = async () => null,
192
+ resolveReceiptSigner = async () => null,
64
193
  }) {
65
194
  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;
195
+ config.maxDailyAtomic !== null && config.maxDailyAtomic !== undefined;
196
+ if (dailyLimitEnabled && spendStore === null) {
197
+ throw new Error(
198
+ 'spendStore is required when maxDailyAtomic is configured',
199
+ );
200
+ }
201
+
202
+ function currentSessionExposure() {
203
+ return session.spentAtomic + sessionPendingAtomic(session);
204
+ }
205
+
206
+ function currentDailyKey() {
207
+ if (!dailyLimitEnabled || typeof signerAddress !== 'string') return null;
208
+ const date = utcDate(now());
209
+ return date === null
210
+ ? null
211
+ : `${signerAddress.toLowerCase()}:${date}`;
212
+ }
73
213
 
74
214
  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}`;
215
+ const key = currentDailyKey();
216
+ if (key === null) return { key: null, spentAtomic: null };
83
217
  try {
84
218
  const stored = await spendStore.load(key);
85
219
  if (typeof stored !== 'string' || !/^[0-9]+$/.test(stored)) {
@@ -91,22 +225,160 @@ export function createPaymentExecutor({
91
225
  }
92
226
  }
93
227
 
94
- async function saveDailySpend(dailySpend, amountAtomic) {
95
- if (dailySpend.key === null || dailySpend.spentAtomic === null) return;
228
+ async function reserveDailySpend(dailySpend, amountAtomic, reservation) {
229
+ if (dailySpend.key === null || dailySpend.spentAtomic === null) {
230
+ return { ok: false, reason: 'unavailable' };
231
+ }
232
+ if (typeof spendStore.reserve === 'function') {
233
+ try {
234
+ const result = await spendStore.reserve(
235
+ dailySpend.key,
236
+ amountAtomic.toString(),
237
+ config.maxDailyAtomic.toString(),
238
+ reservation,
239
+ );
240
+ if (
241
+ !isObject(result) ||
242
+ (result.ok !== true && result.ok !== false)
243
+ ) {
244
+ return { ok: false, reason: 'unavailable' };
245
+ }
246
+ if (result.ok === false) {
247
+ return result.reason === 'limit_exceeded'
248
+ ? result
249
+ : { ok: false, reason: 'unavailable' };
250
+ }
251
+ if (
252
+ typeof result.totalAtomic !== 'string' ||
253
+ !/^[0-9]+$/.test(result.totalAtomic)
254
+ ) {
255
+ return { ok: false, reason: 'unavailable' };
256
+ }
257
+ const totalAtomic = BigInt(result.totalAtomic);
258
+ if (
259
+ totalAtomic < dailySpend.spentAtomic + amountAtomic ||
260
+ totalAtomic > config.maxDailyAtomic
261
+ ) {
262
+ return { ok: false, reason: 'unavailable' };
263
+ }
264
+ return result;
265
+ } catch {
266
+ return { ok: false, reason: 'unavailable' };
267
+ }
268
+ }
269
+
270
+ // Legacy custom stores remain callable, but a write is verified before exposing the signature.
96
271
  try {
97
- await spendStore.save(
98
- dailySpend.key,
99
- (dailySpend.spentAtomic + amountAtomic).toString(),
100
- );
272
+ const fresh = await spendStore.load(dailySpend.key);
273
+ if (typeof fresh !== 'string' || !/^[0-9]+$/.test(fresh)) {
274
+ return { ok: false, reason: 'unavailable' };
275
+ }
276
+ const next = BigInt(fresh) + amountAtomic;
277
+ if (next > config.maxDailyAtomic) {
278
+ return {
279
+ ok: false,
280
+ reason: 'limit_exceeded',
281
+ totalAtomic: fresh,
282
+ };
283
+ }
284
+ await spendStore.save(dailySpend.key, next.toString());
285
+ const persisted = await spendStore.load(dailySpend.key);
286
+ if (
287
+ typeof persisted !== 'string' ||
288
+ !/^[0-9]+$/.test(persisted) ||
289
+ BigInt(persisted) < next
290
+ ) {
291
+ return { ok: false, reason: 'unavailable' };
292
+ }
293
+ return { ok: true, totalAtomic: persisted };
294
+ } catch {
295
+ return { ok: false, reason: 'unavailable' };
296
+ }
297
+ }
298
+
299
+ async function confirmDailyReservation(id) {
300
+ if (typeof spendStore?.confirm !== 'function') return;
301
+ try {
302
+ await spendStore.confirm(id);
101
303
  } catch {
102
- // The unlock already succeeded; store failure must not replace the payment response.
304
+ // Confirmation metadata is ancillary; its failure must not replace a completed unlock response.
305
+ }
306
+ }
307
+
308
+ async function targetAccess(url) {
309
+ const parsedUrl = parseSafePaymentUrl(url);
310
+ if (parsedUrl === null) {
311
+ return {
312
+ ok: false,
313
+ parsedUrl: null,
314
+ catalogListings: null,
315
+ reasons: [REASONS.invalidUrl],
316
+ };
317
+ }
318
+ const hostAllowed = config.allowedHosts.includes(
319
+ parsedUrl.hostname.toLowerCase(),
320
+ );
321
+ let catalogListings = null;
322
+ let catalogListed = false;
323
+ if (!hostAllowed && config.catalogTrust) {
324
+ catalogListings = await resolveCatalogListings();
325
+ catalogListed =
326
+ catalogAcceptForUrl(parsedUrl, catalogListings) !== undefined;
327
+ }
328
+ const reasons = [];
329
+ if (!hostAllowed && !catalogListed) reasons.push(REASONS.hostNotAllowed);
330
+ if (parsedUrl.protocol !== 'https:') {
331
+ reasons.push(REASONS.unsupportedScheme);
103
332
  }
333
+ return {
334
+ ok: reasons.length === 0,
335
+ parsedUrl,
336
+ catalogListings,
337
+ reasons,
338
+ };
339
+ }
340
+
341
+ function fetchTarget(url, headers) {
342
+ return fetchPaymentTarget(url, {
343
+ fetchImpl,
344
+ headers,
345
+ lookup,
346
+ timeoutMs: requestTimeoutMs,
347
+ });
348
+ }
349
+
350
+ async function withForwarderCatalogFallback(guardInput, catalogListings) {
351
+ let guard = evaluatePaymentGuards({
352
+ ...guardInput,
353
+ catalogListings,
354
+ });
355
+ if (
356
+ catalogListings === null &&
357
+ config.catalogTrust &&
358
+ guard.reasons.includes(REASONS.invalidOpenpayForwarder)
359
+ ) {
360
+ const resolved = await resolveCatalogListings();
361
+ guard = evaluatePaymentGuards({
362
+ ...guardInput,
363
+ catalogListings: resolved,
364
+ });
365
+ return { guard, catalogListings: resolved };
366
+ }
367
+ return { guard, catalogListings };
104
368
  }
105
369
 
106
370
  async function quoteImpl(url) {
107
371
  if (typeof url !== 'string') throw new Error('url is required');
108
- const response = await fetchImpl(url, {
109
- headers: { accept: 'application/json' },
372
+ const access = await targetAccess(url);
373
+ if (!access.ok) {
374
+ return quoteShape(url, 0, {
375
+ ok: false,
376
+ reasons: access.reasons,
377
+ summary: null,
378
+ });
379
+ }
380
+ const response = await fetchTarget(url, {
381
+ accept: 'application/json',
110
382
  });
111
383
  const body = await readJson(response);
112
384
  const accept = firstAccept(body);
@@ -119,14 +391,16 @@ export function createPaymentExecutor({
119
391
  };
120
392
  }
121
393
  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
- });
394
+ const { guard } = await withForwarderCatalogFallback(
395
+ {
396
+ url,
397
+ accept,
398
+ config,
399
+ sessionSpentAtomic: currentSessionExposure(),
400
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
401
+ },
402
+ access.catalogListings,
403
+ );
130
404
  return quoteShape(url, response.status, guard);
131
405
  }
132
406
 
@@ -140,8 +414,16 @@ export function createPaymentExecutor({
140
414
 
141
415
  async function payImpl(url, { maxTotalJpyc } = {}) {
142
416
  if (typeof url !== 'string') throw new Error('url is required');
143
- const response = await fetchImpl(url, {
144
- headers: { accept: 'application/json' },
417
+ const access = await targetAccess(url);
418
+ if (!access.ok) {
419
+ return quoteShape(url, 0, {
420
+ ok: false,
421
+ reasons: access.reasons,
422
+ summary: null,
423
+ });
424
+ }
425
+ const response = await fetchTarget(url, {
426
+ accept: 'application/json',
145
427
  });
146
428
  const body = await readJson(response);
147
429
  const accept = firstAccept(body);
@@ -153,19 +435,23 @@ export function createPaymentExecutor({
153
435
  reasons: ['expected_402_with_accepts'],
154
436
  };
155
437
  }
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
- });
438
+ let dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
439
+ const initialGuard = await withForwarderCatalogFallback(
440
+ {
441
+ url,
442
+ accept,
443
+ config,
444
+ sessionSpentAtomic: currentSessionExposure(),
445
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
446
+ maxTotalJpyc,
447
+ requireMaxTotal: true,
448
+ requireSigner: true,
449
+ signerAvailable: signer !== null,
450
+ },
451
+ access.catalogListings,
452
+ );
453
+ let guard = initialGuard.guard;
454
+ const catalogListings = initialGuard.catalogListings;
169
455
  if (!guard.ok) return quoteShape(url, response.status, guard);
170
456
 
171
457
  const authorization = createAuthorization(
@@ -181,30 +467,104 @@ export function createPaymentExecutor({
181
467
  authorization,
182
468
  signature,
183
469
  );
184
- const unlocked = await fetchImpl(url, {
185
- headers: {
186
- accept: 'application/json',
187
- 'X-PAYMENT': encodePaymentPayload(paymentPayload),
188
- },
470
+ const reservation = {
471
+ id: typedData.message.nonce,
472
+ amountAtomic: guard.summary.totalAtomic,
473
+ payer: typedData.message.from,
474
+ network: normalizedAccept.network,
475
+ asset: normalizedAccept.asset,
476
+ validBefore: authorization.validBefore,
477
+ };
478
+
479
+ // Re-read all mutable counters after the last pre-sign await. A UTC rollover or
480
+ // another process reservation must be visible before this authorization leaves.
481
+ dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
482
+ guard = evaluatePaymentGuards({
483
+ url,
484
+ accept,
485
+ config,
486
+ sessionSpentAtomic: currentSessionExposure(),
487
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
488
+ maxTotalJpyc,
489
+ requireMaxTotal: true,
490
+ requireSigner: true,
491
+ signerAvailable: signer !== null,
492
+ catalogListings,
493
+ });
494
+ if (!guard.ok) return quoteShape(url, response.status, guard);
495
+
496
+ if (
497
+ dailyLimitEnabled &&
498
+ (dailySpend?.key === null ||
499
+ utcDate(Number(authorization.validBefore) * 1000) !==
500
+ dailySpend.key.slice(-10))
501
+ ) {
502
+ return rejectedQuote(
503
+ url,
504
+ response.status,
505
+ guard,
506
+ REASONS.dailyAuthorizationCrossesUtcDay,
507
+ );
508
+ }
509
+
510
+ if (!reserveSession(session, reservation, config.maxSessionAtomic)) {
511
+ return rejectedQuote(
512
+ url,
513
+ response.status,
514
+ guard,
515
+ REASONS.sessionLimitExceeded,
516
+ );
517
+ }
518
+ if (dailyLimitEnabled) {
519
+ const reserved = await reserveDailySpend(
520
+ dailySpend,
521
+ guard.summary.totalAtomic,
522
+ reservation,
523
+ );
524
+ if (!reserved.ok) {
525
+ // This signature has not crossed a process boundary, so only this unsent reservation is safe to release.
526
+ cancelUnsentSessionReservation(session, reservation.id);
527
+ return rejectedQuote(
528
+ url,
529
+ response.status,
530
+ guard,
531
+ reserved.reason === 'limit_exceeded'
532
+ ? REASONS.dailyLimitExceeded
533
+ : REASONS.dailySpendUnavailable,
534
+ );
535
+ }
536
+ }
537
+
538
+ const unlocked = await fetchTarget(url, {
539
+ accept: 'application/json',
540
+ 'X-PAYMENT': encodePaymentPayload(paymentPayload),
189
541
  });
190
542
  const unlockedBody = await readJson(unlocked);
191
543
  if (unlocked.status >= 200 && unlocked.status < 300) {
192
- recordSuccessfulPayment(session, guard.summary.totalAtomic);
193
- if (dailySpend !== null) {
194
- await saveDailySpend(dailySpend, guard.summary.totalAtomic);
544
+ confirmSessionReservation(
545
+ session,
546
+ reservation.id,
547
+ guard.summary.totalAtomic,
548
+ );
549
+ if (dailyLimitEnabled) {
550
+ await confirmDailyReservation(reservation.id);
195
551
  }
196
552
  }
197
- const receipt = decodePaymentResponse(
553
+ const { receipt, settlement } = await settlementOutcome(
198
554
  unlocked.headers.get('x-payment-response'),
555
+ reservation,
556
+ normalizedAccept,
557
+ resolveReceiptSigner,
199
558
  );
200
559
  return {
201
560
  status: unlocked.status,
202
561
  body: unlockedBody,
203
562
  receipt,
563
+ settlement,
204
564
  };
205
565
  }
206
566
 
207
- // Serialize the full read → sign → unlock → record path so concurrent calls cannot
567
+ // Serialize the full read → sign → reserve → unlock → record path so concurrent calls cannot
208
568
  // observe the same pre-payment session total and exceed the cumulative cap.
209
569
  let payChain = Promise.resolve();
210
570
  async function pay(url, options) {