@neus/sdk 1.0.2 → 1.0.4

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,778 +1,1292 @@
1
- /**
2
- * NEUS SDK Utilities
3
- * Core utility functions for proof creation and verification
4
- */
5
-
6
- import { SDKError, ApiError, ValidationError } 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 (e.g. "solana:mainnet"),
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 universal 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
- if (error instanceof ValidationError) {
446
- reject(error);
447
- return;
448
- }
449
-
450
- if ((error instanceof ApiError && error.statusCode === 429) || error?.isRetryable === true) {
451
- if (this.options.exponentialBackoff) {
452
- const next = Math.min(this.currentInterval * 2, this.options.maxInterval);
453
- const jitter = next * (0.5 + Math.random() * 0.5);
454
- this.currentInterval = Math.max(250, Math.floor(jitter));
455
- }
456
-
457
- if (this.attempt >= this.options.maxAttempts) {
458
- reject(new SDKError('Verification polling timeout', 'POLLING_TIMEOUT'));
459
- return;
460
- }
461
-
462
- setTimeout(pollAttempt, this.currentInterval);
463
- return;
464
- }
465
-
466
- reject(new SDKError(`Polling failed: ${error.message}`, 'POLLING_ERROR'));
467
- }
468
- };
469
-
470
- // Start polling immediately
471
- pollAttempt();
472
- });
473
- }
474
- }
475
-
476
- /**
477
- * NEUS Network Constants
478
- */
479
- export const NEUS_CONSTANTS = {
480
- // Hub chain (where all verifications occur)
481
- HUB_CHAIN_ID: 84532,
482
-
483
- // Supported target chains for cross-chain propagation
484
- TESTNET_CHAINS: [
485
- 11155111, // Ethereum Sepolia
486
- 11155420, // Optimism Sepolia
487
- 421614, // Arbitrum Sepolia
488
- 80002 // Polygon Amoy
489
- ],
490
-
491
- // API endpoints
492
- API_BASE_URL: 'https://api.neus.network',
493
- API_VERSION: 'v1',
494
-
495
- // Timeouts and limits
496
- SIGNATURE_MAX_AGE_MS: 5 * 60 * 1000, // 5 minutes
497
- REQUEST_TIMEOUT_MS: 30 * 1000, // 30 seconds
498
-
499
- // Default verifier set for quick starts
500
- DEFAULT_VERIFIERS: [
501
- 'ownership-basic',
502
- 'nft-ownership',
503
- 'token-holding'
504
- ]
505
- };
506
-
507
- /**
508
- * Additional validation and utility helpers
509
- */
510
-
511
- /**
512
- * Validate qHash format (0x + 64 hex chars)
513
- * @param {string} qHash - The qHash to validate
514
- * @returns {boolean} True if valid qHash format
515
- */
516
- export function validateQHash(qHash) {
517
- return typeof qHash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(qHash);
518
- }
519
-
520
- /**
521
- * Format timestamp to human readable string
522
- * @param {number} timestamp - Unix timestamp
523
- * @returns {string} Formatted date string
524
- */
525
- export function formatTimestamp(timestamp) {
526
- return new Date(timestamp).toLocaleString();
527
- }
528
-
529
- /**
530
- * Check if a chain ID is supported for cross-chain propagation
531
- * @param {number} chainId - Chain ID to check
532
- * @returns {boolean} True if supported
533
- */
534
- export function isSupportedChain(chainId) {
535
- return NEUS_CONSTANTS.TESTNET_CHAINS.includes(chainId) || chainId === NEUS_CONSTANTS.HUB_CHAIN_ID;
536
- }
537
-
538
- /**
539
- * Normalize wallet address to lowercase (EIP-55 agnostic)
540
- * @param {string} address - Wallet address to normalize
541
- * @returns {string} Lowercase address
542
- */
543
- export function normalizeAddress(address) {
544
- if (!validateWalletAddress(address)) {
545
- throw new SDKError('Invalid wallet address format', 'INVALID_ADDRESS');
546
- }
547
- return address.toLowerCase();
548
- }
549
-
550
- /**
551
- * Validate a verifier payload for basic structural integrity.
552
- * Lightweight validation checks; verifier authors should document complete schemas.
553
- * @param {string} verifierId - Verifier identifier (e.g., 'ownership-basic' or custom)
554
- * @param {any} data - Verifier-specific payload
555
- * @returns {{ valid: boolean, error?: string, missing?: string[], warnings?: string[] }}
556
- */
557
- export function validateVerifierPayload(verifierId, data) {
558
- const result = { valid: true, missing: [], warnings: [] };
559
-
560
- if (!verifierId || typeof verifierId !== 'string') {
561
- return { valid: false, error: 'verifierId is required and must be a string' };
562
- }
563
-
564
- if (data === null || typeof data !== 'object' || Array.isArray(data)) {
565
- return { valid: false, error: 'data must be a non-null object' };
566
- }
567
-
568
- // Minimal field hints for built-in verifiers
569
- const id = verifierId.replace(/@\d+$/, '');
570
- if (id === 'nft-ownership') {
571
- ['contractAddress', 'tokenId', 'chainId'].forEach((key) => {
572
- if (!(key in data)) result.missing.push(key);
573
- });
574
- if (!('ownerAddress' in data)) {
575
- result.warnings.push('ownerAddress omitted (most deployments default to the signed walletAddress)');
576
- }
577
- } else if (id === 'token-holding') {
578
- ['contractAddress', 'minBalance', 'chainId'].forEach((key) => {
579
- if (!(key in data)) result.missing.push(key);
580
- });
581
- if (!('ownerAddress' in data)) {
582
- result.warnings.push('ownerAddress omitted (most deployments default to the signed walletAddress)');
583
- }
584
- } else if (id === 'ownership-basic') {
585
- // ownership-basic requires an owner, and needs at least one binding:
586
- // - content (inline), or
587
- // - contentHash (recommended for large content), or
588
- // - reference.id (reference-only proofs)
589
- if (!('owner' in data)) result.missing.push('owner');
590
- const hasContent = typeof data.content === 'string' && data.content.length > 0;
591
- const hasContentHash = typeof data.contentHash === 'string' && data.contentHash.length > 0;
592
- const hasRefId = typeof data.reference?.id === 'string' && data.reference.id.length > 0;
593
- if (!hasContent && !hasContentHash && !hasRefId) {
594
- result.missing.push('content (or contentHash or reference.id)');
595
- }
596
- }
597
-
598
- if (result.missing.length > 0) {
599
- result.valid = false;
600
- result.error = `Missing required fields: ${result.missing.join(', ')}`;
601
- }
602
-
603
- return result;
604
- }
605
-
606
- /**
607
- * Build a standard verification request and signing message for manual flows.
608
- * Returns the message to sign and the request body (sans signature).
609
- * @param {Object} params
610
- * @param {string[]} params.verifierIds
611
- * @param {object} params.data
612
- * @param {string} params.walletAddress
613
- * @param {number} [params.chainId=NEUS_CONSTANTS.HUB_CHAIN_ID]
614
- * @param {object} [params.options]
615
- * @param {number} [params.signedTimestamp=Date.now()]
616
- * @returns {{ message: string, request: { verifierIds: string[], data: object, walletAddress: string, signedTimestamp: number, chainId: number, options?: object } }}
617
- */
618
- export function buildVerificationRequest({
619
- verifierIds,
620
- data,
621
- walletAddress,
622
- chainId = NEUS_CONSTANTS.HUB_CHAIN_ID,
623
- options = undefined,
624
- signedTimestamp = Date.now()
625
- }) {
626
- if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
627
- throw new SDKError('verifierIds must be a non-empty array', 'INVALID_ARGUMENT');
628
- }
629
- if (!validateWalletAddress(walletAddress)) {
630
- throw new SDKError('walletAddress must be a valid 0x address', 'INVALID_ARGUMENT');
631
- }
632
- if (!data || typeof data !== 'object') {
633
- throw new SDKError('data must be a non-null object', 'INVALID_ARGUMENT');
634
- }
635
- if (typeof chainId !== 'number') {
636
- throw new SDKError('chainId must be a number', 'INVALID_ARGUMENT');
637
- }
638
-
639
- const message = constructVerificationMessage({
640
- walletAddress,
641
- signedTimestamp,
642
- data,
643
- verifierIds,
644
- chainId
645
- });
646
-
647
- const request = {
648
- verifierIds,
649
- data,
650
- walletAddress,
651
- signedTimestamp,
652
- chainId,
653
- ...(options ? { options } : {})
654
- };
655
-
656
- return { message, request };
657
- }
658
-
659
- /**
660
- * Create a retry utility with exponential backoff
661
- * @param {Function} fn - Function to retry
662
- * @param {Object} options - Retry options
663
- * @returns {Promise} Promise that resolves with function result
664
- */
665
- export async function withRetry(fn, options = {}) {
666
- const {
667
- maxAttempts = 3,
668
- baseDelay = 1000,
669
- maxDelay = 10000,
670
- backoffFactor = 2
671
- } = options;
672
-
673
- let lastError;
674
-
675
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
676
- try {
677
- return await fn();
678
- } catch (error) {
679
- lastError = error;
680
-
681
- if (attempt === maxAttempts) break;
682
-
683
- const delayMs = Math.min(
684
- baseDelay * Math.pow(backoffFactor, attempt - 1),
685
- maxDelay
686
- );
687
-
688
- await delay(delayMs);
689
- }
690
- }
691
-
692
- throw lastError;
693
- }
694
-
695
- /**
696
- * Validate signature components for debugging signature verification issues
697
- * @param {Object} params - Signature components to validate
698
- * @param {string} params.walletAddress - Wallet address
699
- * @param {string} params.signature - EIP-191 signature
700
- * @param {number} params.signedTimestamp - Unix timestamp
701
- * @param {Object} params.data - Verification data
702
- * @param {Array<string>} params.verifierIds - Array of verifier IDs
703
- * @param {number} params.chainId - Chain ID
704
- * @returns {Object} Validation result with detailed feedback
705
- */
706
- export function validateSignatureComponents({ walletAddress, signature, signedTimestamp, data, verifierIds, chainId }) {
707
- const result = {
708
- valid: true,
709
- errors: [],
710
- warnings: [],
711
- debugInfo: {}
712
- };
713
-
714
- // Validate wallet address
715
- if (!validateWalletAddress(walletAddress)) {
716
- result.valid = false;
717
- result.errors.push('Invalid wallet address format - must be 0x + 40 hex characters');
718
- } else {
719
- result.debugInfo.normalizedAddress = walletAddress.toLowerCase();
720
- if (walletAddress !== walletAddress.toLowerCase()) {
721
- result.warnings.push('Wallet address should be lowercase for consistency');
722
- }
723
- }
724
-
725
- // Validate signature format
726
- if (!signature || typeof signature !== 'string') {
727
- result.valid = false;
728
- result.errors.push('Signature is required and must be a string');
729
- } else if (!/^0x[a-fA-F0-9]{130}$/.test(signature)) {
730
- result.valid = false;
731
- result.errors.push('Invalid signature format - must be 0x + 130 hex characters (65 bytes)');
732
- }
733
-
734
- // Validate timestamp
735
- if (!validateTimestamp(signedTimestamp)) {
736
- result.valid = false;
737
- result.errors.push('Invalid or expired timestamp - must be within 5 minutes');
738
- } else {
739
- result.debugInfo.timestampAge = Date.now() - signedTimestamp;
740
- }
741
-
742
- // Validate data object
743
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
744
- result.valid = false;
745
- result.errors.push('Data must be a non-null object');
746
- } else {
747
- result.debugInfo.dataString = deterministicStringify(data);
748
- }
749
-
750
- // Validate verifier IDs
751
- if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
752
- result.valid = false;
753
- result.errors.push('VerifierIds must be a non-empty array');
754
- }
755
-
756
- // Validate chain ID
757
- if (typeof chainId !== 'number') {
758
- result.valid = false;
759
- result.errors.push('ChainId must be a number');
760
- }
761
-
762
- // Generate the message that would be signed
763
- if (result.valid || result.errors.length < 3) {
764
- try {
765
- result.debugInfo.messageToSign = constructVerificationMessage({
766
- walletAddress: walletAddress?.toLowerCase() || walletAddress,
767
- signedTimestamp,
768
- data,
769
- verifierIds,
770
- chainId
771
- });
772
- } catch (error) {
773
- result.errors.push(`Failed to construct message: ${error.message}`);
774
- }
775
- }
776
-
777
- return result;
1
+ /**
2
+ * NEUS SDK Utilities
3
+ * Core utility functions for proof creation and verification
4
+ */
5
+
6
+ import { SDKError, ApiError, ValidationError } from './errors.js';
7
+
8
+ /** CAIP-380 six-line signer message — line 1 (fixed context label). */
9
+ export const PORTABLE_PROOF_SIGNER_HEADER = 'Portable Proof Verification Request';
10
+
11
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
12
+
13
+ function encodeBase58Bytes(input) {
14
+ let source;
15
+ if (input instanceof Uint8Array) {
16
+ source = input;
17
+ } else if (input instanceof ArrayBuffer) {
18
+ source = new Uint8Array(input);
19
+ } else if (ArrayBuffer.isView(input)) {
20
+ source = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
21
+ } else if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(input)) {
22
+ source = new Uint8Array(input);
23
+ } else {
24
+ throw new SDKError('Unsupported non-EVM signature byte format', 'INVALID_SIGNATURE_FORMAT');
25
+ }
26
+
27
+ if (source.length === 0) return '';
28
+
29
+ let zeroes = 0;
30
+ while (zeroes < source.length && source[zeroes] === 0) {
31
+ zeroes++;
32
+ }
33
+
34
+ const iFactor = Math.log(256) / Math.log(58);
35
+ const size = (((source.length - zeroes) * iFactor) + 1) >>> 0;
36
+ const b58 = new Uint8Array(size);
37
+
38
+ let length = 0;
39
+ for (let i = zeroes; i < source.length; i++) {
40
+ let carry = source[i];
41
+ let j = 0;
42
+ for (let k = size - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
43
+ carry += 256 * b58[k];
44
+ b58[k] = carry % 58;
45
+ carry = (carry / 58) | 0;
46
+ }
47
+ length = j;
48
+ }
49
+
50
+ let it = size - length;
51
+ while (it < size && b58[it] === 0) {
52
+ it++;
53
+ }
54
+
55
+ let out = BASE58_ALPHABET[0].repeat(zeroes);
56
+ for (; it < size; it++) {
57
+ out += BASE58_ALPHABET[b58[it]];
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Deterministic JSON stringification for consistent serialization
64
+ * @param {Object} obj - Object to stringify
65
+ * @returns {string} Deterministic JSON string
66
+ */
67
+ function deterministicStringify(obj) {
68
+ if (obj === null || obj === undefined) {
69
+ return JSON.stringify(obj);
70
+ }
71
+
72
+ if (typeof obj !== 'object') {
73
+ if (typeof obj === 'string') return JSON.stringify(obj.normalize('NFC'));
74
+ return JSON.stringify(obj);
75
+ }
76
+
77
+ if (Array.isArray(obj)) {
78
+ return `[${ obj.map(item => (item === undefined ? 'null' : deterministicStringify(item))).join(',') }]`;
79
+ }
80
+
81
+ const sortedKeys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
82
+ const pairs = sortedKeys.map(key =>
83
+ `${JSON.stringify(key) }:${ deterministicStringify(obj[key])}`
84
+ );
85
+
86
+ return `{${ pairs.join(',') }}`;
87
+ }
88
+
89
+ /**
90
+ * CAIP-380 EVM: line3 is decimal chainId. Non-EVM: CAIP-2 chain string.
91
+ */
92
+ function chainLineForPortableProofSigner(chain, chainId) {
93
+ if (typeof chain === 'string' && chain.length > 0) {
94
+ const m = chain.match(/^eip155:(\d+)$/);
95
+ if (m) return Number(m[1]);
96
+ return chain;
97
+ }
98
+ if (typeof chainId === 'number' && Number.isFinite(chainId) && chainId > 0) {
99
+ return chainId;
100
+ }
101
+ throw new SDKError('chainId is required (or provide chain for universal mode)', 'INVALID_CHAIN_CONTEXT');
102
+ }
103
+
104
+ /**
105
+ * Construct verification message for wallet signing
106
+ *
107
+ * @param {Object} params - Message parameters
108
+ * @param {string} params.walletAddress - Wallet address
109
+ * @param {number} params.signedTimestamp - Unix timestamp
110
+ * @param {Object} params.data - Verification data
111
+ * @param {Array<string>} params.verifierIds - Array of verifier IDs
112
+ * @param {number} params.chainId - Chain ID
113
+ * @returns {string} Message for signing
114
+ */
115
+ export function constructVerificationMessage({ walletAddress, signedTimestamp, data, verifierIds, chainId, chain }) {
116
+ // Input validation for critical parameters
117
+ if (!walletAddress || typeof walletAddress !== 'string') {
118
+ throw new SDKError('walletAddress is required and must be a string', 'INVALID_WALLET_ADDRESS');
119
+ }
120
+ if (!signedTimestamp || typeof signedTimestamp !== 'number') {
121
+ throw new SDKError('signedTimestamp is required and must be a number', 'INVALID_TIMESTAMP');
122
+ }
123
+ if (!data || typeof data !== 'object') {
124
+ throw new SDKError('data is required and must be an object', 'INVALID_DATA');
125
+ }
126
+ if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
127
+ throw new SDKError('verifierIds is required and must be a non-empty array', 'INVALID_VERIFIER_IDS');
128
+ }
129
+
130
+ if ((typeof chain !== 'string' || !chain.length) && !(typeof chainId === 'number' && chainId > 0)) {
131
+ throw new SDKError('chainId is required (or provide chain for universal mode)', 'INVALID_CHAIN_CONTEXT');
132
+ }
133
+ if (typeof chain === 'string' && chain.length > 0 && (!chain.includes(':'))) {
134
+ throw new SDKError('chain must be a "namespace:reference" string', 'INVALID_CHAIN');
135
+ }
136
+ if ((!chain || !chain.length) && typeof chainId !== 'number') {
137
+ throw new SDKError('chainId must be a number when provided', 'INVALID_CHAIN_ID');
138
+ }
139
+
140
+ const chainLine = chainLineForPortableProofSigner(chain, chainId);
141
+
142
+ // Address normalization: EVM (`eip155`) is lowercased; non-EVM namespaces preserve the original string.
143
+ const namespace = (typeof chain === 'string' && chain.includes(':')) ? chain.split(':')[0] : 'eip155';
144
+ const normalizedWalletAddress = namespace === 'eip155' ? walletAddress.toLowerCase() : walletAddress;
145
+
146
+ const dataString = deterministicStringify(data);
147
+
148
+ const messageComponents = [
149
+ PORTABLE_PROOF_SIGNER_HEADER,
150
+ `Wallet: ${normalizedWalletAddress}`,
151
+ `Chain: ${chainLine}`,
152
+ `Verifiers: ${verifierIds.join(',')}`,
153
+ `Data: ${dataString}`,
154
+ `Timestamp: ${signedTimestamp}`
155
+ ];
156
+
157
+ return messageComponents.join('\n').normalize('NFC');
158
+ }
159
+
160
+ /**
161
+ * Validate Ethereum wallet address format
162
+ *
163
+ * @param {string} address - Address to validate
164
+ * @returns {boolean} True if valid Ethereum address
165
+ */
166
+ export function validateWalletAddress(address) {
167
+ if (!address || typeof address !== 'string') {
168
+ return false;
169
+ }
170
+
171
+ // Basic Ethereum address validation
172
+ return /^0x[a-fA-F0-9]{40}$/.test(address);
173
+ }
174
+
175
+ /**
176
+ * Validate universal wallet address format.
177
+ * Uses chain namespace when provided; otherwise applies conservative multi-chain checks.
178
+ *
179
+ * @param {string} address - Address to validate
180
+ * @param {string} [chain] - Optional CAIP-2 chain reference (namespace:reference)
181
+ * @returns {boolean} True if valid universal wallet address
182
+ */
183
+ export function validateUniversalAddress(address, chain) {
184
+ if (!address || typeof address !== 'string') return false;
185
+ const value = address.trim();
186
+ if (!value) return false;
187
+
188
+ const chainRef = typeof chain === 'string' ? chain.trim().toLowerCase() : '';
189
+ const namespace = chainRef.includes(':') ? chainRef.split(':')[0] : '';
190
+
191
+ if (validateWalletAddress(value)) return true;
192
+ if (namespace === 'eip155') return false;
193
+
194
+ if (namespace === 'solana') {
195
+ return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value);
196
+ }
197
+
198
+ if (namespace === 'bip122') {
199
+ return /^(bc1|tb1|bcrt1)[a-z0-9]{11,87}$/.test(value.toLowerCase()) ||
200
+ /^[13mn2][a-km-zA-HJ-NP-Z1-9]{25,62}$/.test(value);
201
+ }
202
+
203
+ if (namespace === 'near') {
204
+ return /^[a-z0-9._-]{2,64}$/.test(value);
205
+ }
206
+
207
+ // Generic fallback for universal-address style identifiers.
208
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$/.test(value);
209
+ }
210
+
211
+ /**
212
+ * Validate timestamp freshness
213
+ *
214
+ * @param {number} timestamp - Timestamp to validate
215
+ * @param {number} maxAgeMs - Maximum age in milliseconds (default: 5 minutes)
216
+ * @returns {boolean} True if timestamp is valid and recent
217
+ */
218
+ export function validateTimestamp(timestamp, maxAgeMs = 5 * 60 * 1000) {
219
+ if (!timestamp || typeof timestamp !== 'number') {
220
+ return false;
221
+ }
222
+
223
+ const now = Date.now();
224
+ const age = now - timestamp;
225
+
226
+ // Check if timestamp is in the past and within allowed age
227
+ return age >= 0 && age <= maxAgeMs;
228
+ }
229
+
230
+ /**
231
+ * Create formatted verification data object
232
+ *
233
+ * @param {string} content - Content to verify
234
+ * @param {string} owner - Owner wallet address
235
+ * @param {Object} reference - Reference object
236
+ * @returns {Object} Formatted verification data
237
+ */
238
+ export function createVerificationData(content, owner, reference = null) {
239
+ // Small, deterministic reference ID for convenience (NOT a cryptographic hash).
240
+ // Integrators that need a stable binding should prefer contentHash or an explicit reference.id.
241
+ const stableRefId = (value) => {
242
+ const str = typeof value === 'string' ? value : JSON.stringify(value);
243
+ let hash = 0;
244
+ for (let i = 0; i < str.length; i++) {
245
+ hash = ((hash << 5) - hash) + str.charCodeAt(i);
246
+ hash |= 0; // 32-bit
247
+ }
248
+ const hex = Math.abs(hash).toString(16).padStart(8, '0');
249
+ return `ref-id:${hex}:${str.length}`;
250
+ };
251
+
252
+ return {
253
+ content,
254
+ owner: validateWalletAddress(owner) ? owner.toLowerCase() : owner,
255
+ reference: reference || {
256
+ // Must be a valid backend enum value; 'content' is not supported.
257
+ type: 'other',
258
+ id: stableRefId(content)
259
+ }
260
+ };
261
+ }
262
+
263
+ /**
264
+ * DERIVE DID FROM ADDRESS AND CHAIN
265
+ * did:pkh:<namespace>:<chainId|segment>:<address_lowercase>
266
+ */
267
+ export function deriveDid(address, chainIdOrChain) {
268
+ if (!address || typeof address !== 'string') {
269
+ throw new SDKError('deriveDid: address is required', 'INVALID_ARGUMENT');
270
+ }
271
+
272
+ const chainContext = chainIdOrChain || NEUS_CONSTANTS.HUB_CHAIN_ID;
273
+ const isCAIP = typeof chainContext === 'string' && chainContext.includes(':');
274
+
275
+ if (isCAIP) {
276
+ const [namespace, segment] = chainContext.split(':');
277
+ const normalized = (namespace === 'eip155') ? address.toLowerCase() : address;
278
+ return `did:pkh:${namespace}:${segment}:${normalized}`;
279
+ } else {
280
+ if (typeof chainContext !== 'number') {
281
+ throw new SDKError('deriveDid: chainId (number) or chain (namespace:reference string) is required', 'INVALID_ARGUMENT');
282
+ }
283
+ return `did:pkh:eip155:${chainContext}:${address.toLowerCase()}`;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Resolve DID from wallet identity via API endpoint
289
+ *
290
+ * @param {Object} params - DID resolution parameters
291
+ * @param {string} params.walletAddress - Wallet address to resolve
292
+ * @param {number} [params.chainId] - EVM chain ID
293
+ * @param {string} [params.chain] - Universal chain context (namespace:reference)
294
+ * @param {Object} [options] - Request options
295
+ * @param {string} [options.endpoint='/api/v1/profile/did/resolve'] - DID resolve endpoint
296
+ * @param {string} [options.apiUrl] - Absolute API base URL for non-relative endpoints
297
+ * @param {RequestCredentials} [options.credentials] - Fetch credentials mode
298
+ * @param {Record<string, string>} [options.headers] - Extra request headers
299
+ * @returns {Promise<{did: string, data: any, raw: any}>}
300
+ */
301
+ export async function resolveDID(params, options = {}) {
302
+ const endpointPath = options.endpoint || '/api/v1/profile/did/resolve';
303
+ const apiUrl = typeof options.apiUrl === 'string' ? options.apiUrl.trim() : '';
304
+
305
+ const resolveEndpoint = (path) => {
306
+ if (!path || typeof path !== 'string') return null;
307
+ const trimmedPath = path.trim();
308
+ if (!trimmedPath) return null;
309
+ if (/^https?:\/\//i.test(trimmedPath)) return trimmedPath;
310
+ if (trimmedPath.startsWith('/')) {
311
+ if (!apiUrl) return trimmedPath;
312
+ try {
313
+ return new URL(trimmedPath, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`).toString();
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+ const base = apiUrl || NEUS_CONSTANTS.API_BASE_URL;
319
+ if (!base || typeof base !== 'string') return null;
320
+ try {
321
+ return new URL(trimmedPath, base.endsWith('/') ? base : `${base}/`).toString();
322
+ } catch {
323
+ return null;
324
+ }
325
+ };
326
+
327
+ const endpoint = resolveEndpoint(endpointPath);
328
+ if (!endpoint) {
329
+ throw new SDKError('resolveDID requires a valid endpoint', 'INVALID_ENDPOINT');
330
+ }
331
+
332
+ const payload = {
333
+ walletAddress: params?.walletAddress,
334
+ chainId: params?.chainId,
335
+ chain: params?.chain
336
+ };
337
+
338
+ const isRelative = endpoint.startsWith('/') || !/^https?:\/\//i.test(endpoint);
339
+ const credentialsMode = options.credentials !== undefined
340
+ ? options.credentials
341
+ : (isRelative ? 'same-origin' : 'omit');
342
+
343
+ try {
344
+ const response = await fetch(endpoint, {
345
+ method: 'POST',
346
+ headers: {
347
+ 'Content-Type': 'application/json',
348
+ Accept: 'application/json',
349
+ ...(options.headers || {})
350
+ },
351
+ body: JSON.stringify(payload),
352
+ credentials: credentialsMode
353
+ });
354
+
355
+ const json = await response.json().catch(() => null);
356
+
357
+ if (!response.ok) {
358
+ const msg = json?.error?.message || json?.error || json?.message || 'DID resolution failed';
359
+ throw new SDKError(msg, 'DID_RESOLVE_FAILED', json);
360
+ }
361
+
362
+ const did = json?.data?.did || json?.did;
363
+ if (!did || typeof did !== 'string') {
364
+ throw new SDKError('DID resolution missing DID', 'DID_RESOLVE_MISSING', json);
365
+ }
366
+
367
+ return { did, data: json?.data || null, raw: json };
368
+ } catch (error) {
369
+ if (error instanceof SDKError) throw error;
370
+ throw new SDKError(`DID resolution failed: ${error?.message || error}`, 'DID_RESOLVE_FAILED');
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Standardize verification request via backend signer-string endpoint
376
+ *
377
+ * @param {Object} params - Verification request payload
378
+ * @param {Object} [options] - Request options
379
+ * @param {string} [options.endpoint='/api/v1/verification/standardize'] - Standardize endpoint
380
+ * @param {string} [options.apiUrl] - Absolute API base URL for non-relative endpoints
381
+ * @param {RequestCredentials} [options.credentials] - Fetch credentials mode
382
+ * @param {Record<string, string>} [options.headers] - Extra request headers
383
+ * @returns {Promise<any>}
384
+ */
385
+ export async function standardizeVerificationRequest(params, options = {}) {
386
+ const endpointPath = options.endpoint || '/api/v1/verification/standardize';
387
+ const apiUrl = typeof options.apiUrl === 'string' ? options.apiUrl.trim() : '';
388
+
389
+ const resolveEndpoint = (path) => {
390
+ if (!path || typeof path !== 'string') return null;
391
+ const trimmedPath = path.trim();
392
+ if (!trimmedPath) return null;
393
+ if (/^https?:\/\//i.test(trimmedPath)) return trimmedPath;
394
+ if (trimmedPath.startsWith('/')) {
395
+ if (!apiUrl) return trimmedPath;
396
+ try {
397
+ return new URL(trimmedPath, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`).toString();
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ const base = apiUrl || NEUS_CONSTANTS.API_BASE_URL;
403
+ if (!base || typeof base !== 'string') return null;
404
+ try {
405
+ return new URL(trimmedPath, base.endsWith('/') ? base : `${base}/`).toString();
406
+ } catch {
407
+ return null;
408
+ }
409
+ };
410
+
411
+ const endpoint = resolveEndpoint(endpointPath);
412
+ if (!endpoint) {
413
+ throw new SDKError('standardizeVerificationRequest requires a valid endpoint', 'INVALID_ENDPOINT');
414
+ }
415
+
416
+ const isRelative = endpoint.startsWith('/') || !/^https?:\/\//i.test(endpoint);
417
+ const credentialsMode = options.credentials !== undefined
418
+ ? options.credentials
419
+ : (isRelative ? 'same-origin' : 'omit');
420
+
421
+ try {
422
+ const response = await fetch(endpoint, {
423
+ method: 'POST',
424
+ headers: {
425
+ 'Content-Type': 'application/json',
426
+ Accept: 'application/json',
427
+ ...(options.headers || {})
428
+ },
429
+ body: JSON.stringify(params || {}),
430
+ credentials: credentialsMode
431
+ });
432
+
433
+ const json = await response.json().catch(() => null);
434
+ if (!response.ok) {
435
+ const msg = json?.error?.message || json?.error || json?.message || 'Standardize request failed';
436
+ throw new SDKError(msg, 'STANDARDIZE_FAILED', json);
437
+ }
438
+
439
+ return json?.data || json;
440
+ } catch (error) {
441
+ if (error instanceof SDKError) throw error;
442
+ throw new SDKError(`Standardize request failed: ${error?.message || error}`, 'STANDARDIZE_FAILED');
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Resolve default ZK Passport configuration values.
448
+ * Kept as an SDK utility to preserve existing app integrations.
449
+ *
450
+ * @param {Object} [overrides] - Caller-provided config overrides
451
+ * @returns {Object}
452
+ */
453
+ export function resolveZkPassportConfig(overrides = {}) {
454
+ const defaults = {
455
+ provider: 'zkpassport',
456
+ scope: 'basic_kyc',
457
+ checkSanctions: true,
458
+ requireFaceMatch: true,
459
+ faceMatchMode: 'strict'
460
+ };
461
+
462
+ return {
463
+ ...defaults,
464
+ ...(overrides && typeof overrides === 'object' ? overrides : {})
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Convert a UTF-8 string to `0x`-prefixed hex.
470
+ *
471
+ * @param {string} value
472
+ * @returns {string}
473
+ */
474
+ export function toHexUtf8(value) {
475
+ const input = typeof value === 'string' ? value : String(value || '');
476
+ const bytes = new TextEncoder().encode(input);
477
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
478
+ }
479
+
480
+ /**
481
+ * Sign an arbitrary message with the provided wallet/provider.
482
+ * Supports EIP-1193 wallets and signer-like objects.
483
+ *
484
+ * @param {Object} params
485
+ * @param {Object} [params.provider] - Wallet provider/signer
486
+ * @param {string} params.message - Message to sign
487
+ * @param {string} [params.walletAddress] - Explicit signer address (recommended)
488
+ * @param {string} [params.chain] - Chain context (`namespace:reference`)
489
+ * @returns {Promise<string>}
490
+ */
491
+ export async function signMessage({ provider, message, walletAddress, chain } = {}) {
492
+ const msg = typeof message === 'string' ? message : String(message || '');
493
+ if (!msg) {
494
+ throw new SDKError('signMessage: message is required', 'INVALID_ARGUMENT');
495
+ }
496
+
497
+ const resolvedProvider = provider || (
498
+ typeof window !== 'undefined' && window?.ethereum ? window.ethereum : null
499
+ );
500
+ if (!resolvedProvider) {
501
+ throw new SDKError('signMessage: provider is required', 'SIGNER_UNAVAILABLE');
502
+ }
503
+
504
+ const chainStr = typeof chain === 'string' && chain.trim().length > 0 ? chain.trim() : 'eip155';
505
+ const namespace = chainStr.includes(':') ? chainStr.split(':')[0] || 'eip155' : 'eip155';
506
+
507
+ const resolveAddress = async () => {
508
+ if (typeof walletAddress === 'string' && walletAddress.trim().length > 0) return walletAddress;
509
+ if (namespace === 'solana') {
510
+ if (resolvedProvider?.publicKey && typeof resolvedProvider.publicKey.toBase58 === 'function') {
511
+ const pk = resolvedProvider.publicKey.toBase58();
512
+ if (typeof pk === 'string' && pk) return pk;
513
+ }
514
+ if (typeof resolvedProvider.getAddress === 'function') {
515
+ const addr = await resolvedProvider.getAddress().catch(() => null);
516
+ if (typeof addr === 'string' && addr) return addr;
517
+ }
518
+ if (typeof resolvedProvider.address === 'string' && resolvedProvider.address) return resolvedProvider.address;
519
+ return null;
520
+ }
521
+ if (typeof resolvedProvider.address === 'string' && resolvedProvider.address) return resolvedProvider.address;
522
+ if (typeof resolvedProvider.getAddress === 'function') return resolvedProvider.getAddress();
523
+ if (typeof resolvedProvider.request === 'function') {
524
+ let accounts = await resolvedProvider.request({ method: 'eth_accounts' }).catch(() => []);
525
+ if (!Array.isArray(accounts) || accounts.length === 0) {
526
+ accounts = await resolvedProvider.request({ method: 'eth_requestAccounts' }).catch(() => []);
527
+ }
528
+ if (Array.isArray(accounts) && accounts[0]) return accounts[0];
529
+ }
530
+ return null;
531
+ };
532
+
533
+ if (namespace !== 'eip155') {
534
+ if (typeof resolvedProvider.signMessage === 'function') {
535
+ const encoded = typeof msg === 'string' ? new TextEncoder().encode(msg) : msg;
536
+ const result = await resolvedProvider.signMessage(encoded);
537
+ if (typeof result === 'string' && result) return result;
538
+ if (result instanceof Uint8Array) return encodeBase58Bytes(result);
539
+ if (result instanceof ArrayBuffer) return encodeBase58Bytes(new Uint8Array(result));
540
+ if (ArrayBuffer.isView(result)) return encodeBase58Bytes(result);
541
+ if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(result)) return encodeBase58Bytes(result);
542
+ }
543
+ throw new SDKError('Non-EVM signing requires provider.signMessage', 'SIGNER_UNAVAILABLE');
544
+ }
545
+
546
+ const address = await resolveAddress();
547
+
548
+ if (typeof resolvedProvider.request === 'function' && address) {
549
+ let firstPersonalSignError = null;
550
+ try {
551
+ const sig = await resolvedProvider.request({ method: 'personal_sign', params: [msg, address] });
552
+ if (typeof sig === 'string' && sig) return sig;
553
+ } catch (error) {
554
+ firstPersonalSignError = error;
555
+ }
556
+
557
+ let secondPersonalSignError = null;
558
+ try {
559
+ const sig = await resolvedProvider.request({ method: 'personal_sign', params: [address, msg] });
560
+ if (typeof sig === 'string' && sig) return sig;
561
+ } catch (error) {
562
+ secondPersonalSignError = error;
563
+ const signatureErrorMessage = String(
564
+ error?.message ||
565
+ error?.reason ||
566
+ firstPersonalSignError?.message ||
567
+ firstPersonalSignError?.reason ||
568
+ ''
569
+ ).toLowerCase();
570
+ const needsHex = /byte|bytes|invalid byte sequence|encoding|non-hex/i.test(signatureErrorMessage);
571
+ if (needsHex) {
572
+ try {
573
+ const hexMsg = toHexUtf8(msg);
574
+ const sig = await resolvedProvider.request({ method: 'personal_sign', params: [hexMsg, address] });
575
+ if (typeof sig === 'string' && sig) return sig;
576
+ } catch {
577
+ // Continue to additional fallbacks.
578
+ }
579
+ }
580
+ }
581
+
582
+ try {
583
+ const sig = await resolvedProvider.request({ method: 'eth_sign', params: [address, msg] });
584
+ if (typeof sig === 'string' && sig) return sig;
585
+ } catch { /* try next method */ }
586
+
587
+ if (secondPersonalSignError || firstPersonalSignError) {
588
+ const lastError = secondPersonalSignError || firstPersonalSignError;
589
+ const isUserRejection = [4001, 'ACTION_REJECTED'].includes(lastError?.code);
590
+ if (isUserRejection) {
591
+ throw lastError;
592
+ }
593
+ }
594
+ }
595
+
596
+ if (typeof resolvedProvider.signMessage === 'function') {
597
+ const result = await resolvedProvider.signMessage(msg);
598
+ if (typeof result === 'string' && result) return result;
599
+ }
600
+
601
+ throw new SDKError('Unable to sign message with provided wallet/provider', 'SIGNER_UNAVAILABLE');
602
+ }
603
+
604
+ /**
605
+ * Determine if a verification status is terminal (completed or failed)
606
+ * @param {string} status - The verification status
607
+ * @returns {boolean} Whether the status is terminal
608
+ */
609
+ export function isTerminalStatus(status) {
610
+ if (!status || typeof status !== 'string') return false;
611
+
612
+ // Success states
613
+ const successStates = [
614
+ 'verified',
615
+ 'verified_no_verifiers',
616
+ 'verified_crosschain_propagated',
617
+ 'partially_verified',
618
+ 'verified_propagation_failed'
619
+ ];
620
+
621
+ // Failure states
622
+ const failureStates = [
623
+ 'rejected',
624
+ 'rejected_verifier_failure',
625
+ 'rejected_zk_initiation_failure',
626
+ 'error_processing_exception',
627
+ 'error_initialization',
628
+ 'error_storage_unavailable',
629
+ 'error_storage_query',
630
+ 'not_found'
631
+ ];
632
+
633
+ return successStates.includes(status) || failureStates.includes(status);
634
+ }
635
+
636
+ /**
637
+ * Determine if a verification status indicates success
638
+ * @param {string} status - The verification status
639
+ * @returns {boolean} Whether the status indicates success
640
+ */
641
+ export function isSuccessStatus(status) {
642
+ if (!status || typeof status !== 'string') return false;
643
+
644
+ const successStates = [
645
+ 'verified',
646
+ 'verified_no_verifiers',
647
+ 'verified_crosschain_propagated',
648
+ 'partially_verified',
649
+ 'verified_propagation_failed'
650
+ ];
651
+
652
+ return successStates.includes(status);
653
+ }
654
+
655
+ /**
656
+ * Determine if a verification status indicates failure
657
+ * @param {string} status - The verification status
658
+ * @returns {boolean} Whether the status indicates failure
659
+ */
660
+ export function isFailureStatus(status) {
661
+ if (!status || typeof status !== 'string') return false;
662
+
663
+ const failureStates = [
664
+ 'rejected',
665
+ 'rejected_verifier_failure',
666
+ 'rejected_zk_initiation_failure',
667
+ 'error_processing_exception',
668
+ 'error_initialization',
669
+ 'error_storage_unavailable',
670
+ 'error_storage_query',
671
+ 'not_found'
672
+ ];
673
+
674
+ return failureStates.includes(status);
675
+ }
676
+
677
+ /**
678
+ * Format verification status for display
679
+ * @param {string} status - Raw status from API
680
+ * @returns {Object} Formatted status information
681
+ */
682
+ export function formatVerificationStatus(status) {
683
+ const statusMap = {
684
+ 'processing_verifiers': {
685
+ label: 'Processing',
686
+ description: 'Verifiers are being executed',
687
+ category: 'processing',
688
+ color: 'blue'
689
+ },
690
+ 'processing_zk_proofs': {
691
+ label: 'Generating ZK Proofs',
692
+ description: 'Zero-knowledge proofs are being generated',
693
+ category: 'processing',
694
+ color: 'blue'
695
+ },
696
+ 'verified': {
697
+ label: 'Verified',
698
+ description: 'Verification completed successfully',
699
+ category: 'success',
700
+ color: 'green'
701
+ },
702
+ 'verified_crosschain_initiated': {
703
+ label: 'Cross-chain Initiated',
704
+ description: 'Verification successful, cross-chain propagation started',
705
+ category: 'processing',
706
+ color: 'blue'
707
+ },
708
+ 'verified_crosschain_propagating': {
709
+ label: 'Cross-chain Propagating',
710
+ description: 'Verification successful, transactions propagating to spoke chains',
711
+ category: 'processing',
712
+ color: 'blue'
713
+ },
714
+ 'verified_crosschain_propagated': {
715
+ label: 'Fully Propagated',
716
+ description: 'Verification completed and propagated to all target chains',
717
+ category: 'success',
718
+ color: 'green'
719
+ },
720
+ 'verified_no_verifiers': {
721
+ label: 'Verified (No Verifiers)',
722
+ description: 'Verification completed without specific verifiers',
723
+ category: 'success',
724
+ color: 'green'
725
+ },
726
+ 'verified_propagation_failed': {
727
+ label: 'Propagation Failed',
728
+ description: 'Verification successful but cross-chain propagation failed',
729
+ category: 'warning',
730
+ color: 'orange'
731
+ },
732
+ 'partially_verified': {
733
+ label: 'Partially Verified',
734
+ description: 'Some verifiers succeeded, others failed',
735
+ category: 'warning',
736
+ color: 'orange'
737
+ },
738
+ 'rejected': {
739
+ label: 'Rejected',
740
+ description: 'Verification failed',
741
+ category: 'error',
742
+ color: 'red'
743
+ },
744
+ 'rejected_verifier_failure': {
745
+ label: 'Verifier Failed',
746
+ description: 'One or more verifiers failed',
747
+ category: 'error',
748
+ color: 'red'
749
+ },
750
+ 'rejected_zk_initiation_failure': {
751
+ label: 'ZK Initiation Failed',
752
+ description: 'Zero-knowledge proof generation failed to start',
753
+ category: 'error',
754
+ color: 'red'
755
+ },
756
+ 'error_processing_exception': {
757
+ label: 'Processing Error',
758
+ description: 'An error occurred during verification processing',
759
+ category: 'error',
760
+ color: 'red'
761
+ },
762
+ 'error_initialization': {
763
+ label: 'Initialization Error',
764
+ description: 'Failed to initialize verification',
765
+ category: 'error',
766
+ color: 'red'
767
+ },
768
+ 'not_found': {
769
+ label: 'Not Found',
770
+ description: 'Verification record not found',
771
+ category: 'error',
772
+ color: 'red'
773
+ }
774
+ };
775
+
776
+ return statusMap[status] || {
777
+ label: status?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Unknown',
778
+ description: 'Unknown status',
779
+ category: 'unknown',
780
+ color: 'gray'
781
+ };
782
+ }
783
+
784
+ /**
785
+ * Compute keccak256 content hash (0x-prefixed) for arbitrary input
786
+ * Uses ethers (peer dependency) via dynamic import to avoid hard bundling
787
+ *
788
+ * Note: The NEUS backend uses SHAKE256 (quantum-resistant) for content hashing.
789
+ * For content hashes that match the backend, use the /verification/standardize
790
+ * endpoint to get the server-computed hash, or provide the content directly and let
791
+ * the backend compute the hash.
792
+ *
793
+ * @param {string|Uint8Array} input - Raw string (UTF-8) or bytes
794
+ * @returns {Promise<string>} 0x-prefixed keccak256 hash
795
+ */
796
+ export async function computeContentHash(input) {
797
+ try {
798
+ const ethers = await import('ethers');
799
+ const toBytes = typeof input === 'string' ? ethers.toUtf8Bytes(input) : input;
800
+ return ethers.keccak256(toBytes);
801
+ } catch {
802
+ throw new SDKError('computeContentHash requires peer dependency "ethers" >= 6.0.0', 'MISSING_PEER_DEP');
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Create a delay/sleep function
808
+ * @param {number} ms - Milliseconds to wait
809
+ * @returns {Promise} Promise that resolves after the delay
810
+ */
811
+ export function delay(ms) {
812
+ return new Promise(resolve => setTimeout(resolve, ms));
813
+ }
814
+
815
+ /**
816
+ * Status Polling Utility for tracking verification progress
817
+ */
818
+ export class StatusPoller {
819
+ constructor(client, qHash, options = {}) {
820
+ this.client = client;
821
+ this.qHash = qHash;
822
+ this.options = {
823
+ interval: 2000, // 2 seconds
824
+ maxAttempts: 150, // 5 minutes total
825
+ exponentialBackoff: true,
826
+ maxInterval: 10000, // 10 seconds max
827
+ ...options
828
+ };
829
+ this.attempt = 0;
830
+ this.currentInterval = this.options.interval;
831
+ }
832
+
833
+ async poll() {
834
+ return new Promise((resolve, reject) => {
835
+ const pollAttempt = async () => {
836
+ try {
837
+ this.attempt++;
838
+
839
+ const response = await this.client.getProof(this.qHash);
840
+
841
+ // Check if verification is complete using the terminal status utility
842
+ if (isTerminalStatus(response.status)) {
843
+ resolve(response);
844
+ return;
845
+ }
846
+
847
+ // Check if we've exceeded max attempts
848
+ if (this.attempt >= this.options.maxAttempts) {
849
+ reject(new SDKError(
850
+ 'Verification polling timeout',
851
+ 'POLLING_TIMEOUT'
852
+ ));
853
+ return;
854
+ }
855
+
856
+ // Schedule next poll with optional exponential backoff
857
+ if (this.options.exponentialBackoff) {
858
+ this.currentInterval = Math.min(
859
+ this.currentInterval * 1.5,
860
+ this.options.maxInterval
861
+ );
862
+ }
863
+
864
+ setTimeout(pollAttempt, this.currentInterval);
865
+
866
+ } catch (error) {
867
+ if (error instanceof ValidationError) {
868
+ reject(error);
869
+ return;
870
+ }
871
+
872
+ if ((error instanceof ApiError && error.statusCode === 429) || error?.isRetryable === true) {
873
+ if (this.options.exponentialBackoff) {
874
+ const next = Math.min(this.currentInterval * 2, this.options.maxInterval);
875
+ const jitter = next * (0.5 + Math.random() * 0.5);
876
+ this.currentInterval = Math.max(250, Math.floor(jitter));
877
+ }
878
+
879
+ if (this.attempt >= this.options.maxAttempts) {
880
+ reject(new SDKError('Verification polling timeout', 'POLLING_TIMEOUT'));
881
+ return;
882
+ }
883
+
884
+ setTimeout(pollAttempt, this.currentInterval);
885
+ return;
886
+ }
887
+
888
+ reject(new SDKError(`Polling failed: ${error.message}`, 'POLLING_ERROR'));
889
+ }
890
+ };
891
+
892
+ // Start polling immediately
893
+ pollAttempt();
894
+ });
895
+ }
896
+ }
897
+
898
+ /**
899
+ * NEUS Network Constants
900
+ */
901
+ export const NEUS_CONSTANTS = {
902
+ /** Default EVM chain id for NEUS protocol signing context (`HUB_CHAIN_ID` name kept for compatibility). */
903
+ HUB_CHAIN_ID: 84532,
904
+
905
+ // Supported target chains for cross-chain propagation
906
+ TESTNET_CHAINS: [
907
+ 11155111, // Ethereum Sepolia
908
+ 11155420, // Optimism Sepolia
909
+ 421614, // Arbitrum Sepolia
910
+ 80002 // Polygon Amoy
911
+ ],
912
+
913
+ // API endpoints
914
+ API_BASE_URL: 'https://api.neus.network',
915
+ API_VERSION: 'v1',
916
+
917
+ // Timeouts and limits
918
+ SIGNATURE_MAX_AGE_MS: 5 * 60 * 1000, // 5 minutes
919
+ REQUEST_TIMEOUT_MS: 30 * 1000, // 30 seconds
920
+
921
+ // Default verifier set for quick starts
922
+ DEFAULT_VERIFIERS: [
923
+ 'ownership-basic',
924
+ 'nft-ownership',
925
+ 'token-holding'
926
+ ]
927
+ };
928
+
929
+ /**
930
+ * Additional validation and utility helpers
931
+ */
932
+
933
+ /**
934
+ * Validate qHash format (0x + 64 hex chars)
935
+ * @param {string} qHash - The qHash to validate
936
+ * @returns {boolean} True if valid qHash format
937
+ */
938
+ export function validateQHash(qHash) {
939
+ return typeof qHash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(qHash);
940
+ }
941
+
942
+ /**
943
+ * Format timestamp to human readable string
944
+ * @param {number} timestamp - Unix timestamp
945
+ * @returns {string} Formatted date string
946
+ */
947
+ export function formatTimestamp(timestamp) {
948
+ return new Date(timestamp).toLocaleString();
949
+ }
950
+
951
+ /**
952
+ * Check if a chain ID is supported for cross-chain propagation
953
+ * @param {number} chainId - Chain ID to check
954
+ * @returns {boolean} True if supported
955
+ */
956
+ export function isSupportedChain(chainId) {
957
+ return NEUS_CONSTANTS.TESTNET_CHAINS.includes(chainId) || chainId === NEUS_CONSTANTS.HUB_CHAIN_ID;
958
+ }
959
+
960
+ /**
961
+ * Normalize wallet address to lowercase (EIP-55 agnostic)
962
+ * @param {string} address - Wallet address to normalize
963
+ * @returns {string} Lowercase address
964
+ */
965
+ export function normalizeAddress(address) {
966
+ if (!validateWalletAddress(address)) {
967
+ throw new SDKError('Invalid wallet address format', 'INVALID_ADDRESS');
968
+ }
969
+ return address.toLowerCase();
970
+ }
971
+
972
+ /**
973
+ * Validate a verifier payload for basic structural integrity.
974
+ * Lightweight validation checks; verifier authors should document complete schemas.
975
+ * @param {string} verifierId - Verifier identifier (e.g., 'ownership-basic' or custom)
976
+ * @param {any} data - Verifier-specific payload
977
+ * @returns {{ valid: boolean, error?: string, missing?: string[], warnings?: string[] }}
978
+ */
979
+ export function validateVerifierPayload(verifierId, data) {
980
+ const result = { valid: true, missing: [], warnings: [] };
981
+
982
+ if (!verifierId || typeof verifierId !== 'string') {
983
+ return { valid: false, error: 'verifierId is required and must be a string' };
984
+ }
985
+
986
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
987
+ return { valid: false, error: 'data must be a non-null object' };
988
+ }
989
+
990
+ // Minimal field hints for built-in verifiers
991
+ const id = verifierId.replace(/@\d+$/, '');
992
+ if (id === 'nft-ownership') {
993
+ ['contractAddress', 'tokenId', 'chainId'].forEach((key) => {
994
+ if (!(key in data)) result.missing.push(key);
995
+ });
996
+ if (!('ownerAddress' in data)) {
997
+ result.warnings.push('ownerAddress omitted (defaults to the signed walletAddress)');
998
+ }
999
+ } else if (id === 'token-holding') {
1000
+ ['contractAddress', 'minBalance', 'chainId'].forEach((key) => {
1001
+ if (!(key in data)) result.missing.push(key);
1002
+ });
1003
+ if (!('ownerAddress' in data)) {
1004
+ result.warnings.push('ownerAddress omitted (defaults to the signed walletAddress)');
1005
+ }
1006
+ } else if (id === 'ownership-basic') {
1007
+ // ownership-basic requires an owner, and needs at least one binding:
1008
+ // - content (inline), or
1009
+ // - contentHash (recommended for large content), or
1010
+ // - reference.id (reference-only proofs)
1011
+ if (!('owner' in data)) result.missing.push('owner');
1012
+ const hasContent = typeof data.content === 'string' && data.content.length > 0;
1013
+ const hasContentHash = typeof data.contentHash === 'string' && data.contentHash.length > 0;
1014
+ const hasRefId = typeof data.reference?.id === 'string' && data.reference.id.length > 0;
1015
+ if (!hasContent && !hasContentHash && !hasRefId) {
1016
+ result.missing.push('content (or contentHash or reference.id)');
1017
+ }
1018
+ }
1019
+
1020
+ if (result.missing.length > 0) {
1021
+ result.valid = false;
1022
+ result.error = `Missing required fields: ${result.missing.join(', ')}`;
1023
+ }
1024
+
1025
+ return result;
1026
+ }
1027
+
1028
+ /**
1029
+ * Build a standard verification request and signing message for manual flows.
1030
+ * Returns the message to sign and the request body (sans signature).
1031
+ * @param {Object} params
1032
+ * @param {string[]} params.verifierIds
1033
+ * @param {object} params.data
1034
+ * @param {string} params.walletAddress
1035
+ * @param {number} [params.chainId=NEUS_CONSTANTS.HUB_CHAIN_ID]
1036
+ * @param {object} [params.options]
1037
+ * @param {number} [params.signedTimestamp=Date.now()]
1038
+ * @returns {{ message: string, request: { verifierIds: string[], data: object, walletAddress: string, signedTimestamp: number, chainId: number, options?: object } }}
1039
+ */
1040
+ export function buildVerificationRequest({
1041
+ verifierIds,
1042
+ data,
1043
+ walletAddress,
1044
+ chainId = NEUS_CONSTANTS.HUB_CHAIN_ID,
1045
+ options = undefined,
1046
+ signedTimestamp = Date.now()
1047
+ }) {
1048
+ if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
1049
+ throw new SDKError('verifierIds must be a non-empty array', 'INVALID_ARGUMENT');
1050
+ }
1051
+ if (!validateWalletAddress(walletAddress)) {
1052
+ throw new SDKError('walletAddress must be a valid 0x address', 'INVALID_ARGUMENT');
1053
+ }
1054
+ if (!data || typeof data !== 'object') {
1055
+ throw new SDKError('data must be a non-null object', 'INVALID_ARGUMENT');
1056
+ }
1057
+ if (typeof chainId !== 'number') {
1058
+ throw new SDKError('chainId must be a number', 'INVALID_ARGUMENT');
1059
+ }
1060
+
1061
+ const message = constructVerificationMessage({
1062
+ walletAddress,
1063
+ signedTimestamp,
1064
+ data,
1065
+ verifierIds,
1066
+ chainId
1067
+ });
1068
+
1069
+ const request = {
1070
+ verifierIds,
1071
+ data,
1072
+ walletAddress,
1073
+ signedTimestamp,
1074
+ chainId,
1075
+ ...(options ? { options } : {})
1076
+ };
1077
+
1078
+ return { message, request };
1079
+ }
1080
+
1081
+ /**
1082
+ * Create a retry utility with exponential backoff
1083
+ * @param {Function} fn - Function to retry
1084
+ * @param {Object} options - Retry options
1085
+ * @returns {Promise} Promise that resolves with function result
1086
+ */
1087
+ export async function withRetry(fn, options = {}) {
1088
+ const {
1089
+ maxAttempts = 3,
1090
+ baseDelay = 1000,
1091
+ maxDelay = 10000,
1092
+ backoffFactor = 2
1093
+ } = options;
1094
+
1095
+ let lastError;
1096
+
1097
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1098
+ try {
1099
+ return await fn();
1100
+ } catch (error) {
1101
+ lastError = error;
1102
+
1103
+ if (attempt === maxAttempts) break;
1104
+
1105
+ const delayMs = Math.min(
1106
+ baseDelay * Math.pow(backoffFactor, attempt - 1),
1107
+ maxDelay
1108
+ );
1109
+
1110
+ await delay(delayMs);
1111
+ }
1112
+ }
1113
+
1114
+ throw lastError;
1115
+ }
1116
+
1117
+ /**
1118
+ * Validate signature components for debugging signature verification issues
1119
+ * @param {Object} params - Signature components to validate
1120
+ * @param {string} params.walletAddress - Wallet address
1121
+ * @param {string} params.signature - EIP-191 signature
1122
+ * @param {number} params.signedTimestamp - Unix timestamp
1123
+ * @param {Object} params.data - Verification data
1124
+ * @param {Array<string>} params.verifierIds - Array of verifier IDs
1125
+ * @param {number} params.chainId - Chain ID
1126
+ * @returns {Object} Validation result with detailed feedback
1127
+ */
1128
+ export function validateSignatureComponents({ walletAddress, signature, signedTimestamp, data, verifierIds, chainId }) {
1129
+ const result = {
1130
+ valid: true,
1131
+ errors: [],
1132
+ warnings: [],
1133
+ debugInfo: {}
1134
+ };
1135
+
1136
+ // Validate wallet address
1137
+ if (!validateWalletAddress(walletAddress)) {
1138
+ result.valid = false;
1139
+ result.errors.push('Invalid wallet address format - must be 0x + 40 hex characters');
1140
+ } else {
1141
+ result.debugInfo.normalizedAddress = walletAddress.toLowerCase();
1142
+ if (walletAddress !== walletAddress.toLowerCase()) {
1143
+ result.warnings.push('Wallet address should be lowercase for consistency');
1144
+ }
1145
+ }
1146
+
1147
+ // Validate signature format
1148
+ if (!signature || typeof signature !== 'string') {
1149
+ result.valid = false;
1150
+ result.errors.push('Signature is required and must be a string');
1151
+ } else if (!/^0x[a-fA-F0-9]{130}$/.test(signature)) {
1152
+ result.valid = false;
1153
+ result.errors.push('Invalid signature format - must be 0x + 130 hex characters (65 bytes)');
1154
+ }
1155
+
1156
+ // Validate timestamp
1157
+ if (!validateTimestamp(signedTimestamp)) {
1158
+ result.valid = false;
1159
+ result.errors.push('Invalid or expired timestamp - must be within 5 minutes');
1160
+ } else {
1161
+ result.debugInfo.timestampAge = Date.now() - signedTimestamp;
1162
+ }
1163
+
1164
+ // Validate data object
1165
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
1166
+ result.valid = false;
1167
+ result.errors.push('Data must be a non-null object');
1168
+ } else {
1169
+ result.debugInfo.dataString = deterministicStringify(data);
1170
+ }
1171
+
1172
+ // Validate verifier IDs
1173
+ if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
1174
+ result.valid = false;
1175
+ result.errors.push('VerifierIds must be a non-empty array');
1176
+ }
1177
+
1178
+ // Validate chain ID
1179
+ if (typeof chainId !== 'number') {
1180
+ result.valid = false;
1181
+ result.errors.push('ChainId must be a number');
1182
+ }
1183
+
1184
+ // Generate the message that would be signed
1185
+ if (result.valid || result.errors.length < 3) {
1186
+ try {
1187
+ result.debugInfo.messageToSign = constructVerificationMessage({
1188
+ walletAddress: walletAddress?.toLowerCase() || walletAddress,
1189
+ signedTimestamp,
1190
+ data,
1191
+ verifierIds,
1192
+ chainId
1193
+ });
1194
+ } catch (error) {
1195
+ result.errors.push(`Failed to construct message: ${error.message}`);
1196
+ }
1197
+ }
1198
+
1199
+ return result;
1200
+ }
1201
+
1202
+ /**
1203
+ * Convert a non-negative decimal display amount to agent-delegation `maxSpend`
1204
+ * (whole-number string in token base units, 1–78 digits).
1205
+ * @param {string|number} humanAmount - e.g. "100.50" or 100.5
1206
+ * @param {number} decimals - Number of decimal places for the token (e.g. 6 for USDC, 18 for ETH)
1207
+ * @returns {string}
1208
+ */
1209
+ export function toAgentDelegationMaxSpend(humanAmount, decimals) {
1210
+ if (humanAmount === undefined || humanAmount === null) {
1211
+ throw new ValidationError('humanAmount is required', 'humanAmount', humanAmount);
1212
+ }
1213
+ const s0 = String(humanAmount).trim();
1214
+ if (!s0) {
1215
+ throw new ValidationError('humanAmount must be non-empty', 'humanAmount', humanAmount);
1216
+ }
1217
+ if (s0.startsWith('-') || s0.startsWith('+')) {
1218
+ throw new ValidationError('humanAmount must be non-negative', 'humanAmount', humanAmount);
1219
+ }
1220
+ const d = Number(decimals);
1221
+ if (!Number.isInteger(d) || d < 0 || d > 78) {
1222
+ throw new ValidationError('decimalPlaces must be an integer from 0 to 78', 'decimals', decimals);
1223
+ }
1224
+ if (!/^(?:\d+\.?\d*|\.\d+)$/.test(s0)) {
1225
+ throw new ValidationError(
1226
+ 'humanAmount must be a decimal string (e.g. "100" or "100.50")',
1227
+ 'humanAmount',
1228
+ humanAmount
1229
+ );
1230
+ }
1231
+ const dot = s0.indexOf('.');
1232
+ const intPart = dot === -1 ? s0 : s0.slice(0, dot);
1233
+ const fracRaw = dot === -1 ? '' : s0.slice(dot + 1);
1234
+ const intNormalized =
1235
+ intPart === ''
1236
+ ? '0'
1237
+ : (() => {
1238
+ const s = intPart.replace(/^0+/, '');
1239
+ return s === '' ? '0' : s;
1240
+ })();
1241
+ if (!/^\d+$/.test(intNormalized)) {
1242
+ throw new ValidationError('humanAmount has an invalid integer part', 'humanAmount', humanAmount);
1243
+ }
1244
+ if (fracRaw && !/^\d*$/.test(fracRaw)) {
1245
+ throw new ValidationError('humanAmount has an invalid fractional part', 'humanAmount', humanAmount);
1246
+ }
1247
+ const fracPadded = `${fracRaw}${'0'.repeat(d)}`.slice(0, d);
1248
+ const base = BigInt(10) ** BigInt(d);
1249
+ const value = BigInt(intNormalized) * base + BigInt(fracPadded || '0');
1250
+ const out = value.toString();
1251
+ if (out.length > 78) {
1252
+ throw new ValidationError('maxSpend exceeds 78-digit limit after conversion', 'humanAmount', humanAmount);
1253
+ }
1254
+ return out;
1255
+ }
1256
+
1257
+ /** Default hosted verify base URL */
1258
+ export const DEFAULT_HOSTED_VERIFY_URL = 'https://neus.network/verify';
1259
+
1260
+ /**
1261
+ * Build standardized hosted checkout/verify URL for your app.
1262
+ * Single typed entry point to avoid copy-paste errors.
1263
+ * @param {Object} opts
1264
+ * @param {string} [opts.gateId] - Gate ID for gate-backed checkout
1265
+ * @param {string} [opts.returnUrl] - Partner return URL (postMessage/redirect)
1266
+ * @param {string[]} [opts.verifiers] - Verifier IDs (comma-joined)
1267
+ * @param {string} [opts.preset] - Preset name (e.g. 'human')
1268
+ * @param {string} [opts.mode] - 'popup' or null
1269
+ * @param {string} [opts.intent] - 'login' for auth-code flow
1270
+ * @param {string} [opts.origin] - Allowed parent origin for popup completion
1271
+ * @param {string} [opts.oauthProvider] - Optional OAuth provider id to pre-select for social/org verifiers (hosted flow)
1272
+ * @param {string} [opts.baseUrl] - Hosted verify URL override
1273
+ * @returns {string} Full URL
1274
+ */
1275
+ export function getHostedCheckoutUrl(opts = {}) {
1276
+ const base = typeof opts.baseUrl === 'string' && opts.baseUrl.trim()
1277
+ ? opts.baseUrl.replace(/\/+$/, '')
1278
+ : DEFAULT_HOSTED_VERIFY_URL;
1279
+ const params = new URLSearchParams();
1280
+ if (opts.gateId) params.set('gateId', String(opts.gateId));
1281
+ if (opts.returnUrl) params.set('returnUrl', String(opts.returnUrl));
1282
+ if (Array.isArray(opts.verifiers) && opts.verifiers.length > 0) {
1283
+ params.set('verifiers', opts.verifiers.filter(Boolean).join(','));
1284
+ }
1285
+ if (opts.preset) params.set('preset', String(opts.preset));
1286
+ if (opts.mode) params.set('mode', String(opts.mode));
1287
+ if (opts.intent) params.set('intent', String(opts.intent));
1288
+ if (opts.origin) params.set('origin', String(opts.origin));
1289
+ if (opts.oauthProvider) params.set('oauthProvider', String(opts.oauthProvider));
1290
+ const qs = params.toString();
1291
+ return qs ? `${base}?${qs}` : base;
778
1292
  }