@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/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
+ }