@neus/sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +181 -0
- package/README.md +206 -0
- package/SECURITY.md +224 -0
- package/client.js +844 -0
- package/errors.js +228 -0
- package/index.js +70 -0
- package/package.json +75 -0
- package/types.d.ts +550 -0
- package/utils.js +722 -0
package/utils.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NEUS SDK Utilities
|
|
3
|
+
* Core utility functions for proof creation and verification
|
|
4
|
+
* @license Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { SDKError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic JSON stringification for consistent serialization
|
|
11
|
+
* @param {Object} obj - Object to stringify
|
|
12
|
+
* @returns {string} Deterministic JSON string
|
|
13
|
+
*/
|
|
14
|
+
function deterministicStringify(obj) {
|
|
15
|
+
if (obj === null || obj === undefined) {
|
|
16
|
+
return JSON.stringify(obj);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof obj !== 'object') {
|
|
20
|
+
return JSON.stringify(obj);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(obj)) {
|
|
24
|
+
return '[' + obj.map(item => deterministicStringify(item)).join(',') + ']';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Sort object keys for deterministic output
|
|
28
|
+
const sortedKeys = Object.keys(obj).sort();
|
|
29
|
+
const pairs = sortedKeys.map(key => JSON.stringify(key) + ':' + deterministicStringify(obj[key]));
|
|
30
|
+
|
|
31
|
+
return '{' + pairs.join(',') + '}';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Construct verification message for wallet signing
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} params - Message parameters
|
|
38
|
+
* @param {string} params.walletAddress - Wallet address
|
|
39
|
+
* @param {number} params.signedTimestamp - Unix timestamp
|
|
40
|
+
* @param {Object} params.data - Verification data
|
|
41
|
+
* @param {Array<string>} params.verifierIds - Array of verifier IDs
|
|
42
|
+
* @param {number} params.chainId - Chain ID
|
|
43
|
+
* @returns {string} Message for signing
|
|
44
|
+
*/
|
|
45
|
+
export function constructVerificationMessage({
|
|
46
|
+
walletAddress,
|
|
47
|
+
signedTimestamp,
|
|
48
|
+
data,
|
|
49
|
+
verifierIds,
|
|
50
|
+
chainId
|
|
51
|
+
}) {
|
|
52
|
+
// Input validation for critical parameters
|
|
53
|
+
if (!walletAddress || typeof walletAddress !== 'string') {
|
|
54
|
+
throw new SDKError('walletAddress is required and must be a string', 'INVALID_WALLET_ADDRESS');
|
|
55
|
+
}
|
|
56
|
+
if (!signedTimestamp || typeof signedTimestamp !== 'number') {
|
|
57
|
+
throw new SDKError('signedTimestamp is required and must be a number', 'INVALID_TIMESTAMP');
|
|
58
|
+
}
|
|
59
|
+
if (!data || typeof data !== 'object') {
|
|
60
|
+
throw new SDKError('data is required and must be an object', 'INVALID_DATA');
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
|
|
63
|
+
throw new SDKError(
|
|
64
|
+
'verifierIds is required and must be a non-empty array',
|
|
65
|
+
'INVALID_VERIFIER_IDS'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!chainId || typeof chainId !== 'number') {
|
|
69
|
+
throw new SDKError('chainId is required and must be a number', 'INVALID_CHAIN_ID');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize wallet address to lowercase for consistency (CRITICAL for signature recovery)
|
|
73
|
+
const normalizedWalletAddress = walletAddress.toLowerCase();
|
|
74
|
+
|
|
75
|
+
// CRITICAL: Use deterministic JSON serialization for consistency with backend
|
|
76
|
+
// This MUST match backend serialization exactly for signature verification to work
|
|
77
|
+
// NOTE: Any modification to this serialization will break signature verification
|
|
78
|
+
const dataString = deterministicStringify(data);
|
|
79
|
+
|
|
80
|
+
// Create canonical message format - EXACT format expected by backend
|
|
81
|
+
const messageComponents = [
|
|
82
|
+
'NEUS Verification Request',
|
|
83
|
+
`Wallet: ${normalizedWalletAddress}`,
|
|
84
|
+
`Chain: ${chainId}`,
|
|
85
|
+
`Verifiers: ${verifierIds.join(',')}`,
|
|
86
|
+
`Data: ${dataString}`,
|
|
87
|
+
`Timestamp: ${signedTimestamp}`
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Join with newlines - this is the message that gets signed
|
|
91
|
+
return messageComponents.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate Ethereum wallet address format
|
|
96
|
+
*
|
|
97
|
+
* @param {string} address - Address to validate
|
|
98
|
+
* @returns {boolean} True if valid Ethereum address
|
|
99
|
+
*/
|
|
100
|
+
export function validateWalletAddress(address) {
|
|
101
|
+
if (!address || typeof address !== 'string') {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Basic Ethereum address validation
|
|
106
|
+
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate timestamp freshness
|
|
111
|
+
*
|
|
112
|
+
* @param {number} timestamp - Timestamp to validate
|
|
113
|
+
* @param {number} maxAgeMs - Maximum age in milliseconds (default: 5 minutes)
|
|
114
|
+
* @returns {boolean} True if timestamp is valid and recent
|
|
115
|
+
*/
|
|
116
|
+
export function validateTimestamp(timestamp, maxAgeMs = 5 * 60 * 1000) {
|
|
117
|
+
if (!timestamp || typeof timestamp !== 'number') {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const age = now - timestamp;
|
|
123
|
+
|
|
124
|
+
// Check if timestamp is in the past and within allowed age
|
|
125
|
+
return age >= 0 && age <= maxAgeMs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create formatted verification data object
|
|
130
|
+
*
|
|
131
|
+
* @param {string} content - Content to verify
|
|
132
|
+
* @param {string} owner - Owner wallet address
|
|
133
|
+
* @param {Object} reference - Reference object
|
|
134
|
+
* @returns {Object} Formatted verification data
|
|
135
|
+
*/
|
|
136
|
+
export function createVerificationData(content, owner, reference = null) {
|
|
137
|
+
return {
|
|
138
|
+
content,
|
|
139
|
+
owner: owner.toLowerCase(),
|
|
140
|
+
reference: reference || {
|
|
141
|
+
type: 'content',
|
|
142
|
+
id: content.substring(0, 32)
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* DERIVE DID FROM ADDRESS AND CHAIN
|
|
149
|
+
* did:pkh:eip155:<chainId>:<address_lowercase>
|
|
150
|
+
*/
|
|
151
|
+
export function deriveDid(address, chainId = 84532) {
|
|
152
|
+
if (!address || typeof address !== 'string') {
|
|
153
|
+
throw new SDKError('deriveDid: address is required', 'INVALID_ARGUMENT');
|
|
154
|
+
}
|
|
155
|
+
if (!validateWalletAddress(address)) {
|
|
156
|
+
throw new SDKError('deriveDid: invalid wallet address format', 'INVALID_ARGUMENT');
|
|
157
|
+
}
|
|
158
|
+
if (!chainId || typeof chainId !== 'number') {
|
|
159
|
+
throw new SDKError('deriveDid: chainId (number) is required', 'INVALID_ARGUMENT');
|
|
160
|
+
}
|
|
161
|
+
return `did:pkh:eip155:${chainId}:${address.toLowerCase()}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Determine if a verification status is terminal (completed or failed)
|
|
166
|
+
* @param {string} status - The verification status
|
|
167
|
+
* @returns {boolean} Whether the status is terminal
|
|
168
|
+
*/
|
|
169
|
+
export function isTerminalStatus(status) {
|
|
170
|
+
if (!status || typeof status !== 'string') return false;
|
|
171
|
+
|
|
172
|
+
// Success states
|
|
173
|
+
const successStates = [
|
|
174
|
+
'verified',
|
|
175
|
+
'verified_no_verifiers',
|
|
176
|
+
'verified_crosschain_propagated',
|
|
177
|
+
'partially_verified',
|
|
178
|
+
'verified_propagation_failed'
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
// Failure states
|
|
182
|
+
const failureStates = [
|
|
183
|
+
'rejected',
|
|
184
|
+
'rejected_verifier_failure',
|
|
185
|
+
'rejected_zk_initiation_failure',
|
|
186
|
+
'error_processing_exception',
|
|
187
|
+
'error_initialization',
|
|
188
|
+
'error_storage_unavailable',
|
|
189
|
+
'error_storage_query',
|
|
190
|
+
'not_found'
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
return successStates.includes(status) || failureStates.includes(status);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Determine if a verification status indicates success
|
|
198
|
+
* @param {string} status - The verification status
|
|
199
|
+
* @returns {boolean} Whether the status indicates success
|
|
200
|
+
*/
|
|
201
|
+
export function isSuccessStatus(status) {
|
|
202
|
+
if (!status || typeof status !== 'string') return false;
|
|
203
|
+
|
|
204
|
+
const successStates = [
|
|
205
|
+
'verified',
|
|
206
|
+
'verified_no_verifiers',
|
|
207
|
+
'verified_crosschain_propagated',
|
|
208
|
+
'partially_verified',
|
|
209
|
+
'verified_propagation_failed'
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
return successStates.includes(status);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Determine if a verification status indicates failure
|
|
217
|
+
* @param {string} status - The verification status
|
|
218
|
+
* @returns {boolean} Whether the status indicates failure
|
|
219
|
+
*/
|
|
220
|
+
export function isFailureStatus(status) {
|
|
221
|
+
if (!status || typeof status !== 'string') return false;
|
|
222
|
+
|
|
223
|
+
const failureStates = [
|
|
224
|
+
'rejected',
|
|
225
|
+
'rejected_verifier_failure',
|
|
226
|
+
'rejected_zk_initiation_failure',
|
|
227
|
+
'error_processing_exception',
|
|
228
|
+
'error_initialization',
|
|
229
|
+
'error_storage_unavailable',
|
|
230
|
+
'error_storage_query',
|
|
231
|
+
'not_found'
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
return failureStates.includes(status);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Format verification status for display
|
|
239
|
+
* @param {string} status - Raw status from API
|
|
240
|
+
* @returns {Object} Formatted status information
|
|
241
|
+
*/
|
|
242
|
+
export function formatVerificationStatus(status) {
|
|
243
|
+
const statusMap = {
|
|
244
|
+
processing_verifiers: {
|
|
245
|
+
label: 'Processing',
|
|
246
|
+
description: 'Verifiers are being executed',
|
|
247
|
+
category: 'processing',
|
|
248
|
+
color: 'blue'
|
|
249
|
+
},
|
|
250
|
+
processing_zk_proofs: {
|
|
251
|
+
label: 'Generating ZK Proofs',
|
|
252
|
+
description: 'Zero-knowledge proofs are being generated',
|
|
253
|
+
category: 'processing',
|
|
254
|
+
color: 'blue'
|
|
255
|
+
},
|
|
256
|
+
verified: {
|
|
257
|
+
label: 'Verified',
|
|
258
|
+
description: 'Verification completed successfully',
|
|
259
|
+
category: 'success',
|
|
260
|
+
color: 'green'
|
|
261
|
+
},
|
|
262
|
+
verified_crosschain_initiated: {
|
|
263
|
+
label: 'Cross-chain Initiated',
|
|
264
|
+
description: 'Verification successful, cross-chain propagation started',
|
|
265
|
+
category: 'processing',
|
|
266
|
+
color: 'blue'
|
|
267
|
+
},
|
|
268
|
+
verified_crosschain_propagating: {
|
|
269
|
+
label: 'Cross-chain Propagating',
|
|
270
|
+
description: 'Verification successful, transactions propagating to spoke chains',
|
|
271
|
+
category: 'processing',
|
|
272
|
+
color: 'blue'
|
|
273
|
+
},
|
|
274
|
+
verified_crosschain_propagated: {
|
|
275
|
+
label: 'Fully Propagated',
|
|
276
|
+
description: 'Verification completed and propagated to all target chains',
|
|
277
|
+
category: 'success',
|
|
278
|
+
color: 'green'
|
|
279
|
+
},
|
|
280
|
+
verified_no_verifiers: {
|
|
281
|
+
label: 'Verified (No Verifiers)',
|
|
282
|
+
description: 'Verification completed without specific verifiers',
|
|
283
|
+
category: 'success',
|
|
284
|
+
color: 'green'
|
|
285
|
+
},
|
|
286
|
+
verified_propagation_failed: {
|
|
287
|
+
label: 'Propagation Failed',
|
|
288
|
+
description: 'Verification successful but cross-chain propagation failed',
|
|
289
|
+
category: 'warning',
|
|
290
|
+
color: 'orange'
|
|
291
|
+
},
|
|
292
|
+
partially_verified: {
|
|
293
|
+
label: 'Partially Verified',
|
|
294
|
+
description: 'Some verifiers succeeded, others failed',
|
|
295
|
+
category: 'warning',
|
|
296
|
+
color: 'orange'
|
|
297
|
+
},
|
|
298
|
+
rejected: {
|
|
299
|
+
label: 'Rejected',
|
|
300
|
+
description: 'Verification failed',
|
|
301
|
+
category: 'error',
|
|
302
|
+
color: 'red'
|
|
303
|
+
},
|
|
304
|
+
rejected_verifier_failure: {
|
|
305
|
+
label: 'Verifier Failed',
|
|
306
|
+
description: 'One or more verifiers failed',
|
|
307
|
+
category: 'error',
|
|
308
|
+
color: 'red'
|
|
309
|
+
},
|
|
310
|
+
rejected_zk_initiation_failure: {
|
|
311
|
+
label: 'ZK Initiation Failed',
|
|
312
|
+
description: 'Zero-knowledge proof generation failed to start',
|
|
313
|
+
category: 'error',
|
|
314
|
+
color: 'red'
|
|
315
|
+
},
|
|
316
|
+
error_processing_exception: {
|
|
317
|
+
label: 'Processing Error',
|
|
318
|
+
description: 'An error occurred during verification processing',
|
|
319
|
+
category: 'error',
|
|
320
|
+
color: 'red'
|
|
321
|
+
},
|
|
322
|
+
error_initialization: {
|
|
323
|
+
label: 'Initialization Error',
|
|
324
|
+
description: 'Failed to initialize verification',
|
|
325
|
+
category: 'error',
|
|
326
|
+
color: 'red'
|
|
327
|
+
},
|
|
328
|
+
not_found: {
|
|
329
|
+
label: 'Not Found',
|
|
330
|
+
description: 'Verification record not found',
|
|
331
|
+
category: 'error',
|
|
332
|
+
color: 'red'
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
statusMap[status] || {
|
|
338
|
+
label: status?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Unknown',
|
|
339
|
+
description: 'Unknown status',
|
|
340
|
+
category: 'unknown',
|
|
341
|
+
color: 'gray'
|
|
342
|
+
}
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Compute keccak256 content hash (0x-prefixed) for arbitrary input
|
|
348
|
+
* Uses ethers (peer dependency) via dynamic import to avoid hard bundling
|
|
349
|
+
*
|
|
350
|
+
* @param {string|Uint8Array} input - Raw string (UTF-8) or bytes
|
|
351
|
+
* @returns {Promise<string>} 0x-prefixed keccak256 hash
|
|
352
|
+
*/
|
|
353
|
+
export async function computeContentHash(input) {
|
|
354
|
+
try {
|
|
355
|
+
const ethers = await import('ethers');
|
|
356
|
+
const toBytes = typeof input === 'string' ? ethers.toUtf8Bytes(input) : input;
|
|
357
|
+
return ethers.keccak256(toBytes);
|
|
358
|
+
} catch {
|
|
359
|
+
throw new SDKError(
|
|
360
|
+
'computeContentHash requires peer dependency "ethers" >= 6.0.0',
|
|
361
|
+
'MISSING_PEER_DEP'
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Create a delay/sleep function
|
|
368
|
+
* @param {number} ms - Milliseconds to wait
|
|
369
|
+
* @returns {Promise} Promise that resolves after the delay
|
|
370
|
+
*/
|
|
371
|
+
export function delay(ms) {
|
|
372
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Status Polling Utility for tracking verification progress
|
|
377
|
+
*/
|
|
378
|
+
export class StatusPoller {
|
|
379
|
+
constructor(client, qHash, options = {}) {
|
|
380
|
+
this.client = client;
|
|
381
|
+
this.qHash = qHash;
|
|
382
|
+
this.options = {
|
|
383
|
+
interval: 2000, // 2 seconds
|
|
384
|
+
maxAttempts: 150, // 5 minutes total
|
|
385
|
+
exponentialBackoff: true,
|
|
386
|
+
maxInterval: 10000, // 10 seconds max
|
|
387
|
+
...options
|
|
388
|
+
};
|
|
389
|
+
this.attempt = 0;
|
|
390
|
+
this.currentInterval = this.options.interval;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async poll() {
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
const pollAttempt = async () => {
|
|
396
|
+
try {
|
|
397
|
+
this.attempt++;
|
|
398
|
+
|
|
399
|
+
const response = await this.client.getStatus(this.qHash);
|
|
400
|
+
|
|
401
|
+
// Check if verification is complete using the terminal status utility
|
|
402
|
+
if (isTerminalStatus(response.status)) {
|
|
403
|
+
resolve(response);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Check if we've exceeded max attempts
|
|
408
|
+
if (this.attempt >= this.options.maxAttempts) {
|
|
409
|
+
reject(new SDKError('Verification polling timeout', 'POLLING_TIMEOUT'));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Schedule next poll with optional exponential backoff
|
|
414
|
+
if (this.options.exponentialBackoff) {
|
|
415
|
+
this.currentInterval = Math.min(this.currentInterval * 1.5, this.options.maxInterval);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
setTimeout(pollAttempt, this.currentInterval);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
reject(new SDKError(`Polling failed: ${error.message}`, 'POLLING_ERROR'));
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Start polling immediately
|
|
425
|
+
pollAttempt();
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* NEUS Network Constants
|
|
432
|
+
*/
|
|
433
|
+
export const NEUS_CONSTANTS = {
|
|
434
|
+
// Hub chain (where all verifications occur)
|
|
435
|
+
HUB_CHAIN_ID: 84532, // Base Sepolia
|
|
436
|
+
|
|
437
|
+
// Supported target chains for cross-chain propagation
|
|
438
|
+
TESTNET_CHAINS: [
|
|
439
|
+
11155111, // Ethereum Sepolia
|
|
440
|
+
11155420, // Optimism Sepolia
|
|
441
|
+
421614, // Arbitrum Sepolia
|
|
442
|
+
80002 // Polygon Amoy
|
|
443
|
+
],
|
|
444
|
+
|
|
445
|
+
// API endpoints
|
|
446
|
+
API_BASE_URL: 'https://api.neus.network',
|
|
447
|
+
API_VERSION: 'v1',
|
|
448
|
+
|
|
449
|
+
// Timeouts and limits
|
|
450
|
+
SIGNATURE_MAX_AGE_MS: 5 * 60 * 1000, // 5 minutes
|
|
451
|
+
REQUEST_TIMEOUT_MS: 30 * 1000, // 30 seconds
|
|
452
|
+
|
|
453
|
+
// Day-one verifiers (available at launch)
|
|
454
|
+
DEFAULT_VERIFIERS: ['ownership-basic', 'nft-ownership', 'token-holding', 'ownership-licensed']
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Additional validation and utility helpers
|
|
459
|
+
*/
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Validate qHash format (0x + 64 hex chars)
|
|
463
|
+
* @param {string} qHash - The qHash to validate
|
|
464
|
+
* @returns {boolean} True if valid qHash format
|
|
465
|
+
*/
|
|
466
|
+
export function validateQHash(qHash) {
|
|
467
|
+
return typeof qHash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(qHash);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Format timestamp to human readable string
|
|
472
|
+
* @param {number} timestamp - Unix timestamp
|
|
473
|
+
* @returns {string} Formatted date string
|
|
474
|
+
*/
|
|
475
|
+
export function formatTimestamp(timestamp) {
|
|
476
|
+
return new Date(timestamp).toLocaleString();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Check if a chain ID is supported for cross-chain propagation
|
|
481
|
+
* @param {number} chainId - Chain ID to check
|
|
482
|
+
* @returns {boolean} True if supported
|
|
483
|
+
*/
|
|
484
|
+
export function isSupportedChain(chainId) {
|
|
485
|
+
return NEUS_CONSTANTS.TESTNET_CHAINS.includes(chainId) || chainId === NEUS_CONSTANTS.HUB_CHAIN_ID;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Normalize wallet address to lowercase (EIP-55 agnostic)
|
|
490
|
+
* @param {string} address - Wallet address to normalize
|
|
491
|
+
* @returns {string} Lowercase address
|
|
492
|
+
*/
|
|
493
|
+
export function normalizeAddress(address) {
|
|
494
|
+
if (!validateWalletAddress(address)) {
|
|
495
|
+
throw new SDKError('Invalid wallet address format', 'INVALID_ADDRESS');
|
|
496
|
+
}
|
|
497
|
+
return address.toLowerCase();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Validate a verifier payload for basic structural integrity.
|
|
502
|
+
* Lightweight validation checks; verifier authors should document complete schemas.
|
|
503
|
+
* @param {string} verifierId - Verifier identifier (e.g., 'ownership-basic' or custom)
|
|
504
|
+
* @param {any} data - Verifier-specific payload
|
|
505
|
+
* @returns {{ valid: boolean, error?: string, missing?: string[], warnings?: string[] }}
|
|
506
|
+
*/
|
|
507
|
+
export function validateVerifierPayload(verifierId, data) {
|
|
508
|
+
const result = { valid: true, missing: [], warnings: [] };
|
|
509
|
+
|
|
510
|
+
if (!verifierId || typeof verifierId !== 'string') {
|
|
511
|
+
return { valid: false, error: 'verifierId is required and must be a string' };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
515
|
+
return { valid: false, error: 'data must be a non-null object' };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Best-effort field hints for day-one built-ins
|
|
519
|
+
const id = verifierId.replace(/@\d+$/, '');
|
|
520
|
+
if (id === 'nft-ownership') {
|
|
521
|
+
['ownerAddress', 'contractAddress', 'tokenId', 'chainId'].forEach(key => {
|
|
522
|
+
if (!(key in data)) result.missing.push(key);
|
|
523
|
+
});
|
|
524
|
+
} else if (id === 'token-holding') {
|
|
525
|
+
['ownerAddress', 'contractAddress', 'minBalance', 'chainId'].forEach(key => {
|
|
526
|
+
if (!(key in data)) result.missing.push(key);
|
|
527
|
+
});
|
|
528
|
+
} else if (id === 'ownership-basic') {
|
|
529
|
+
['content'].forEach(key => {
|
|
530
|
+
if (!(key in data)) result.missing.push(key);
|
|
531
|
+
});
|
|
532
|
+
} else if (id === 'ownership-licensed') {
|
|
533
|
+
['content', 'owner', 'license'].forEach(key => {
|
|
534
|
+
if (!(key in data)) result.missing.push(key);
|
|
535
|
+
});
|
|
536
|
+
if (data.license && typeof data.license === 'object') {
|
|
537
|
+
['contractAddress', 'tokenId', 'chainId', 'ownerAddress'].forEach(key => {
|
|
538
|
+
if (!(key in data.license)) result.missing.push(`license.${key}`);
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (result.missing.length > 0) {
|
|
544
|
+
result.valid = false;
|
|
545
|
+
result.error = `Missing required fields: ${result.missing.join(', ')}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return result;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Build a canonical verification request and signing message for manual flows.
|
|
553
|
+
* Returns the message to sign and the request body (sans signature).
|
|
554
|
+
* @param {Object} params
|
|
555
|
+
* @param {string[]} params.verifierIds
|
|
556
|
+
* @param {object} params.data
|
|
557
|
+
* @param {string} params.walletAddress
|
|
558
|
+
* @param {number} [params.chainId=NEUS_CONSTANTS.HUB_CHAIN_ID]
|
|
559
|
+
* @param {object} [params.options]
|
|
560
|
+
* @param {number} [params.signedTimestamp=Date.now()]
|
|
561
|
+
* @returns {{ message: string, request: { verifierIds: string[], data: object, walletAddress: string, signedTimestamp: number, chainId: number, options?: object } }}
|
|
562
|
+
*/
|
|
563
|
+
export function buildVerificationRequest({
|
|
564
|
+
verifierIds,
|
|
565
|
+
data,
|
|
566
|
+
walletAddress,
|
|
567
|
+
chainId = NEUS_CONSTANTS.HUB_CHAIN_ID,
|
|
568
|
+
options = undefined,
|
|
569
|
+
signedTimestamp = Date.now()
|
|
570
|
+
}) {
|
|
571
|
+
if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
|
|
572
|
+
throw new SDKError('verifierIds must be a non-empty array', 'INVALID_ARGUMENT');
|
|
573
|
+
}
|
|
574
|
+
if (!validateWalletAddress(walletAddress)) {
|
|
575
|
+
throw new SDKError('walletAddress must be a valid 0x address', 'INVALID_ARGUMENT');
|
|
576
|
+
}
|
|
577
|
+
if (!data || typeof data !== 'object') {
|
|
578
|
+
throw new SDKError('data must be a non-null object', 'INVALID_ARGUMENT');
|
|
579
|
+
}
|
|
580
|
+
if (typeof chainId !== 'number') {
|
|
581
|
+
throw new SDKError('chainId must be a number', 'INVALID_ARGUMENT');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const message = constructVerificationMessage({
|
|
585
|
+
walletAddress,
|
|
586
|
+
signedTimestamp,
|
|
587
|
+
data,
|
|
588
|
+
verifierIds,
|
|
589
|
+
chainId
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const request = {
|
|
593
|
+
verifierIds,
|
|
594
|
+
data,
|
|
595
|
+
walletAddress,
|
|
596
|
+
signedTimestamp,
|
|
597
|
+
chainId,
|
|
598
|
+
...(options ? { options } : {})
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
return { message, request };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Create a retry utility with exponential backoff
|
|
606
|
+
* @param {Function} fn - Function to retry
|
|
607
|
+
* @param {Object} options - Retry options
|
|
608
|
+
* @returns {Promise} Promise that resolves with function result
|
|
609
|
+
*/
|
|
610
|
+
export async function withRetry(fn, options = {}) {
|
|
611
|
+
const { maxAttempts = 3, baseDelay = 1000, maxDelay = 10000, backoffFactor = 2 } = options;
|
|
612
|
+
|
|
613
|
+
let lastError;
|
|
614
|
+
|
|
615
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
616
|
+
try {
|
|
617
|
+
return await fn();
|
|
618
|
+
} catch (error) {
|
|
619
|
+
lastError = error;
|
|
620
|
+
|
|
621
|
+
if (attempt === maxAttempts) break;
|
|
622
|
+
|
|
623
|
+
const delayMs = Math.min(baseDelay * Math.pow(backoffFactor, attempt - 1), maxDelay);
|
|
624
|
+
|
|
625
|
+
await delay(delayMs);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
throw lastError;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Validate signature components for debugging signature verification issues
|
|
634
|
+
* @param {Object} params - Signature components to validate
|
|
635
|
+
* @param {string} params.walletAddress - Wallet address
|
|
636
|
+
* @param {string} params.signature - EIP-191 signature
|
|
637
|
+
* @param {number} params.signedTimestamp - Unix timestamp
|
|
638
|
+
* @param {Object} params.data - Verification data
|
|
639
|
+
* @param {Array<string>} params.verifierIds - Array of verifier IDs
|
|
640
|
+
* @param {number} params.chainId - Chain ID
|
|
641
|
+
* @returns {Object} Validation result with detailed feedback
|
|
642
|
+
*/
|
|
643
|
+
export function validateSignatureComponents({
|
|
644
|
+
walletAddress,
|
|
645
|
+
signature,
|
|
646
|
+
signedTimestamp,
|
|
647
|
+
data,
|
|
648
|
+
verifierIds,
|
|
649
|
+
chainId
|
|
650
|
+
}) {
|
|
651
|
+
const result = {
|
|
652
|
+
valid: true,
|
|
653
|
+
errors: [],
|
|
654
|
+
warnings: [],
|
|
655
|
+
debugInfo: {}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// Validate wallet address
|
|
659
|
+
if (!validateWalletAddress(walletAddress)) {
|
|
660
|
+
result.valid = false;
|
|
661
|
+
result.errors.push('Invalid wallet address format - must be 0x + 40 hex characters');
|
|
662
|
+
} else {
|
|
663
|
+
result.debugInfo.normalizedAddress = walletAddress.toLowerCase();
|
|
664
|
+
if (walletAddress !== walletAddress.toLowerCase()) {
|
|
665
|
+
result.warnings.push('Wallet address should be lowercase for consistency');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Validate signature format
|
|
670
|
+
if (!signature || typeof signature !== 'string') {
|
|
671
|
+
result.valid = false;
|
|
672
|
+
result.errors.push('Signature is required and must be a string');
|
|
673
|
+
} else if (!/^0x[a-fA-F0-9]{130}$/.test(signature)) {
|
|
674
|
+
result.valid = false;
|
|
675
|
+
result.errors.push('Invalid signature format - must be 0x + 130 hex characters (65 bytes)');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Validate timestamp
|
|
679
|
+
if (!validateTimestamp(signedTimestamp)) {
|
|
680
|
+
result.valid = false;
|
|
681
|
+
result.errors.push('Invalid or expired timestamp - must be within 5 minutes');
|
|
682
|
+
} else {
|
|
683
|
+
result.debugInfo.timestampAge = Date.now() - signedTimestamp;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Validate data object
|
|
687
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
688
|
+
result.valid = false;
|
|
689
|
+
result.errors.push('Data must be a non-null object');
|
|
690
|
+
} else {
|
|
691
|
+
result.debugInfo.dataString = deterministicStringify(data);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Validate verifier IDs
|
|
695
|
+
if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
|
|
696
|
+
result.valid = false;
|
|
697
|
+
result.errors.push('VerifierIds must be a non-empty array');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Validate chain ID
|
|
701
|
+
if (typeof chainId !== 'number') {
|
|
702
|
+
result.valid = false;
|
|
703
|
+
result.errors.push('ChainId must be a number');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Generate the message that would be signed
|
|
707
|
+
if (result.valid || result.errors.length < 3) {
|
|
708
|
+
try {
|
|
709
|
+
result.debugInfo.messageToSign = constructVerificationMessage({
|
|
710
|
+
walletAddress: walletAddress?.toLowerCase() || walletAddress,
|
|
711
|
+
signedTimestamp,
|
|
712
|
+
data,
|
|
713
|
+
verifierIds,
|
|
714
|
+
chainId
|
|
715
|
+
});
|
|
716
|
+
} catch (error) {
|
|
717
|
+
result.errors.push(`Failed to construct message: ${error.message}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return result;
|
|
722
|
+
}
|