@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/README.md +116 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +62 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/map.d.ts +56 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/map.js +111 -0
- package/dist/map.js.map +1 -0
- package/dist/types.d.ts +392 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +58 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +80 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +919 -0
- package/dist/verify.js.map +1 -0
- package/package.json +37 -0
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
|