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/CHANGELOG.md +67 -0
- package/README.md +134 -19
- package/index.d.ts +238 -0
- package/package.json +1 -1
- package/src/catalog.mjs +27 -3
- package/src/client.mjs +16 -1
- package/src/dualGate.mjs +196 -0
- package/src/executor.mjs +417 -57
- package/src/gate.mjs +151 -6
- package/src/guards.mjs +123 -27
- package/src/index.mjs +4 -0
- package/src/listing.mjs +173 -0
- package/src/network.mjs +273 -0
- package/src/payment.mjs +20 -1
- package/src/receipt.mjs +201 -0
- package/src/spendStore.mjs +411 -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,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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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}`;
|
|
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
|
|
95
|
-
if (dailySpend.key === null || dailySpend.spentAtomic === null)
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
//
|
|
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
|
|
109
|
-
|
|
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 =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 =
|
|
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) {
|