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/CHANGELOG.md +26 -0
- package/README.md +74 -7
- package/index.d.ts +151 -0
- package/package.json +1 -1
- package/src/catalog.mjs +27 -3
- package/src/client.mjs +24 -1
- package/src/executor.mjs +423 -30
- package/src/gate.mjs +151 -6
- package/src/guards.mjs +132 -24
- package/src/index.mjs +3 -0
- package/src/network.mjs +254 -0
- package/src/payment.mjs +20 -1
- package/src/receipt.mjs +201 -0
- package/src/spendStore.mjs +305 -0
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
|
|
65
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 =
|
|
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) {
|