@peac/adapter-x402 0.12.0 → 0.12.1

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 CHANGED
@@ -2,46 +2,42 @@
2
2
  /**
3
3
  * x402 Offer/Receipt verification
4
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)
5
+ * Verification layers:
6
+ * 1. Wire validation (verifyWire): structural shape, format tags, required fields
7
+ * 2. Offer term verification (verifyOfferTerms): version, expiry, amount, network, term-matching
8
+ * 3. Receipt semantic verification (verifyReceiptSemantics): required fields, recency
9
+ * 4. Offer-receipt consistency (verifyOfferReceiptConsistency): resourceUrl, network, freshness
10
+ * 5. Opt-in crypto + authorization: CryptoVerifier and SignerAuthorizer interfaces (types only)
14
11
  *
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.
12
+ * Each layer is separately callable and separately reportable.
18
13
  *
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.
14
+ * IMPORTANT: Cryptographic signature verification is NOT performed here.
15
+ * That requires injecting a CryptoVerifier implementation.
25
16
  */
26
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.verifyOfferWire = verifyOfferWire;
19
+ exports.verifyReceiptWire = verifyReceiptWire;
27
20
  exports.verifyOffer = verifyOffer;
28
21
  exports.verifyReceipt = verifyReceipt;
22
+ exports.verifyOfferReceiptConsistency = verifyOfferReceiptConsistency;
29
23
  exports.matchAcceptTerms = matchAcceptTerms;
30
24
  exports.selectAccept = selectAccept;
31
25
  const errors_js_1 = require("./errors.js");
26
+ const raw_js_1 = require("./raw.js");
27
+ const normalize_js_1 = require("./normalize.js");
32
28
  const types_js_1 = require("./types.js");
33
29
  // ---------------------------------------------------------------------------
34
30
  // Constants
35
31
  // ---------------------------------------------------------------------------
36
- const DEFAULT_SUPPORTED_VERSIONS = ['1'];
32
+ const DEFAULT_SUPPORTED_VERSIONS = [1];
37
33
  const DEFAULT_CLOCK_SKEW_SECONDS = 60;
34
+ // Upstream x402 client helper uses 3600s (1 hour) default
35
+ const DEFAULT_RECEIPT_RECENCY_SECONDS = 3600;
38
36
  // 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
37
+ const MAX_FIELD_BYTES = 256;
41
38
  // 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
39
  const MAX_ENTRY_BYTES = 2048;
44
- // Portable byte length calculation (works in Node.js and edge runtimes)
40
+ // Portable byte length calculation
45
41
  const textEncoder = new TextEncoder();
46
42
  function getByteLength(str) {
47
43
  return textEncoder.encode(str).length;
@@ -49,46 +45,31 @@ function getByteLength(str) {
49
45
  // ---------------------------------------------------------------------------
50
46
  // Shape Validation (Runtime Type Guards)
51
47
  // ---------------------------------------------------------------------------
52
- /**
53
- * Check if a value is a plain object (not null, not array, not class instance)
54
- */
55
48
  function isPlainObject(value) {
56
49
  if (value === null || typeof value !== 'object')
57
50
  return false;
58
51
  if (Array.isArray(value))
59
52
  return false;
60
- // Check for plain object prototype (not class instance)
61
53
  const proto = Object.getPrototypeOf(value);
62
54
  return proto === Object.prototype || proto === null;
63
55
  }
64
56
  /**
65
57
  * Count bytes in a JSON value without allocating the full JSON string.
66
- *
67
58
  * 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
59
  */
74
60
  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
61
  const stack = [];
78
62
  const seen = new WeakSet();
79
63
  let bytes = 0;
80
64
  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
65
  bytes += 2 + getByteLength(str);
85
66
  };
86
67
  const countPrimitive = (val) => {
87
68
  if (val === null) {
88
- bytes += 4; // "null"
69
+ bytes += 4;
89
70
  }
90
71
  else if (typeof val === 'boolean') {
91
- bytes += val ? 4 : 5; // "true" or "false"
72
+ bytes += val ? 4 : 5;
92
73
  }
93
74
  else if (typeof val === 'number') {
94
75
  bytes += String(val).length;
@@ -97,20 +78,19 @@ function countJsonBytes(value, limit) {
97
78
  addStringBytes(val);
98
79
  }
99
80
  };
100
- // Initialize with root value
101
81
  if (value === null || typeof value !== 'object') {
102
82
  countPrimitive(value);
103
83
  return bytes;
104
84
  }
105
85
  if (seen.has(value))
106
- return -1; // Cycle
86
+ return -1;
107
87
  seen.add(value);
108
88
  if (Array.isArray(value)) {
109
- bytes += 2; // [ and ]
89
+ bytes += 2;
110
90
  stack.push({ val: value, idx: 0, isObj: false });
111
91
  }
112
92
  else {
113
- bytes += 2; // { and }
93
+ bytes += 2;
114
94
  const keys = Object.keys(value);
115
95
  stack.push({ val: value, keys, idx: 0, isObj: true });
116
96
  }
@@ -126,19 +106,16 @@ function countJsonBytes(value, limit) {
126
106
  const key = keys[frame.idx];
127
107
  const childVal = obj[key];
128
108
  frame.idx++;
129
- // Add comma if not first
130
109
  if (frame.idx > 1)
131
110
  bytes += 1;
132
- // Add key
133
111
  addStringBytes(key);
134
- bytes += 1; // colon
135
- // Handle child value
112
+ bytes += 1;
136
113
  if (childVal === null || typeof childVal !== 'object') {
137
114
  countPrimitive(childVal);
138
115
  }
139
116
  else {
140
117
  if (seen.has(childVal))
141
- return -1; // Cycle
118
+ return -1;
142
119
  seen.add(childVal);
143
120
  if (Array.isArray(childVal)) {
144
121
  bytes += 2;
@@ -159,16 +136,14 @@ function countJsonBytes(value, limit) {
159
136
  }
160
137
  const childVal = arr[frame.idx];
161
138
  frame.idx++;
162
- // Add comma if not first
163
139
  if (frame.idx > 1)
164
140
  bytes += 1;
165
- // Handle child value
166
141
  if (childVal === null || typeof childVal !== 'object') {
167
142
  countPrimitive(childVal);
168
143
  }
169
144
  else {
170
145
  if (seen.has(childVal))
171
- return -1; // Cycle
146
+ return -1;
172
147
  seen.add(childVal);
173
148
  if (Array.isArray(childVal)) {
174
149
  bytes += 2;
@@ -184,16 +159,6 @@ function countJsonBytes(value, limit) {
184
159
  }
185
160
  return bytes;
186
161
  }
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
162
  function validateAcceptEntryShape(entry, index) {
198
163
  if (!isPlainObject(entry)) {
199
164
  return {
@@ -202,8 +167,7 @@ function validateAcceptEntryShape(entry, index) {
202
167
  field: `accepts[${index}]`,
203
168
  };
204
169
  }
205
- // Required string fields
206
- const requiredStringFields = ['network', 'asset', 'payTo', 'amount'];
170
+ const requiredStringFields = ['network', 'asset', 'payTo', 'amount', 'scheme'];
207
171
  for (const field of requiredStringFields) {
208
172
  const value = entry[field];
209
173
  if (value === undefined || value === null) {
@@ -221,59 +185,146 @@ function validateAcceptEntryShape(entry, index) {
221
185
  };
222
186
  }
223
187
  }
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
188
  return null;
243
189
  }
244
190
  // ---------------------------------------------------------------------------
245
- // Constants
191
+ // Regex Constants
246
192
  // ---------------------------------------------------------------------------
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)
193
+ // EIP-712 hex signature: 0x + 130 hex chars (65 bytes: r + s + v)
250
194
  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")
195
+ // Amount: non-negative integer string
252
196
  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
197
+ // CAIP-2 network format
258
198
  const CAIP2_NAMESPACE_RE = /^[a-z][a-z0-9-]{2,7}$/;
259
199
  const CAIP2_REFERENCE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
260
200
  // ---------------------------------------------------------------------------
261
- // Offer Verification
201
+ // Layer 1: Wire Validation
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Validate raw wire structure of a signed offer
205
+ *
206
+ * Checks discriminated union shape, format tag, required fields.
207
+ * No semantic interpretation, no payload extraction.
208
+ */
209
+ function verifyOfferWire(offer) {
210
+ const errors = [];
211
+ if (!offer || typeof offer !== 'object') {
212
+ return {
213
+ valid: false,
214
+ errors: [{ code: 'offer_invalid_format', message: 'Offer must be an object' }],
215
+ };
216
+ }
217
+ if (offer.format !== 'eip712' && offer.format !== 'jws') {
218
+ errors.push({
219
+ code: 'offer_invalid_format',
220
+ message: `Invalid signature format: ${String(offer.format)}`,
221
+ field: 'format',
222
+ });
223
+ return { valid: false, errors };
224
+ }
225
+ if (!offer.signature || typeof offer.signature !== 'string') {
226
+ errors.push({
227
+ code: 'offer_invalid_format',
228
+ message: 'Offer signature is required',
229
+ field: 'signature',
230
+ });
231
+ }
232
+ if (offer.format === 'eip712') {
233
+ const eip = offer;
234
+ if (!eip.payload || typeof eip.payload !== 'object') {
235
+ errors.push({
236
+ code: 'offer_invalid_format',
237
+ message: 'EIP-712 offer must have a payload object',
238
+ field: 'payload',
239
+ });
240
+ }
241
+ // Validate EIP-712 signature format
242
+ if (eip.signature && !EIP712_SIG_RE.test(eip.signature)) {
243
+ errors.push({
244
+ code: 'offer_signature_invalid',
245
+ message: 'EIP-712 signature must be 0x-prefixed 65-byte hex string',
246
+ field: 'signature',
247
+ });
248
+ }
249
+ }
250
+ // JWS format: no payload field expected, signature is compact JWS
251
+ // JWS structural validation is deferred to extraction (parseCompactJWS)
252
+ if (offer.acceptIndex !== undefined) {
253
+ if (typeof offer.acceptIndex !== 'number' ||
254
+ !Number.isInteger(offer.acceptIndex) ||
255
+ offer.acceptIndex < 0) {
256
+ errors.push({
257
+ code: 'offer_invalid_format',
258
+ message: 'acceptIndex must be a non-negative integer',
259
+ field: 'acceptIndex',
260
+ });
261
+ }
262
+ }
263
+ return { valid: errors.length === 0, errors };
264
+ }
265
+ /**
266
+ * Validate raw wire structure of a signed receipt
267
+ */
268
+ function verifyReceiptWire(receipt) {
269
+ const errors = [];
270
+ if (!receipt || typeof receipt !== 'object') {
271
+ return {
272
+ valid: false,
273
+ errors: [{ code: 'receipt_invalid_format', message: 'Receipt must be an object' }],
274
+ };
275
+ }
276
+ if (receipt.format !== 'eip712' && receipt.format !== 'jws') {
277
+ errors.push({
278
+ code: 'receipt_invalid_format',
279
+ message: `Invalid signature format: ${String(receipt.format)}`,
280
+ field: 'format',
281
+ });
282
+ return { valid: false, errors };
283
+ }
284
+ if (!receipt.signature || typeof receipt.signature !== 'string') {
285
+ errors.push({
286
+ code: 'receipt_invalid_format',
287
+ message: 'Receipt signature is required',
288
+ field: 'signature',
289
+ });
290
+ }
291
+ if (receipt.format === 'eip712') {
292
+ const eip = receipt;
293
+ if (!eip.payload || typeof eip.payload !== 'object') {
294
+ errors.push({
295
+ code: 'receipt_invalid_format',
296
+ message: 'EIP-712 receipt must have a payload object',
297
+ field: 'payload',
298
+ });
299
+ }
300
+ if (eip.signature && !EIP712_SIG_RE.test(eip.signature)) {
301
+ errors.push({
302
+ code: 'receipt_signature_invalid',
303
+ message: 'EIP-712 signature must be 0x-prefixed 65-byte hex string',
304
+ field: 'signature',
305
+ });
306
+ }
307
+ }
308
+ return { valid: errors.length === 0, errors };
309
+ }
310
+ // ---------------------------------------------------------------------------
311
+ // Layer 2: Offer Term Verification
262
312
  // ---------------------------------------------------------------------------
263
313
  /**
264
314
  * Verify an x402 signed offer against accept terms
265
315
  *
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).
316
+ * Extracts and normalizes payload, then performs:
317
+ * - Version check
318
+ * - Expiry check (with offerExpiryPolicy)
319
+ * - Amount/network validation
320
+ * - Term-matching against accept entries
269
321
  *
270
322
  * @param offer - The signed offer to verify
271
323
  * @param accepts - The list of acceptable payment terms
272
- * @param acceptIndex - Optional hint index (unsigned, treat as advisory)
273
324
  * @param config - Optional adapter configuration
274
325
  * @returns Verification result with matched accept entry or errors
275
326
  */
276
- function verifyOffer(offer, accepts, acceptIndex, config) {
327
+ function verifyOffer(offer, accepts, config) {
277
328
  const errors = [];
278
329
  const supportedVersions = config?.supportedVersions ?? DEFAULT_SUPPORTED_VERSIONS;
279
330
  const clockSkew = config?.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
@@ -282,25 +333,44 @@ function verifyOffer(offer, accepts, acceptIndex, config) {
282
333
  const mismatchPolicy = config?.mismatchPolicy ?? 'fail';
283
334
  const strictAmount = config?.strictAmountValidation ?? true;
284
335
  const strictNetwork = config?.strictNetworkValidation ?? true;
285
- // Pre-term-matching default: we know if hint was provided, but haven't checked for mismatch yet
336
+ // Default matches upstream: validUntil is optional
337
+ const offerExpiryPolicy = config?.offerExpiryPolicy ?? 'allow_missing';
338
+ const addressComparator = config?.addressComparator ?? types_js_1.defaultAddressComparator;
339
+ const maxJwsBytes = config?.maxCompactJwsBytes;
340
+ const acceptIndex = offer.acceptIndex;
286
341
  const hintProvided = acceptIndex !== undefined;
287
342
  const preMatchTermMatching = {
288
343
  method: 'scan',
289
344
  hintProvided,
290
345
  hintMismatchDetected: false,
291
346
  };
292
- // 1. Structural validation
293
- const structErrors = validateOfferStructure(offer);
294
- if (structErrors.length > 0) {
347
+ // 1. Wire validation
348
+ const wireResult = verifyOfferWire(offer);
349
+ if (!wireResult.valid) {
350
+ return {
351
+ valid: false,
352
+ usedHint: false,
353
+ errors: wireResult.errors,
354
+ termMatching: preMatchTermMatching,
355
+ };
356
+ }
357
+ // 2. Extract and normalize payload
358
+ let rawPayload;
359
+ try {
360
+ rawPayload = (0, raw_js_1.extractOfferPayload)(offer, maxJwsBytes);
361
+ }
362
+ catch (e) {
363
+ const msg = e instanceof Error ? e.message : String(e);
364
+ const code = e instanceof errors_js_1.X402Error ? e.code : 'offer_invalid_format';
295
365
  return {
296
366
  valid: false,
297
367
  usedHint: false,
298
- errors: structErrors,
368
+ errors: [{ code, message: msg }],
299
369
  termMatching: preMatchTermMatching,
300
370
  };
301
371
  }
302
- const payload = offer.payload;
303
- // 2. DoS guard: check accepts array count
372
+ const payload = (0, normalize_js_1.normalizeOfferPayload)(rawPayload);
373
+ // 3. DoS guard: check accepts array count
304
374
  if (accepts.length > maxAccepts) {
305
375
  errors.push({
306
376
  code: 'accept_too_many_entries',
@@ -308,32 +378,26 @@ function verifyOffer(offer, accepts, acceptIndex, config) {
308
378
  });
309
379
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
310
380
  }
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
381
+ // 3b. Validate accept entry shapes and sizes
314
382
  for (let i = 0; i < accepts.length; i++) {
315
383
  const entry = accepts[i];
316
- // First: validate shape (runtime type guard for untrusted input)
317
384
  const shapeError = validateAcceptEntryShape(entry, i);
318
385
  if (shapeError) {
319
386
  errors.push(shapeError);
320
387
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
321
388
  }
322
- // Second: check per-entry total size (bounds settlement + all fields)
323
389
  const entryError = validateAcceptEntrySize(entry, i);
324
390
  if (entryError) {
325
391
  errors.push(entryError);
326
392
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
327
393
  }
328
- // Third: check individual string field sizes for clearer error messages
329
394
  const fieldError = validateAcceptFieldBytes(entry, i);
330
395
  if (fieldError) {
331
396
  errors.push(fieldError);
332
397
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
333
398
  }
334
399
  }
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
400
+ // 3c. DoS guard: total accepts byte size
337
401
  const maxBytes = config?.maxTotalAcceptsBytes ?? types_js_1.MAX_TOTAL_ACCEPTS_BYTES;
338
402
  const acceptsBytes = countJsonBytes(accepts, maxBytes + 1);
339
403
  if (acceptsBytes === -1 || acceptsBytes > maxBytes) {
@@ -343,7 +407,17 @@ function verifyOffer(offer, accepts, acceptIndex, config) {
343
407
  });
344
408
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
345
409
  }
346
- // 3. Amount validation (if enabled)
410
+ // 4. Validate offer payload required fields
411
+ const payloadErrors = validateOfferPayloadFields(rawPayload);
412
+ if (payloadErrors.length > 0) {
413
+ return {
414
+ valid: false,
415
+ usedHint: false,
416
+ errors: payloadErrors,
417
+ termMatching: preMatchTermMatching,
418
+ };
419
+ }
420
+ // 5. Amount validation
347
421
  if (strictAmount) {
348
422
  const amountError = validateAmount(payload.amount, 'offer');
349
423
  if (amountError) {
@@ -351,7 +425,7 @@ function verifyOffer(offer, accepts, acceptIndex, config) {
351
425
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
352
426
  }
353
427
  }
354
- // 4. Network validation (if enabled)
428
+ // 6. Network validation
355
429
  if (strictNetwork && payload.network) {
356
430
  const networkError = validateNetwork(payload.network, 'offer');
357
431
  if (networkError) {
@@ -359,31 +433,40 @@ function verifyOffer(offer, accepts, acceptIndex, config) {
359
433
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
360
434
  }
361
435
  }
362
- // 5. Version check
436
+ // 7. Version check
363
437
  if (!supportedVersions.includes(payload.version)) {
364
438
  errors.push({
365
439
  code: 'offer_version_unsupported',
366
- message: `Offer version "${payload.version}" is not supported. Supported: ${supportedVersions.join(', ')}`,
440
+ message: `Offer version ${payload.version} is not supported. Supported: ${supportedVersions.join(', ')}`,
367
441
  field: 'payload.version',
368
442
  });
369
443
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
370
444
  }
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 };
445
+ // 8. Expiry check (with offerExpiryPolicy)
446
+ if (payload.validUntil !== undefined) {
447
+ // Expiry is present: check if expired
448
+ if (payload.validUntil <= now - clockSkew) {
449
+ errors.push({
450
+ code: 'offer_expired',
451
+ message: `Offer expired at ${payload.validUntil}, current time is ${now} (skew: ${clockSkew}s)`,
452
+ field: 'payload.validUntil',
453
+ });
454
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
455
+ }
379
456
  }
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 };
457
+ else {
458
+ // No expiry: check policy
459
+ if (offerExpiryPolicy === 'require') {
460
+ errors.push({
461
+ code: 'offer_no_expiry',
462
+ message: 'Offer has no expiry (validUntil absent or zero); offerExpiryPolicy requires expiry',
463
+ field: 'payload.validUntil',
464
+ });
465
+ return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
466
+ }
467
+ // 'allow_missing': continue without expiry
385
468
  }
386
- // 8. Accept selection via term-matching with mismatchPolicy support
469
+ // 9. Accept selection via term-matching
387
470
  if (accepts.length === 0) {
388
471
  errors.push({
389
472
  code: 'accept_no_match',
@@ -391,74 +474,165 @@ function verifyOffer(offer, accepts, acceptIndex, config) {
391
474
  });
392
475
  return { valid: false, usedHint: false, errors, termMatching: preMatchTermMatching };
393
476
  }
394
- return verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy, errors);
477
+ return verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy, errors, addressComparator);
395
478
  }
479
+ // ---------------------------------------------------------------------------
480
+ // Layer 3: Receipt Semantic Verification
481
+ // ---------------------------------------------------------------------------
396
482
  /**
397
483
  * Verify an x402 signed receipt
398
484
  *
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
485
+ * Extracts and normalizes payload, then performs:
486
+ * - Required field validation (version, network, resourceUrl, payer, issuedAt)
487
+ * - Version check
488
+ * - Payer format check
489
+ * - issuedAt recency check
490
+ * - Network validation
405
491
  */
406
492
  function verifyReceipt(receipt, config) {
407
493
  const errors = [];
408
- const strictAmount = config?.strictAmountValidation ?? true;
409
- const strictNetwork = config?.strictNetworkValidation ?? true;
410
494
  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)) {
495
+ const strictNetwork = config?.strictNetworkValidation ?? true;
496
+ const recencySeconds = config?.receiptRecencySeconds ?? DEFAULT_RECEIPT_RECENCY_SECONDS;
497
+ const now = config?.nowSeconds ?? Math.floor(Date.now() / 1000);
498
+ const clockSkew = config?.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
499
+ const maxJwsBytes = config?.maxCompactJwsBytes;
500
+ // 1. Wire validation
501
+ const wireResult = verifyReceiptWire(receipt);
502
+ if (!wireResult.valid) {
503
+ return { valid: false, errors: wireResult.errors };
504
+ }
505
+ // 2. Extract and normalize payload
506
+ let rawPayload;
507
+ try {
508
+ rawPayload = (0, raw_js_1.extractReceiptPayload)(receipt, maxJwsBytes);
509
+ }
510
+ catch (e) {
511
+ const msg = e instanceof Error ? e.message : String(e);
512
+ const code = e instanceof errors_js_1.X402Error ? e.code : 'receipt_invalid_format';
513
+ return { valid: false, errors: [{ code, message: msg }] };
514
+ }
515
+ const payload = (0, normalize_js_1.normalizeReceiptPayload)(rawPayload);
516
+ // 3. Validate required fields
517
+ const fieldErrors = validateReceiptPayloadFields(rawPayload);
518
+ if (fieldErrors.length > 0) {
519
+ return { valid: false, errors: fieldErrors };
520
+ }
521
+ // 4. Version check
522
+ if (!supportedVersions.includes(payload.version)) {
418
523
  errors.push({
419
524
  code: 'receipt_version_unsupported',
420
- message: `Receipt version "${receipt.payload.version}" is not supported. Supported: ${supportedVersions.join(', ')}`,
525
+ message: `Receipt version ${payload.version} is not supported. Supported: ${supportedVersions.join(', ')}`,
421
526
  field: 'payload.version',
422
527
  });
423
528
  return { valid: false, errors };
424
529
  }
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');
530
+ // 5. Network validation
531
+ if (strictNetwork && payload.network) {
532
+ const networkError = validateNetwork(payload.network, 'receipt');
442
533
  if (networkError) {
443
534
  errors.push(networkError);
444
535
  return { valid: false, errors };
445
536
  }
446
537
  }
538
+ // 6. Payer validation (non-empty string)
539
+ if (!payload.payer || typeof payload.payer !== 'string' || payload.payer.trim() === '') {
540
+ errors.push({
541
+ code: 'receipt_payer_invalid',
542
+ message: 'Receipt payer must be a non-empty string',
543
+ field: 'payload.payer',
544
+ });
545
+ return { valid: false, errors };
546
+ }
547
+ // 7. issuedAt recency check
548
+ if (payload.issuedAt < now - recencySeconds - clockSkew) {
549
+ errors.push({
550
+ code: 'receipt_issuedAt_stale',
551
+ message: `Receipt issuedAt ${payload.issuedAt} is too old (current: ${now}, recency window: ${recencySeconds}s, skew: ${clockSkew}s)`,
552
+ field: 'payload.issuedAt',
553
+ });
554
+ return { valid: false, errors };
555
+ }
556
+ // 8. Transaction validation (if present, must be non-empty string)
557
+ if (payload.transaction !== undefined &&
558
+ (typeof payload.transaction !== 'string' || payload.transaction.trim() === '')) {
559
+ errors.push({
560
+ code: 'receipt_invalid_format',
561
+ message: 'Receipt transaction, if present, must be a non-empty string',
562
+ field: 'payload.transaction',
563
+ });
564
+ return { valid: false, errors };
565
+ }
447
566
  return { valid: true, errors: [] };
448
567
  }
449
568
  // ---------------------------------------------------------------------------
450
- // Term Matching
569
+ // Layer 4: Offer-Receipt Consistency
451
570
  // ---------------------------------------------------------------------------
452
571
  /**
453
- * Match offer payload fields against a single accept entry
572
+ * Verify consistency between an offer and a receipt
454
573
  *
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.
574
+ * Checks:
575
+ * - resourceUrl must match (exact string comparison)
576
+ * - network must match
577
+ * - issuedAt freshness relative to offer validity window
578
+ * - payer must match one of payerCandidates (if provided, network-aware)
579
+ */
580
+ function verifyOfferReceiptConsistency(offerPayload, receiptPayload, config, options) {
581
+ const errors = [];
582
+ const clockSkew = config?.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
583
+ const addressComparator = options?.addressComparator ?? config?.addressComparator ?? types_js_1.defaultAddressComparator;
584
+ // 1. resourceUrl must match
585
+ if (offerPayload.resourceUrl !== receiptPayload.resourceUrl) {
586
+ errors.push({
587
+ code: 'receipt_resource_mismatch',
588
+ message: `Receipt resourceUrl "${receiptPayload.resourceUrl}" does not match offer resourceUrl "${offerPayload.resourceUrl}"`,
589
+ field: 'payload.resourceUrl',
590
+ });
591
+ }
592
+ // 2. network must match
593
+ if (offerPayload.network !== receiptPayload.network) {
594
+ errors.push({
595
+ code: 'receipt_network_mismatch',
596
+ message: `Receipt network "${receiptPayload.network}" does not match offer network "${offerPayload.network}"`,
597
+ field: 'payload.network',
598
+ });
599
+ }
600
+ // 3. issuedAt freshness: receipt must be issued within the offer's validity window
601
+ if (offerPayload.validUntil !== undefined &&
602
+ receiptPayload.issuedAt > offerPayload.validUntil + clockSkew) {
603
+ errors.push({
604
+ code: 'receipt_issuedAt_stale',
605
+ message: `Receipt issuedAt ${receiptPayload.issuedAt} is after offer validUntil ${offerPayload.validUntil}`,
606
+ field: 'payload.issuedAt',
607
+ });
608
+ }
609
+ // 4. Payer candidate check
610
+ if (options?.payerCandidates && options.payerCandidates.length > 0) {
611
+ const network = receiptPayload.network;
612
+ const payer = receiptPayload.payer;
613
+ const matched = options.payerCandidates.some((candidate) => addressComparator(payer, candidate, network));
614
+ if (!matched) {
615
+ errors.push({
616
+ code: 'receipt_payer_not_in_candidates',
617
+ message: `Receipt payer "${payer}" does not match any expected payer candidate`,
618
+ field: 'payload.payer',
619
+ });
620
+ }
621
+ }
622
+ return { valid: errors.length === 0, errors };
623
+ }
624
+ // ---------------------------------------------------------------------------
625
+ // Term Matching
626
+ // ---------------------------------------------------------------------------
627
+ /**
628
+ * Match normalized offer payload fields against a single accept entry
458
629
  *
630
+ * Compares: network, asset, payTo, amount, scheme
631
+ * All fields must match. Amount comparison is string-based.
632
+ * payTo comparison uses the address comparator (network-aware).
459
633
  * @returns Array of mismatched field names (empty = match)
460
634
  */
461
- function matchAcceptTerms(payload, accept) {
635
+ function matchAcceptTerms(payload, accept, addressComparator = types_js_1.defaultAddressComparator) {
462
636
  const mismatches = [];
463
637
  if (payload.network !== accept.network) {
464
638
  mismatches.push('network');
@@ -466,17 +640,16 @@ function matchAcceptTerms(payload, accept) {
466
640
  if (payload.asset !== accept.asset) {
467
641
  mismatches.push('asset');
468
642
  }
469
- if (payload.payTo !== accept.payTo) {
643
+ // Network-aware address comparison
644
+ if (!addressComparator(payload.payTo, accept.payTo, payload.network)) {
470
645
  mismatches.push('payTo');
471
646
  }
472
647
  if (payload.amount !== accept.amount) {
473
648
  mismatches.push('amount');
474
649
  }
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
- }
650
+ // scheme is always compared (required per upstream)
651
+ if (payload.scheme !== accept.scheme) {
652
+ mismatches.push('scheme');
480
653
  }
481
654
  return mismatches;
482
655
  }
@@ -491,27 +664,20 @@ function matchAcceptTerms(payload, accept) {
491
664
  * - If absent: scan all entries for a unique match
492
665
  * - If multiple matches: fail closed (ambiguous)
493
666
  * - 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
667
  */
500
- function selectAccept(payload, accepts, acceptIndex) {
668
+ function selectAccept(payload, accepts, acceptIndex, addressComparator = types_js_1.defaultAddressComparator) {
501
669
  if (accepts.length === 0) {
502
670
  throw new errors_js_1.X402Error('accept_no_match', 'No accept entries provided');
503
671
  }
504
672
  if (acceptIndex !== undefined) {
505
- // Bounds check
506
673
  if (acceptIndex < 0 || acceptIndex >= accepts.length) {
507
674
  throw new errors_js_1.X402Error('accept_index_out_of_range', `acceptIndex ${acceptIndex} is out of range [0, ${accepts.length - 1}]`, {
508
675
  field: 'acceptIndex',
509
676
  details: { acceptIndex, acceptsLength: accepts.length },
510
677
  });
511
678
  }
512
- // Term-match the hinted entry
513
679
  const entry = accepts[acceptIndex];
514
- const mismatches = matchAcceptTerms(payload, entry);
680
+ const mismatches = matchAcceptTerms(payload, entry, addressComparator);
515
681
  if (mismatches.length > 0) {
516
682
  throw new errors_js_1.X402Error('accept_term_mismatch', `acceptIndex ${acceptIndex} does not match signed payload on: ${mismatches.join(', ')}`, {
517
683
  field: 'acceptIndex',
@@ -520,10 +686,9 @@ function selectAccept(payload, accepts, acceptIndex) {
520
686
  }
521
687
  return { entry, index: acceptIndex, usedHint: true };
522
688
  }
523
- // Full scan: find all matching entries
524
689
  const matches = [];
525
690
  for (let i = 0; i < accepts.length; i++) {
526
- const mismatches = matchAcceptTerms(payload, accepts[i]);
691
+ const mismatches = matchAcceptTerms(payload, accepts[i], addressComparator);
527
692
  if (mismatches.length === 0) {
528
693
  matches.push({ entry: accepts[i], index: i });
529
694
  }
@@ -542,44 +707,18 @@ function selectAccept(payload, accepts, acceptIndex) {
542
707
  // Internal Helpers
543
708
  // ---------------------------------------------------------------------------
544
709
  /**
545
- * Validate offer structural requirements (fail-closed)
710
+ * Validate raw offer payload required fields
546
711
  */
547
- function validateOfferStructure(offer) {
712
+ function validateOfferPayloadFields(p) {
548
713
  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
714
  const requiredFields = [
577
- ['version', p.version, 'string'],
578
- ['validUntil', p.validUntil, 'number'],
715
+ ['version', p.version, 'number'],
579
716
  ['network', p.network, 'string'],
580
717
  ['asset', p.asset, 'string'],
581
718
  ['amount', p.amount, 'string'],
582
719
  ['payTo', p.payTo, 'string'],
720
+ ['resourceUrl', p.resourceUrl, 'string'],
721
+ ['scheme', p.scheme, 'string'],
583
722
  ];
584
723
  for (const [name, value, expectedType] of requiredFields) {
585
724
  if (value === undefined || value === null) {
@@ -597,43 +736,27 @@ function validateOfferStructure(offer) {
597
736
  });
598
737
  }
599
738
  }
739
+ // resourceUrl must be non-empty
740
+ if (typeof p.resourceUrl === 'string' && p.resourceUrl.trim() === '') {
741
+ errors.push({
742
+ code: 'offer_invalid_format',
743
+ message: 'Offer payload.resourceUrl must be a non-empty string',
744
+ field: 'payload.resourceUrl',
745
+ });
746
+ }
600
747
  return errors;
601
748
  }
602
749
  /**
603
- * Validate receipt structural requirements (fail-closed)
750
+ * Validate raw receipt payload required fields
604
751
  */
605
- function validateReceiptStructure(receipt) {
752
+ function validateReceiptPayloadFields(p) {
606
753
  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
754
  const requiredFields = [
634
- ['version', p.version, 'string'],
755
+ ['version', p.version, 'number'],
635
756
  ['network', p.network, 'string'],
636
- ['txHash', p.txHash, 'string'],
757
+ ['resourceUrl', p.resourceUrl, 'string'],
758
+ ['payer', p.payer, 'string'],
759
+ ['issuedAt', p.issuedAt, 'number'],
637
760
  ];
638
761
  for (const [name, value, expectedType] of requiredFields) {
639
762
  if (value === undefined || value === null) {
@@ -653,43 +776,7 @@ function validateReceiptStructure(receipt) {
653
776
  }
654
777
  return errors;
655
778
  }
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
779
  function validateAmount(amount, kind) {
692
- // Check length first (DoS protection)
693
780
  if (amount.length > types_js_1.MAX_AMOUNT_LENGTH) {
694
781
  return {
695
782
  code: 'amount_invalid',
@@ -697,7 +784,6 @@ function validateAmount(amount, kind) {
697
784
  field: 'payload.amount',
698
785
  };
699
786
  }
700
- // Check format: non-negative integer string
701
787
  if (!AMOUNT_RE.test(amount)) {
702
788
  return {
703
789
  code: 'amount_invalid',
@@ -707,19 +793,8 @@ function validateAmount(amount, kind) {
707
793
  }
708
794
  return null;
709
795
  }
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
796
  function validateAcceptEntrySize(entry, index) {
720
- // Use bounded traversal instead of JSON.stringify to avoid allocating full string
721
797
  const entryBytes = countJsonBytes(entry, MAX_ENTRY_BYTES + 1);
722
- // -1 means cycle detected
723
798
  if (entryBytes === -1) {
724
799
  return {
725
800
  code: 'accept_entry_invalid',
@@ -736,12 +811,6 @@ function validateAcceptEntrySize(entry, index) {
736
811
  }
737
812
  return null;
738
813
  }
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
814
  function validateAcceptFieldBytes(entry, index) {
746
815
  const fields = [
747
816
  ['network', entry.network],
@@ -758,7 +827,6 @@ function validateAcceptFieldBytes(entry, index) {
758
827
  };
759
828
  }
760
829
  }
761
- // Amount has its own limit (MAX_AMOUNT_LENGTH = 78 chars, but amount is ASCII-only digits)
762
830
  if (entry.amount && entry.amount.length > types_js_1.MAX_AMOUNT_LENGTH) {
763
831
  return {
764
832
  code: 'amount_invalid',
@@ -768,17 +836,6 @@ function validateAcceptFieldBytes(entry, index) {
768
836
  }
769
837
  return null;
770
838
  }
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
839
  function validateNetwork(network, kind) {
783
840
  const parts = network.split(':');
784
841
  if (parts.length !== 2) {
@@ -807,29 +864,19 @@ function validateNetwork(network, kind) {
807
864
  }
808
865
  /**
809
866
  * 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
867
  */
816
- function verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy, errors) {
868
+ function verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy, errors, addressComparator) {
817
869
  const hintProvided = acceptIndex !== undefined;
818
- // ignore_and_scan: always scan, ignore acceptIndex for matching
819
870
  if (mismatchPolicy === 'ignore_and_scan') {
820
- return verifyWithScan(payload, accepts, errors, hintProvided, false);
871
+ return verifyWithScan(payload, accepts, errors, hintProvided, false, addressComparator);
821
872
  }
822
- // No hint provided: scan
823
873
  if (acceptIndex === undefined) {
824
- return verifyWithScan(payload, accepts, errors, false, false);
874
+ return verifyWithScan(payload, accepts, errors, false, false, addressComparator);
825
875
  }
826
- // Bounds check
827
876
  if (acceptIndex < 0 || acceptIndex >= accepts.length) {
828
877
  if (mismatchPolicy === 'warn_and_scan') {
829
- // Out of range counts as mismatch, fallback to scan
830
- return verifyWithScan(payload, accepts, errors, true, true);
878
+ return verifyWithScan(payload, accepts, errors, true, true, addressComparator);
831
879
  }
832
- // fail policy: reject
833
880
  errors.push({
834
881
  code: 'accept_index_out_of_range',
835
882
  message: `acceptIndex ${acceptIndex} is out of range [0, ${accepts.length - 1}]`,
@@ -842,11 +889,9 @@ function verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy,
842
889
  termMatching: { method: 'hint', hintProvided: true, hintMismatchDetected: true },
843
890
  };
844
891
  }
845
- // Term-match the hinted entry
846
892
  const entry = accepts[acceptIndex];
847
- const mismatches = matchAcceptTerms(payload, entry);
893
+ const mismatches = matchAcceptTerms(payload, entry, addressComparator);
848
894
  if (mismatches.length === 0) {
849
- // Hint matched: success
850
895
  return {
851
896
  valid: true,
852
897
  matchedAccept: entry,
@@ -856,7 +901,6 @@ function verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy,
856
901
  termMatching: { method: 'hint', hintProvided: true, hintMismatchDetected: false },
857
902
  };
858
903
  }
859
- // Hint didn't match
860
904
  if (mismatchPolicy === 'fail') {
861
905
  errors.push({
862
906
  code: 'accept_term_mismatch',
@@ -870,24 +914,16 @@ function verifyWithMismatchPolicy(payload, accepts, acceptIndex, mismatchPolicy,
870
914
  termMatching: { method: 'hint', hintProvided: true, hintMismatchDetected: true },
871
915
  };
872
916
  }
873
- // warn_and_scan: fallback to scan, record mismatch
874
- return verifyWithScan(payload, accepts, errors, true, true);
917
+ return verifyWithScan(payload, accepts, errors, true, true, addressComparator);
875
918
  }
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) {
919
+ function verifyWithScan(payload, accepts, errors, hintProvided, hintMismatchDetected, addressComparator) {
883
920
  const matches = [];
884
921
  for (let i = 0; i < accepts.length; i++) {
885
- const mismatches = matchAcceptTerms(payload, accepts[i]);
922
+ const mismatches = matchAcceptTerms(payload, accepts[i], addressComparator);
886
923
  if (mismatches.length === 0) {
887
924
  matches.push({ entry: accepts[i], index: i });
888
925
  }
889
926
  }
890
- // termMatching is always fully populated (no optional booleans for deterministic output)
891
927
  const termMatching = {
892
928
  method: 'scan',
893
929
  hintProvided,