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