@peac/adapter-x402 0.12.0-preview.2 → 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/README.md +46 -19
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +14 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -22
- package/dist/index.js.map +1 -1
- package/dist/map.d.ts +29 -23
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +57 -36
- package/dist/map.js.map +1 -1
- package/dist/normalize.d.ts +97 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +75 -0
- package/dist/normalize.js.map +1 -0
- package/dist/raw.d.ts +246 -0
- package/dist/raw.d.ts.map +1 -0
- package/dist/raw.js +246 -0
- package/dist/raw.js.map +1 -0
- package/dist/types.d.ts +292 -144
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -16
- package/dist/types.js.map +1 -1
- package/dist/verify.d.ts +50 -41
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +380 -344
- package/dist/verify.js.map +1 -1
- package/package.json +5 -4
package/dist/verify.js
CHANGED
|
@@ -2,46 +2,42 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* x402 Offer/Receipt verification
|
|
4
4
|
*
|
|
5
|
-
* Verification
|
|
6
|
-
* 1.
|
|
7
|
-
* 2.
|
|
8
|
-
* 3.
|
|
9
|
-
* 4.
|
|
10
|
-
* 5.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
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;
|
|
69
|
+
bytes += 4;
|
|
89
70
|
}
|
|
90
71
|
else if (typeof val === 'boolean') {
|
|
91
|
-
bytes += val ? 4 : 5;
|
|
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;
|
|
86
|
+
return -1;
|
|
107
87
|
seen.add(value);
|
|
108
88
|
if (Array.isArray(value)) {
|
|
109
|
-
bytes += 2;
|
|
89
|
+
bytes += 2;
|
|
110
90
|
stack.push({ val: value, idx: 0, isObj: false });
|
|
111
91
|
}
|
|
112
92
|
else {
|
|
113
|
-
bytes += 2;
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
195
|
+
// Amount: non-negative integer string
|
|
252
196
|
const AMOUNT_RE = /^(0|[1-9][0-9]*)$/;
|
|
253
|
-
// CAIP-2 network format
|
|
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
|
-
//
|
|
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
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
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.
|
|
293
|
-
const
|
|
294
|
-
if (
|
|
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:
|
|
368
|
+
errors: [{ code, message: msg }],
|
|
299
369
|
termMatching: preMatchTermMatching,
|
|
300
370
|
};
|
|
301
371
|
}
|
|
302
|
-
const payload =
|
|
303
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
372
|
-
if (payload.validUntil
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
|
|
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
|
|
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
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
//
|
|
569
|
+
// Layer 4: Offer-Receipt Consistency
|
|
451
570
|
// ---------------------------------------------------------------------------
|
|
452
571
|
/**
|
|
453
|
-
*
|
|
572
|
+
* Verify consistency between an offer and a receipt
|
|
454
573
|
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
476
|
-
if (payload.scheme !==
|
|
477
|
-
|
|
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
|
|
710
|
+
* Validate raw offer payload required fields
|
|
546
711
|
*/
|
|
547
|
-
function
|
|
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, '
|
|
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
|
|
750
|
+
* Validate raw receipt payload required fields
|
|
604
751
|
*/
|
|
605
|
-
function
|
|
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, '
|
|
755
|
+
['version', p.version, 'number'],
|
|
635
756
|
['network', p.network, 'string'],
|
|
636
|
-
['
|
|
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
|
-
|
|
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
|
-
|
|
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,
|