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/CHANGELOG.md +19 -0
- package/README.md +70 -19
- package/index.d.ts +113 -0
- package/package.json +1 -1
- package/src/catalog.mjs +27 -3
- package/src/client.mjs +16 -1
- package/src/executor.mjs +399 -57
- package/src/gate.mjs +151 -6
- package/src/guards.mjs +109 -24
- package/src/index.mjs +2 -0
- package/src/network.mjs +254 -0
- package/src/payment.mjs +20 -1
- package/src/receipt.mjs +201 -0
- package/src/spendStore.mjs +251 -32
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
95
|
-
if (dailySpend.key === null || dailySpend.spentAtomic === null)
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
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 =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 =
|
|
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) {
|