@neus/sdk 1.0.3 → 1.0.5

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