@peac/adapter-x402 0.10.5

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/dist/verify.js ADDED
@@ -0,0 +1,919 @@
1
+ "use strict";
2
+ /**
3
+ * x402 Offer/Receipt verification
4
+ *
5
+ * Verification rules (PEAC-first):
6
+ * 1. Structural validation (format, required fields)
7
+ * 2. DoS guards (accepts array limits)
8
+ * 3. Amount validation (non-negative integer string)
9
+ * 4. Network validation (CAIP-2 format)
10
+ * 5. Expiry check (validUntil vs current time)
11
+ * 6. Version check (supported versions)
12
+ * 7. Signature format validation (EIP-712 or JWS structure)
13
+ * 8. Term-matching (offer payload vs accept entry)
14
+ *
15
+ * IMPORTANT: Cryptographic signature verification (EIP-712 recovery, JWS validation)
16
+ * is NOT performed here. That requires a crypto provider (viem, ethers, etc.)
17
+ * and should be done by the caller before passing artifacts to this adapter.
18
+ *
19
+ * `valid: true` does NOT imply cryptographic signature validity unless a
20
+ * CryptoVerifier is supplied and `verification.cryptographic.verified` is true.
21
+ *
22
+ * The focus of this module is TERM-MATCHING: ensuring the signed payload
23
+ * fields match the declared accept terms. This is the binding mechanism
24
+ * that makes unsigned acceptIndex irrelevant for security.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.verifyOffer = verifyOffer;
28
+ exports.verifyReceipt = verifyReceipt;
29
+ exports.matchAcceptTerms = matchAcceptTerms;
30
+ exports.selectAccept = selectAccept;
31
+ const errors_js_1 = require("./errors.js");
32
+ const types_js_1 = require("./types.js");
33
+ // ---------------------------------------------------------------------------
34
+ // Constants
35
+ // ---------------------------------------------------------------------------
36
+ const DEFAULT_SUPPORTED_VERSIONS = ['1'];
37
+ const DEFAULT_CLOCK_SKEW_SECONDS = 60;
38
+ // Per-field DoS limits (prevent giant strings in individual fields)
39
+ // These are BYTE limits, not character counts, to handle UTF-8 multibyte strings
40
+ const MAX_FIELD_BYTES = 256; // Generous limit for network/asset/payTo/scheme
41
+ // Per-entry total size limit (bounds settlement objects + all fields)
42
+ // With 128 entries max, 2KB each = 256KB total (matches MAX_TOTAL_ACCEPTS_BYTES)
43
+ const MAX_ENTRY_BYTES = 2048;
44
+ // Portable byte length calculation (works in Node.js and edge runtimes)
45
+ const textEncoder = new TextEncoder();
46
+ function getByteLength(str) {
47
+ return textEncoder.encode(str).length;
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Shape Validation (Runtime Type Guards)
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * Check if a value is a plain object (not null, not array, not class instance)
54
+ */
55
+ function isPlainObject(value) {
56
+ if (value === null || typeof value !== 'object')
57
+ return false;
58
+ if (Array.isArray(value))
59
+ return false;
60
+ // Check for plain object prototype (not class instance)
61
+ const proto = Object.getPrototypeOf(value);
62
+ return proto === Object.prototype || proto === null;
63
+ }
64
+ /**
65
+ * Count bytes in a JSON value without allocating the full JSON string.
66
+ *
67
+ * Uses bounded traversal that stops early when limit is exceeded.
68
+ * This is allocation-safe: we never build the full JSON string.
69
+ *
70
+ * @param value - The value to measure
71
+ * @param limit - The byte limit (stop counting once exceeded)
72
+ * @returns The byte count (may be >= limit if exceeded), or -1 if cycle detected
73
+ */
74
+ function countJsonBytes(value, limit) {
75
+ // Use a stack-based traversal to avoid recursion stack overflow
76
+ // Each stack item: [value, state] where state tracks position in object/array
77
+ const stack = [];
78
+ const seen = new WeakSet();
79
+ let bytes = 0;
80
+ const addStringBytes = (str) => {
81
+ // JSON string encoding: quotes + content + escape overhead
82
+ // For simplicity, we use a conservative estimate: 2 (quotes) + UTF-8 bytes * 1.5 (escape overhead)
83
+ // More accurate: encode and count, but that's expensive
84
+ bytes += 2 + getByteLength(str);
85
+ };
86
+ const countPrimitive = (val) => {
87
+ if (val === null) {
88
+ bytes += 4; // "null"
89
+ }
90
+ else if (typeof val === 'boolean') {
91
+ bytes += val ? 4 : 5; // "true" or "false"
92
+ }
93
+ else if (typeof val === 'number') {
94
+ bytes += String(val).length;
95
+ }
96
+ else if (typeof val === 'string') {
97
+ addStringBytes(val);
98
+ }
99
+ };
100
+ // Initialize with root value
101
+ if (value === null || typeof value !== 'object') {
102
+ countPrimitive(value);
103
+ return bytes;
104
+ }
105
+ if (seen.has(value))
106
+ return -1; // Cycle
107
+ seen.add(value);
108
+ if (Array.isArray(value)) {
109
+ bytes += 2; // [ and ]
110
+ stack.push({ val: value, idx: 0, isObj: false });
111
+ }
112
+ else {
113
+ bytes += 2; // { and }
114
+ const keys = Object.keys(value);
115
+ stack.push({ val: value, keys, idx: 0, isObj: true });
116
+ }
117
+ while (stack.length > 0 && bytes <= limit) {
118
+ const frame = stack[stack.length - 1];
119
+ if (frame.isObj) {
120
+ const obj = frame.val;
121
+ const keys = frame.keys;
122
+ if (frame.idx >= keys.length) {
123
+ stack.pop();
124
+ continue;
125
+ }
126
+ const key = keys[frame.idx];
127
+ const childVal = obj[key];
128
+ frame.idx++;
129
+ // Add comma if not first
130
+ if (frame.idx > 1)
131
+ bytes += 1;
132
+ // Add key
133
+ addStringBytes(key);
134
+ bytes += 1; // colon
135
+ // Handle child value
136
+ if (childVal === null || typeof childVal !== 'object') {
137
+ countPrimitive(childVal);
138
+ }
139
+ else {
140
+ if (seen.has(childVal))
141
+ return -1; // Cycle
142
+ seen.add(childVal);
143
+ if (Array.isArray(childVal)) {
144
+ bytes += 2;
145
+ stack.push({ val: childVal, idx: 0, isObj: false });
146
+ }
147
+ else {
148
+ bytes += 2;
149
+ const childKeys = Object.keys(childVal);
150
+ stack.push({ val: childVal, keys: childKeys, idx: 0, isObj: true });
151
+ }
152
+ }
153
+ }
154
+ else {
155
+ const arr = frame.val;
156
+ if (frame.idx >= arr.length) {
157
+ stack.pop();
158
+ continue;
159
+ }
160
+ const childVal = arr[frame.idx];
161
+ frame.idx++;
162
+ // Add comma if not first
163
+ if (frame.idx > 1)
164
+ bytes += 1;
165
+ // Handle child value
166
+ if (childVal === null || typeof childVal !== 'object') {
167
+ countPrimitive(childVal);
168
+ }
169
+ else {
170
+ if (seen.has(childVal))
171
+ return -1; // Cycle
172
+ seen.add(childVal);
173
+ if (Array.isArray(childVal)) {
174
+ bytes += 2;
175
+ stack.push({ val: childVal, idx: 0, isObj: false });
176
+ }
177
+ else {
178
+ bytes += 2;
179
+ const childKeys = Object.keys(childVal);
180
+ stack.push({ val: childVal, keys: childKeys, idx: 0, isObj: true });
181
+ }
182
+ }
183
+ }
184
+ }
185
+ return bytes;
186
+ }
187
+ /**
188
+ * Validate accept entry shape (runtime type guard)
189
+ *
190
+ * Ensures all fields are the expected types before byte validation.
191
+ * This prevents crashes when untrusted JSON contains non-string values.
192
+ *
193
+ * @param entry - The entry to validate (typed as unknown since from untrusted input)
194
+ * @param index - The index for error reporting
195
+ * @returns VerificationError if shape is invalid, null otherwise
196
+ */
197
+ function validateAcceptEntryShape(entry, index) {
198
+ if (!isPlainObject(entry)) {
199
+ return {
200
+ code: 'accept_entry_invalid',
201
+ message: `accepts[${index}] must be a plain object`,
202
+ field: `accepts[${index}]`,
203
+ };
204
+ }
205
+ // Required string fields
206
+ const requiredStringFields = ['network', 'asset', 'payTo', 'amount'];
207
+ for (const field of requiredStringFields) {
208
+ const value = entry[field];
209
+ if (value === undefined || value === null) {
210
+ return {
211
+ code: 'accept_entry_invalid',
212
+ message: `accepts[${index}].${field} is required`,
213
+ field: `accepts[${index}].${field}`,
214
+ };
215
+ }
216
+ if (typeof value !== 'string') {
217
+ return {
218
+ code: 'accept_entry_invalid',
219
+ message: `accepts[${index}].${field} must be a string, got ${typeof value}`,
220
+ field: `accepts[${index}].${field}`,
221
+ };
222
+ }
223
+ }
224
+ // Optional string field
225
+ if (entry.scheme !== undefined && entry.scheme !== null && typeof entry.scheme !== 'string') {
226
+ return {
227
+ code: 'accept_entry_invalid',
228
+ message: `accepts[${index}].scheme must be a string, got ${typeof entry.scheme}`,
229
+ field: `accepts[${index}].scheme`,
230
+ };
231
+ }
232
+ // settlement is optional and can be any plain object
233
+ if (entry.settlement !== undefined && entry.settlement !== null) {
234
+ if (!isPlainObject(entry.settlement)) {
235
+ return {
236
+ code: 'accept_entry_invalid',
237
+ message: `accepts[${index}].settlement must be a plain object`,
238
+ field: `accepts[${index}].settlement`,
239
+ };
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+ // ---------------------------------------------------------------------------
245
+ // Constants
246
+ // ---------------------------------------------------------------------------
247
+ // Regex for JWS compact serialization: header.payload.signature
248
+ const JWS_COMPACT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
249
+ // Regex for EIP-712 hex signature: 0x + 130 hex chars (65 bytes: r + s + v)
250
+ const EIP712_SIG_RE = /^0x[0-9a-fA-F]{130}$/;
251
+ // Regex for amount: non-negative integer string (no decimals, no leading zeros except "0")
252
+ const AMOUNT_RE = /^(0|[1-9][0-9]*)$/;
253
+ // CAIP-2 network format: namespace:reference
254
+ // Per CAIP-2 spec: https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-2.md
255
+ // namespace: 3-8 chars (lowercase letters, digits, hyphens)
256
+ // reference: 1-64 chars (letters, digits, hyphens, underscores)
257
+ // Examples: eip155:1, eip155:8453, cosmos:cosmoshub-4, solana:mainnet
258
+ const CAIP2_NAMESPACE_RE = /^[a-z][a-z0-9-]{2,7}$/;
259
+ const CAIP2_REFERENCE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
260
+ // ---------------------------------------------------------------------------
261
+ // Offer Verification
262
+ // ---------------------------------------------------------------------------
263
+ /**
264
+ * Verify an x402 signed offer against accept terms
265
+ *
266
+ * This performs structural validation, expiry/version checks, and
267
+ * term-matching against the provided accept entries. It does NOT verify
268
+ * the cryptographic signature (that is the caller's responsibility).
269
+ *
270
+ * @param offer - The signed offer to verify
271
+ * @param accepts - The list of acceptable payment terms
272
+ * @param acceptIndex - Optional hint index (unsigned, treat as advisory)
273
+ * @param config - Optional adapter configuration
274
+ * @returns Verification result with matched accept entry or errors
275
+ */
276
+ function verifyOffer(offer, accepts, acceptIndex, config) {
277
+ const errors = [];
278
+ const supportedVersions = config?.supportedVersions ?? DEFAULT_SUPPORTED_VERSIONS;
279
+ const clockSkew = config?.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
280
+ const now = config?.nowSeconds ?? Math.floor(Date.now() / 1000);
281
+ const maxAccepts = config?.maxAcceptEntries ?? types_js_1.MAX_ACCEPT_ENTRIES;
282
+ const mismatchPolicy = config?.mismatchPolicy ?? 'fail';
283
+ const strictAmount = config?.strictAmountValidation ?? true;
284
+ const strictNetwork = config?.strictNetworkValidation ?? true;
285
+ // Pre-term-matching default: we know if hint was provided, but haven't checked for mismatch yet
286
+ const hintProvided = acceptIndex !== undefined;
287
+ const preMatchTermMatching = {
288
+ method: 'scan',
289
+ hintProvided,
290
+ hintMismatchDetected: false,
291
+ };
292
+ // 1. Structural validation
293
+ const structErrors = validateOfferStructure(offer);
294
+ if (structErrors.length > 0) {
295
+ return {
296
+ valid: false,
297
+ usedHint: false,
298
+ errors: structErrors,
299
+ termMatching: preMatchTermMatching,
300
+ };
301
+ }
302
+ const payload = offer.payload;
303
+ // 2. DoS guard: check accepts array count
304
+ if (accepts.length > maxAccepts) {
305
+ errors.push({
306
+ code: 'accept_too_many_entries',
307
+ message: `Too many accept entries: ${accepts.length} exceeds limit of ${maxAccepts}`,
308
+ });
309
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
310
+ }
311
+ // 2b. DoS guard: validate entry shape first (prevents crashes from malformed JSON)
312
+ // Then validate per-entry size (including settlement) BEFORE aggregate operations
313
+ // This bounds memory cost and rejects oversized nested objects early
314
+ for (let i = 0; i < accepts.length; i++) {
315
+ const entry = accepts[i];
316
+ // First: validate shape (runtime type guard for untrusted input)
317
+ const shapeError = validateAcceptEntryShape(entry, i);
318
+ if (shapeError) {
319
+ errors.push(shapeError);
320
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
321
+ }
322
+ // Second: check per-entry total size (bounds settlement + all fields)
323
+ const entryError = validateAcceptEntrySize(entry, i);
324
+ if (entryError) {
325
+ errors.push(entryError);
326
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
327
+ }
328
+ // Third: check individual string field sizes for clearer error messages
329
+ const fieldError = validateAcceptFieldBytes(entry, i);
330
+ if (fieldError) {
331
+ errors.push(fieldError);
332
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
333
+ }
334
+ }
335
+ // 2c. DoS guard: check total accepts byte size using bounded traversal
336
+ // NOTE: With per-entry bounds (128 * 2KB = 256KB), this is a redundant safety check
337
+ const maxBytes = config?.maxTotalAcceptsBytes ?? types_js_1.MAX_TOTAL_ACCEPTS_BYTES;
338
+ const acceptsBytes = countJsonBytes(accepts, maxBytes + 1);
339
+ if (acceptsBytes === -1 || acceptsBytes > maxBytes) {
340
+ errors.push({
341
+ code: 'accept_too_many_entries',
342
+ message: `Accepts array too large: exceeds limit of ${maxBytes} bytes`,
343
+ });
344
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
345
+ }
346
+ // 3. Amount validation (if enabled)
347
+ if (strictAmount) {
348
+ const amountError = validateAmount(payload.amount, 'offer');
349
+ if (amountError) {
350
+ errors.push(amountError);
351
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
352
+ }
353
+ }
354
+ // 4. Network validation (if enabled)
355
+ if (strictNetwork && payload.network) {
356
+ const networkError = validateNetwork(payload.network, 'offer');
357
+ if (networkError) {
358
+ errors.push(networkError);
359
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
360
+ }
361
+ }
362
+ // 5. Version check
363
+ if (!supportedVersions.includes(payload.version)) {
364
+ errors.push({
365
+ code: 'offer_version_unsupported',
366
+ message: `Offer version "${payload.version}" is not supported. Supported: ${supportedVersions.join(', ')}`,
367
+ field: 'payload.version',
368
+ });
369
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
370
+ }
371
+ // 6. Expiry check (with clock skew tolerance)
372
+ if (payload.validUntil <= now - clockSkew) {
373
+ errors.push({
374
+ code: 'offer_expired',
375
+ message: `Offer expired at ${payload.validUntil}, current time is ${now} (skew: ${clockSkew}s)`,
376
+ field: 'payload.validUntil',
377
+ });
378
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
379
+ }
380
+ // 7. Signature format validation (structural only, not cryptographic)
381
+ const sigError = validateSignatureFormat(offer.signature, offer.format, 'offer');
382
+ if (sigError) {
383
+ errors.push(sigError);
384
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
385
+ }
386
+ // 8. Accept selection via term-matching with mismatchPolicy support
387
+ if (accepts.length === 0) {
388
+ errors.push({
389
+ code: 'accept_no_match',
390
+ message: 'No accept entries provided',
391
+ });
392
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
393
+ }
394
+ return verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy, errors);
395
+ }
396
+ /**
397
+ * Verify an x402 signed receipt
398
+ *
399
+ * Performs structural validation on the receipt. Does NOT verify
400
+ * the cryptographic signature.
401
+ *
402
+ * @param receipt - The signed receipt to verify
403
+ * @param config - Optional adapter configuration
404
+ * @returns Verification result with errors if invalid
405
+ */
406
+ function verifyReceipt(receipt, config) {
407
+ const errors = [];
408
+ const strictAmount = config?.strictAmountValidation ?? true;
409
+ const strictNetwork = config?.strictNetworkValidation ?? true;
410
+ const supportedVersions = config?.supportedVersions ?? DEFAULT_SUPPORTED_VERSIONS;
411
+ // 1. Structural validation (format, required fields)
412
+ const structErrors = validateReceiptStructure(receipt);
413
+ if (structErrors.length > 0) {
414
+ return { valid: false, errors: structErrors };
415
+ }
416
+ // 2. Version check (before semantic validation)
417
+ if (!supportedVersions.includes(receipt.payload.version)) {
418
+ errors.push({
419
+ code: 'receipt_version_unsupported',
420
+ message: `Receipt version "${receipt.payload.version}" is not supported. Supported: ${supportedVersions.join(', ')}`,
421
+ field: 'payload.version',
422
+ });
423
+ return { valid: false, errors };
424
+ }
425
+ // 3. Signature format validation (structural, not cryptographic)
426
+ const sigError = validateSignatureFormat(receipt.signature, receipt.format, 'receipt');
427
+ if (sigError) {
428
+ errors.push(sigError);
429
+ return { valid: false, errors };
430
+ }
431
+ // 4. Amount validation (if present and enabled)
432
+ if (strictAmount && receipt.payload.amount) {
433
+ const amountError = validateAmount(receipt.payload.amount, 'receipt');
434
+ if (amountError) {
435
+ errors.push(amountError);
436
+ return { valid: false, errors };
437
+ }
438
+ }
439
+ // 5. Network validation (if enabled)
440
+ if (strictNetwork && receipt.payload.network) {
441
+ const networkError = validateNetwork(receipt.payload.network, 'receipt');
442
+ if (networkError) {
443
+ errors.push(networkError);
444
+ return { valid: false, errors };
445
+ }
446
+ }
447
+ return { valid: true, errors: [] };
448
+ }
449
+ // ---------------------------------------------------------------------------
450
+ // Term Matching
451
+ // ---------------------------------------------------------------------------
452
+ /**
453
+ * Match offer payload fields against a single accept entry
454
+ *
455
+ * Compares: network, asset, payTo, amount, scheme, settlement
456
+ * All present fields must match exactly. Amount comparison is string-based
457
+ * (minor units) to avoid floating-point issues.
458
+ *
459
+ * @returns Array of mismatched field names (empty = match)
460
+ */
461
+ function matchAcceptTerms(payload, accept) {
462
+ const mismatches = [];
463
+ if (payload.network !== accept.network) {
464
+ mismatches.push('network');
465
+ }
466
+ if (payload.asset !== accept.asset) {
467
+ mismatches.push('asset');
468
+ }
469
+ if (payload.payTo !== accept.payTo) {
470
+ mismatches.push('payTo');
471
+ }
472
+ if (payload.amount !== accept.amount) {
473
+ mismatches.push('amount');
474
+ }
475
+ // Optional fields: only compare if both are present
476
+ if (payload.scheme !== undefined && accept.scheme !== undefined) {
477
+ if (payload.scheme !== accept.scheme) {
478
+ mismatches.push('scheme');
479
+ }
480
+ }
481
+ return mismatches;
482
+ }
483
+ // ---------------------------------------------------------------------------
484
+ // Accept Selection
485
+ // ---------------------------------------------------------------------------
486
+ /**
487
+ * Select the matching accept entry from the list
488
+ *
489
+ * Selection logic:
490
+ * - If acceptIndex provided: bounds-check, then term-match that entry
491
+ * - If absent: scan all entries for a unique match
492
+ * - If multiple matches: fail closed (ambiguous)
493
+ * - If no matches: fail closed
494
+ *
495
+ * @param payload - Offer payload to match
496
+ * @param accepts - List of accept entries
497
+ * @param acceptIndex - Optional hint (unsigned, untrusted)
498
+ * @returns The matched entry and its index, or throws X402Error
499
+ */
500
+ function selectAccept(payload, accepts, acceptIndex) {
501
+ if (accepts.length === 0) {
502
+ throw new errors_js_1.X402Error('accept_no_match', 'No accept entries provided');
503
+ }
504
+ if (acceptIndex !== undefined) {
505
+ // Bounds check
506
+ if (acceptIndex < 0 || acceptIndex >= accepts.length) {
507
+ throw new errors_js_1.X402Error('accept_index_out_of_range', `acceptIndex ${acceptIndex} is out of range [0, ${accepts.length - 1}]`, {
508
+ field: 'acceptIndex',
509
+ details: { acceptIndex, acceptsLength: accepts.length },
510
+ });
511
+ }
512
+ // Term-match the hinted entry
513
+ const entry = accepts[acceptIndex];
514
+ const mismatches = matchAcceptTerms(payload, entry);
515
+ if (mismatches.length > 0) {
516
+ throw new errors_js_1.X402Error('accept_term_mismatch', `acceptIndex ${acceptIndex} does not match signed payload on: ${mismatches.join(', ')}`, {
517
+ field: 'acceptIndex',
518
+ details: { acceptIndex, mismatches },
519
+ });
520
+ }
521
+ return { entry, index: acceptIndex, usedHint: true };
522
+ }
523
+ // Full scan: find all matching entries
524
+ const matches = [];
525
+ for (let i = 0; i < accepts.length; i++) {
526
+ const mismatches = matchAcceptTerms(payload, accepts[i]);
527
+ if (mismatches.length === 0) {
528
+ matches.push({ entry: accepts[i], index: i });
529
+ }
530
+ }
531
+ if (matches.length === 0) {
532
+ throw new errors_js_1.X402Error('accept_no_match', 'No accept entry matches the signed offer payload');
533
+ }
534
+ if (matches.length > 1) {
535
+ throw new errors_js_1.X402Error('accept_ambiguous', `${matches.length} accept entries match the offer payload; provide acceptIndex to disambiguate`, {
536
+ details: { matchedIndices: matches.map((m) => m.index) },
537
+ });
538
+ }
539
+ return { entry: matches[0].entry, index: matches[0].index, usedHint: false };
540
+ }
541
+ // ---------------------------------------------------------------------------
542
+ // Internal Helpers
543
+ // ---------------------------------------------------------------------------
544
+ /**
545
+ * Validate offer structural requirements (fail-closed)
546
+ */
547
+ function validateOfferStructure(offer) {
548
+ const errors = [];
549
+ if (!offer || typeof offer !== 'object') {
550
+ return [{ code: 'offer_invalid_format', message: 'Offer must be an object' }];
551
+ }
552
+ if (!offer.payload || typeof offer.payload !== 'object') {
553
+ errors.push({
554
+ code: 'offer_invalid_format',
555
+ message: 'Offer payload is required',
556
+ field: 'payload',
557
+ });
558
+ return errors;
559
+ }
560
+ if (!offer.signature || typeof offer.signature !== 'string') {
561
+ errors.push({
562
+ code: 'offer_invalid_format',
563
+ message: 'Offer signature is required',
564
+ field: 'signature',
565
+ });
566
+ }
567
+ if (offer.format !== 'eip712' && offer.format !== 'jws') {
568
+ errors.push({
569
+ code: 'offer_invalid_format',
570
+ message: `Invalid signature format: ${String(offer.format)}`,
571
+ field: 'format',
572
+ });
573
+ }
574
+ // Payload field validation
575
+ const p = offer.payload;
576
+ const requiredFields = [
577
+ ['version', p.version, 'string'],
578
+ ['validUntil', p.validUntil, 'number'],
579
+ ['network', p.network, 'string'],
580
+ ['asset', p.asset, 'string'],
581
+ ['amount', p.amount, 'string'],
582
+ ['payTo', p.payTo, 'string'],
583
+ ];
584
+ for (const [name, value, expectedType] of requiredFields) {
585
+ if (value === undefined || value === null) {
586
+ errors.push({
587
+ code: 'payload_missing_field',
588
+ message: `Offer payload missing required field: ${name}`,
589
+ field: `payload.${name}`,
590
+ });
591
+ }
592
+ else if (typeof value !== expectedType) {
593
+ errors.push({
594
+ code: 'offer_invalid_format',
595
+ message: `Offer payload.${name} must be ${expectedType}, got ${typeof value}`,
596
+ field: `payload.${name}`,
597
+ });
598
+ }
599
+ }
600
+ return errors;
601
+ }
602
+ /**
603
+ * Validate receipt structural requirements (fail-closed)
604
+ */
605
+ function validateReceiptStructure(receipt) {
606
+ const errors = [];
607
+ if (!receipt || typeof receipt !== 'object') {
608
+ return [{ code: 'receipt_invalid_format', message: 'Receipt must be an object' }];
609
+ }
610
+ if (!receipt.payload || typeof receipt.payload !== 'object') {
611
+ errors.push({
612
+ code: 'receipt_invalid_format',
613
+ message: 'Receipt payload is required',
614
+ field: 'payload',
615
+ });
616
+ return errors;
617
+ }
618
+ if (!receipt.signature || typeof receipt.signature !== 'string') {
619
+ errors.push({
620
+ code: 'receipt_invalid_format',
621
+ message: 'Receipt signature is required',
622
+ field: 'signature',
623
+ });
624
+ }
625
+ if (receipt.format !== 'eip712' && receipt.format !== 'jws') {
626
+ errors.push({
627
+ code: 'receipt_invalid_format',
628
+ message: `Invalid signature format: ${String(receipt.format)}`,
629
+ field: 'format',
630
+ });
631
+ }
632
+ const p = receipt.payload;
633
+ const requiredFields = [
634
+ ['version', p.version, 'string'],
635
+ ['network', p.network, 'string'],
636
+ ['txHash', p.txHash, 'string'],
637
+ ];
638
+ for (const [name, value, expectedType] of requiredFields) {
639
+ if (value === undefined || value === null) {
640
+ errors.push({
641
+ code: 'payload_missing_field',
642
+ message: `Receipt payload missing required field: ${name}`,
643
+ field: `payload.${name}`,
644
+ });
645
+ }
646
+ else if (typeof value !== expectedType) {
647
+ errors.push({
648
+ code: 'receipt_invalid_format',
649
+ message: `Receipt payload.${name} must be ${expectedType}, got ${typeof value}`,
650
+ field: `payload.${name}`,
651
+ });
652
+ }
653
+ }
654
+ return errors;
655
+ }
656
+ /**
657
+ * Validate signature format (structural, not cryptographic)
658
+ *
659
+ * @param signature - The signature string to validate
660
+ * @param format - The declared signature format
661
+ * @param kind - 'offer' or 'receipt' for correct error code
662
+ */
663
+ function validateSignatureFormat(signature, format, kind) {
664
+ const errorCode = kind === 'offer' ? 'offer_signature_invalid' : 'receipt_signature_invalid';
665
+ if (format === 'jws') {
666
+ if (!JWS_COMPACT_RE.test(signature)) {
667
+ return {
668
+ code: errorCode,
669
+ message: 'JWS signature does not match compact serialization format (header.payload.signature)',
670
+ field: 'signature',
671
+ };
672
+ }
673
+ }
674
+ else if (format === 'eip712') {
675
+ if (!EIP712_SIG_RE.test(signature)) {
676
+ return {
677
+ code: errorCode,
678
+ message: 'EIP-712 signature must be 0x-prefixed 65-byte hex string',
679
+ field: 'signature',
680
+ };
681
+ }
682
+ }
683
+ return null;
684
+ }
685
+ /**
686
+ * Validate amount is a non-negative integer string
687
+ *
688
+ * @param amount - The amount string to validate
689
+ * @param kind - 'offer' or 'receipt' for context in error message
690
+ */
691
+ function validateAmount(amount, kind) {
692
+ // Check length first (DoS protection)
693
+ if (amount.length > types_js_1.MAX_AMOUNT_LENGTH) {
694
+ return {
695
+ code: 'amount_invalid',
696
+ message: `${kind} amount exceeds maximum length of ${types_js_1.MAX_AMOUNT_LENGTH} characters`,
697
+ field: 'payload.amount',
698
+ };
699
+ }
700
+ // Check format: non-negative integer string
701
+ if (!AMOUNT_RE.test(amount)) {
702
+ return {
703
+ code: 'amount_invalid',
704
+ message: `${kind} amount must be a non-negative integer string (got: "${amount}")`,
705
+ field: 'payload.amount',
706
+ };
707
+ }
708
+ return null;
709
+ }
710
+ /**
711
+ * Validate accept entry total size (DoS protection)
712
+ *
713
+ * Bounds the total serialized size of each entry, including settlement objects.
714
+ * Uses bounded traversal that stops early when limit is exceeded, avoiding
715
+ * full JSON string allocation.
716
+ *
717
+ * Rejects entries where small string fields hide oversized nested objects.
718
+ */
719
+ function validateAcceptEntrySize(entry, index) {
720
+ // Use bounded traversal instead of JSON.stringify to avoid allocating full string
721
+ const entryBytes = countJsonBytes(entry, MAX_ENTRY_BYTES + 1);
722
+ // -1 means cycle detected
723
+ if (entryBytes === -1) {
724
+ return {
725
+ code: 'accept_entry_invalid',
726
+ message: `accepts[${index}] contains circular reference`,
727
+ field: `accepts[${index}]`,
728
+ };
729
+ }
730
+ if (entryBytes > MAX_ENTRY_BYTES) {
731
+ return {
732
+ code: 'accept_entry_invalid',
733
+ message: `accepts[${index}] exceeds max entry size of ${MAX_ENTRY_BYTES} bytes`,
734
+ field: `accepts[${index}]`,
735
+ };
736
+ }
737
+ return null;
738
+ }
739
+ /**
740
+ * Validate accept entry field byte lengths (DoS protection)
741
+ *
742
+ * Prevents memory exhaustion from individual giant strings within accept entries.
743
+ * Uses getByteLength (TextEncoder) to handle UTF-8 multibyte characters correctly.
744
+ */
745
+ function validateAcceptFieldBytes(entry, index) {
746
+ const fields = [
747
+ ['network', entry.network],
748
+ ['asset', entry.asset],
749
+ ['payTo', entry.payTo],
750
+ ['scheme', entry.scheme],
751
+ ];
752
+ for (const [name, value] of fields) {
753
+ if (value && getByteLength(value) > MAX_FIELD_BYTES) {
754
+ return {
755
+ code: 'accept_too_many_entries',
756
+ message: `accepts[${index}].${name} exceeds max field size of ${MAX_FIELD_BYTES} bytes`,
757
+ field: `accepts[${index}].${name}`,
758
+ };
759
+ }
760
+ }
761
+ // Amount has its own limit (MAX_AMOUNT_LENGTH = 78 chars, but amount is ASCII-only digits)
762
+ if (entry.amount && entry.amount.length > types_js_1.MAX_AMOUNT_LENGTH) {
763
+ return {
764
+ code: 'amount_invalid',
765
+ message: `accepts[${index}].amount exceeds max length of ${types_js_1.MAX_AMOUNT_LENGTH}`,
766
+ field: `accepts[${index}].amount`,
767
+ };
768
+ }
769
+ return null;
770
+ }
771
+ /**
772
+ * Validate network matches CAIP-2 format
773
+ *
774
+ * Uses split-based parsing for clarity and debuggability:
775
+ * - Must have exactly one colon separator
776
+ * - namespace: 3-8 chars, starts with letter, lowercase letters/digits/hyphens
777
+ * - reference: 1-64 chars, starts with alphanumeric, letters/digits/hyphens/underscores
778
+ *
779
+ * @param network - The network string to validate
780
+ * @param kind - 'offer' or 'receipt' for context in error message
781
+ */
782
+ function validateNetwork(network, kind) {
783
+ const parts = network.split(':');
784
+ if (parts.length !== 2) {
785
+ return {
786
+ code: 'network_invalid',
787
+ message: `${kind} network must be CAIP-2 format (namespace:reference), got "${network}"`,
788
+ field: 'payload.network',
789
+ };
790
+ }
791
+ const [namespace, reference] = parts;
792
+ if (!CAIP2_NAMESPACE_RE.test(namespace)) {
793
+ return {
794
+ code: 'network_invalid',
795
+ message: `${kind} network namespace invalid: "${namespace}" (expected 3-8 lowercase chars starting with letter)`,
796
+ field: 'payload.network',
797
+ };
798
+ }
799
+ if (!CAIP2_REFERENCE_RE.test(reference)) {
800
+ return {
801
+ code: 'network_invalid',
802
+ message: `${kind} network reference invalid: "${reference}" (expected 1-64 alphanumeric chars with hyphens/underscores)`,
803
+ field: 'payload.network',
804
+ };
805
+ }
806
+ return null;
807
+ }
808
+ /**
809
+ * Verify offer with mismatchPolicy support
810
+ *
811
+ * Implements the three mismatch policies:
812
+ * - 'fail': Reject on mismatch (default)
813
+ * - 'warn_and_scan': If hint mismatches, scan for match, record mismatch
814
+ * - 'ignore_and_scan': Always scan, ignore hint for matching
815
+ */
816
+ function verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy, errors) {
817
+ const hintProvided = acceptIndex !== undefined;
818
+ // ignore_and_scan: always scan, ignore acceptIndex for matching
819
+ if (mismatchPolicy === 'ignore_and_scan') {
820
+ return verifyWithScan(payload, accepts, errors, hintProvided, false);
821
+ }
822
+ // No hint provided: scan
823
+ if (acceptIndex === undefined) {
824
+ return verifyWithScan(payload, accepts, errors, false, false);
825
+ }
826
+ // Bounds check
827
+ if (acceptIndex < 0 || acceptIndex >= accepts.length) {
828
+ if (mismatchPolicy === 'warn_and_scan') {
829
+ // Out of range counts as mismatch, fallback to scan
830
+ return verifyWithScan(payload, accepts, errors, true, true);
831
+ }
832
+ // fail policy: reject
833
+ errors.push({
834
+ code: 'accept_index_out_of_range',
835
+ message: `acceptIndex ${acceptIndex} is out of range [0, ${accepts.length - 1}]`,
836
+ field: 'acceptIndex',
837
+ });
838
+ return {
839
+ valid: false,
840
+ usedHint: true,
841
+ errors,
842
+ termMatching: { method: 'hint', hintProvided: true, hintMismatchDetected: true },
843
+ };
844
+ }
845
+ // Term-match the hinted entry
846
+ const entry = accepts[acceptIndex];
847
+ const mismatches = matchAcceptTerms(payload, entry);
848
+ if (mismatches.length === 0) {
849
+ // Hint matched: success
850
+ return {
851
+ valid: true,
852
+ matchedAccept: entry,
853
+ matchedIndex: acceptIndex,
854
+ usedHint: true,
855
+ errors: [],
856
+ termMatching: { method: 'hint', hintProvided: true, hintMismatchDetected: false },
857
+ };
858
+ }
859
+ // Hint didn't match
860
+ if (mismatchPolicy === 'fail') {
861
+ errors.push({
862
+ code: 'accept_term_mismatch',
863
+ message: `acceptIndex ${acceptIndex} does not match signed payload on: ${mismatches.join(', ')}`,
864
+ field: 'acceptIndex',
865
+ });
866
+ return {
867
+ valid: false,
868
+ usedHint: true,
869
+ errors,
870
+ termMatching: { method: 'hint', hintProvided: true, hintMismatchDetected: true },
871
+ };
872
+ }
873
+ // warn_and_scan: fallback to scan, record mismatch
874
+ return verifyWithScan(payload, accepts, errors, true, true);
875
+ }
876
+ /**
877
+ * Verify offer by scanning all accept entries
878
+ *
879
+ * @param hintProvided - Whether acceptIndex was provided (for termMatching metadata)
880
+ * @param hintMismatchDetected - Whether the hint was tried and failed (for termMatching metadata)
881
+ */
882
+ function verifyWithScan(payload, accepts, errors, hintProvided, hintMismatchDetected) {
883
+ const matches = [];
884
+ for (let i = 0; i < accepts.length; i++) {
885
+ const mismatches = matchAcceptTerms(payload, accepts[i]);
886
+ if (mismatches.length === 0) {
887
+ matches.push({ entry: accepts[i], index: i });
888
+ }
889
+ }
890
+ // termMatching is always fully populated (no optional booleans for deterministic output)
891
+ const termMatching = {
892
+ method: 'scan',
893
+ hintProvided,
894
+ hintMismatchDetected,
895
+ };
896
+ if (matches.length === 0) {
897
+ errors.push({
898
+ code: 'accept_no_match',
899
+ message: 'No accept entry matches the signed offer payload',
900
+ });
901
+ return { valid: false, usedHint: false, errors, termMatching };
902
+ }
903
+ if (matches.length > 1) {
904
+ errors.push({
905
+ code: 'accept_ambiguous',
906
+ message: `${matches.length} accept entries match; provide acceptIndex to disambiguate`,
907
+ });
908
+ return { valid: false, usedHint: false, errors, termMatching };
909
+ }
910
+ return {
911
+ valid: true,
912
+ matchedAccept: matches[0].entry,
913
+ matchedIndex: matches[0].index,
914
+ usedHint: false,
915
+ errors: [],
916
+ termMatching,
917
+ };
918
+ }
919
+ //# sourceMappingURL=verify.js.map