@neus/sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +181 -0
- package/README.md +206 -0
- package/SECURITY.md +224 -0
- package/client.js +844 -0
- package/errors.js +228 -0
- package/index.js +70 -0
- package/package.json +75 -0
- package/types.d.ts +550 -0
- package/utils.js +722 -0
package/client.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NEUS SDK Client
|
|
3
|
+
* Create and verify cryptographic proofs across applications
|
|
4
|
+
* @license Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ApiError, ValidationError, NetworkError, ConfigurationError } from './errors.js';
|
|
8
|
+
import { constructVerificationMessage, validateWalletAddress, NEUS_CONSTANTS } from './utils.js';
|
|
9
|
+
// Validation for supported verifiers
|
|
10
|
+
const validateVerifierData = (verifierId, data) => {
|
|
11
|
+
if (!data || typeof data !== 'object') {
|
|
12
|
+
return { valid: false, error: 'Data object is required' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Validate wallet address if present
|
|
16
|
+
// Validate owner/ownerAddress fields based on verifier type
|
|
17
|
+
const ownerField =
|
|
18
|
+
verifierId === 'nft-ownership' || verifierId === 'token-holding' ? 'ownerAddress' : 'owner';
|
|
19
|
+
if (data[ownerField] && !validateWalletAddress(data[ownerField])) {
|
|
20
|
+
return { valid: false, error: `Invalid ${ownerField} address` };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Format validation for supported verifiers
|
|
24
|
+
switch (verifierId) {
|
|
25
|
+
case 'ownership-basic':
|
|
26
|
+
if (!data.content) {
|
|
27
|
+
return { valid: false, error: 'content is required' };
|
|
28
|
+
}
|
|
29
|
+
break;
|
|
30
|
+
case 'nft-ownership':
|
|
31
|
+
if (
|
|
32
|
+
!data.ownerAddress ||
|
|
33
|
+
!data.contractAddress ||
|
|
34
|
+
data.tokenId == null ||
|
|
35
|
+
typeof data.chainId !== 'number'
|
|
36
|
+
) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
error: 'ownerAddress, contractAddress, tokenId, and chainId are required'
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (!validateWalletAddress(data.contractAddress)) {
|
|
43
|
+
return { valid: false, error: 'Invalid contractAddress' };
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
case 'token-holding':
|
|
47
|
+
if (
|
|
48
|
+
!data.ownerAddress ||
|
|
49
|
+
!data.contractAddress ||
|
|
50
|
+
data.minBalance == null ||
|
|
51
|
+
typeof data.chainId !== 'number'
|
|
52
|
+
) {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
error: 'ownerAddress, contractAddress, minBalance, and chainId are required'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (!validateWalletAddress(data.contractAddress)) {
|
|
59
|
+
return { valid: false, error: 'Invalid contractAddress' };
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case 'ownership-licensed':
|
|
63
|
+
if (!data.content) {
|
|
64
|
+
return { valid: false, error: 'content is required for ownership-licensed' };
|
|
65
|
+
}
|
|
66
|
+
if (!data.owner && !data.license?.ownerAddress) {
|
|
67
|
+
return { valid: false, error: 'owner or license.ownerAddress is required' };
|
|
68
|
+
}
|
|
69
|
+
if (!data.license) {
|
|
70
|
+
return { valid: false, error: 'license object is required' };
|
|
71
|
+
}
|
|
72
|
+
if (!data.license.contractAddress || !validateWalletAddress(data.license.contractAddress)) {
|
|
73
|
+
return { valid: false, error: 'license.contractAddress must be a valid Ethereum address' };
|
|
74
|
+
}
|
|
75
|
+
if (!data.license.tokenId) {
|
|
76
|
+
return { valid: false, error: 'license.tokenId is required' };
|
|
77
|
+
}
|
|
78
|
+
if (typeof data.license.chainId !== 'number') {
|
|
79
|
+
return { valid: false, error: 'license.chainId must be a number' };
|
|
80
|
+
}
|
|
81
|
+
if (!data.license.ownerAddress || !validateWalletAddress(data.license.ownerAddress)) {
|
|
82
|
+
return { valid: false, error: 'license.ownerAddress must be a valid Ethereum address' };
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { valid: true };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export class NeusClient {
|
|
91
|
+
constructor(config = {}) {
|
|
92
|
+
this.config = {
|
|
93
|
+
timeout: 30000,
|
|
94
|
+
enableLogging: false,
|
|
95
|
+
allowPublicFallback: false,
|
|
96
|
+
...config
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// NEUS Network API
|
|
100
|
+
this.baseUrl = this.config.apiUrl || 'https://api.neus.network';
|
|
101
|
+
// Enforce HTTPS for neus.network domains to satisfy CSP and normalize URLs
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(this.baseUrl);
|
|
104
|
+
if (url.hostname.endsWith('neus.network') && url.protocol === 'http:') {
|
|
105
|
+
url.protocol = 'https:';
|
|
106
|
+
}
|
|
107
|
+
// Always remove trailing slash for consistency
|
|
108
|
+
this.baseUrl = url.toString().replace(/\/$/, '');
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// If invalid URL string, leave as-is
|
|
111
|
+
this.logger?.debug('URL parsing failed, using as-is:', error.message);
|
|
112
|
+
}
|
|
113
|
+
// Normalize apiUrl on config
|
|
114
|
+
this.config.apiUrl = this.baseUrl;
|
|
115
|
+
// Default headers for API requests
|
|
116
|
+
this.defaultHeaders = {
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
Accept: 'application/json',
|
|
119
|
+
'X-Neus-Sdk': 'js'
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Optional app-level identification
|
|
123
|
+
if (typeof this.config.appId === 'string' && this.config.appId.trim().length > 0) {
|
|
124
|
+
this.defaultHeaders['X-Neus-App'] = this.config.appId.trim();
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
// Attach origin in browser environments
|
|
128
|
+
if (typeof window !== 'undefined' && window.location && window.location.origin) {
|
|
129
|
+
this.defaultHeaders['X-Client-Origin'] = window.location.origin;
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Ignore origin detection errors
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this._log('NEUS Network Client initialized');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// CORE VERIFICATION METHODS
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* VERIFY - Canonical verification (auto or manual)
|
|
144
|
+
*
|
|
145
|
+
* Create proofs with complete control over the verification process.
|
|
146
|
+
* If signature and walletAddress are omitted but verifier/content are provided,
|
|
147
|
+
* this method performs the wallet flow inline (no aliases, no secondary methods).
|
|
148
|
+
*
|
|
149
|
+
* @param {Object} params - Verification parameters
|
|
150
|
+
* @param {Array<string>} [params.verifierIds] - Array of verifier IDs (manual path)
|
|
151
|
+
* @param {Object} [params.data] - Verification data object (manual path)
|
|
152
|
+
* @param {string} [params.walletAddress] - Wallet address that signed the request (manual path)
|
|
153
|
+
* @param {string} [params.signature] - EIP-191 signature (manual path)
|
|
154
|
+
* @param {number} [params.signedTimestamp] - Unix timestamp when signature was created (manual path)
|
|
155
|
+
* @param {number} [params.chainId] - Chain ID for verification context (optional, managed by protocol)
|
|
156
|
+
* @param {Object} [params.options] - Additional options
|
|
157
|
+
* @param {string} [params.verifier] - Verifier ID (auto path)
|
|
158
|
+
* @param {string} [params.content] - Content/description (auto path)
|
|
159
|
+
* @param {Object} [params.wallet] - Optional injected wallet/provider (auto path)
|
|
160
|
+
* @returns {Promise<Object>} Verification result with qHash
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* const proof = await client.verify({
|
|
164
|
+
* verifierIds: ['ownership-basic'],
|
|
165
|
+
* data: {
|
|
166
|
+
* content: "My content",
|
|
167
|
+
* owner: walletAddress, // or ownerAddress for nft-ownership/token-holding
|
|
168
|
+
* reference: { type: 'content', id: 'my-unique-identifier' }
|
|
169
|
+
* },
|
|
170
|
+
* walletAddress: '0x...',
|
|
171
|
+
* signature: '0x...',
|
|
172
|
+
* signedTimestamp: Date.now(),
|
|
173
|
+
* options: { targetChains: [421614, 11155111] }
|
|
174
|
+
* });
|
|
175
|
+
*/
|
|
176
|
+
/**
|
|
177
|
+
* Create a verification proof
|
|
178
|
+
*
|
|
179
|
+
* @param {Object} params - Verification parameters
|
|
180
|
+
* @param {string} [params.verifier] - Verifier ID (e.g., 'ownership-basic')
|
|
181
|
+
* @param {string} [params.content] - Content to verify
|
|
182
|
+
* @param {Object} [params.data] - Structured verification data
|
|
183
|
+
* @param {Object} [params.wallet] - Wallet provider
|
|
184
|
+
* @param {Object} [params.options] - Additional options
|
|
185
|
+
* @returns {Promise<Object>} Verification result with qHash
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* // Simple ownership proof
|
|
189
|
+
* const proof = await client.verify({
|
|
190
|
+
* verifier: 'ownership-basic',
|
|
191
|
+
* content: 'Hello World',
|
|
192
|
+
* wallet: window.ethereum
|
|
193
|
+
* });
|
|
194
|
+
*/
|
|
195
|
+
async verify(params) {
|
|
196
|
+
// Auto path: if no manual signature fields but auto fields are provided, perform inline wallet flow
|
|
197
|
+
if (
|
|
198
|
+
(!params?.signature || !params?.walletAddress) &&
|
|
199
|
+
(params?.verifier || params?.content || params?.data)
|
|
200
|
+
) {
|
|
201
|
+
const {
|
|
202
|
+
content,
|
|
203
|
+
verifier = 'ownership-basic',
|
|
204
|
+
data = null,
|
|
205
|
+
wallet = null,
|
|
206
|
+
options = {}
|
|
207
|
+
} = params;
|
|
208
|
+
|
|
209
|
+
if (verifier === 'ownership-basic' && (!content || typeof content !== 'string')) {
|
|
210
|
+
throw new ValidationError('content is required and must be a string');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const validVerifiers = [
|
|
214
|
+
'ownership-basic',
|
|
215
|
+
'nft-ownership',
|
|
216
|
+
'token-holding',
|
|
217
|
+
'ownership-licensed'
|
|
218
|
+
];
|
|
219
|
+
if (!validVerifiers.includes(verifier)) {
|
|
220
|
+
throw new ValidationError(
|
|
221
|
+
`Invalid verifier '${verifier}'. Must be one of: ${validVerifiers.join(', ')}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Auto-detect wallet and get address
|
|
226
|
+
let walletAddress, provider;
|
|
227
|
+
if (wallet) {
|
|
228
|
+
walletAddress = wallet.address || wallet.selectedAddress;
|
|
229
|
+
provider = wallet.provider || wallet;
|
|
230
|
+
} else {
|
|
231
|
+
if (typeof window === 'undefined' || !window.ethereum) {
|
|
232
|
+
throw new ConfigurationError(
|
|
233
|
+
'No Web3 wallet detected. Please install MetaMask or provide wallet parameter.'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
237
|
+
provider = window.ethereum;
|
|
238
|
+
const accounts = await provider.request({ method: 'eth_accounts' });
|
|
239
|
+
walletAddress = accounts[0];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Prepare verification data based on verifier type
|
|
243
|
+
let verificationData;
|
|
244
|
+
if (verifier === 'ownership-basic') {
|
|
245
|
+
verificationData = {
|
|
246
|
+
content: content,
|
|
247
|
+
owner: walletAddress,
|
|
248
|
+
reference: { type: 'content', id: content.substring(0, 32) }
|
|
249
|
+
};
|
|
250
|
+
} else if (verifier === 'ownership-licensed') {
|
|
251
|
+
if (!data?.license && (!data?.contractAddress || !data?.tokenId)) {
|
|
252
|
+
throw new ValidationError(
|
|
253
|
+
'ownership-licensed requires either license object or contractAddress + tokenId'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
verificationData = {
|
|
257
|
+
content: content || 'Licensed content',
|
|
258
|
+
owner: walletAddress,
|
|
259
|
+
license: data?.license || {
|
|
260
|
+
contractAddress: data?.contractAddress,
|
|
261
|
+
tokenId: data?.tokenId,
|
|
262
|
+
chainId: data?.chainId,
|
|
263
|
+
ownerAddress: walletAddress,
|
|
264
|
+
type: data?.licenseType || 'erc721'
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
} else if (verifier === 'token-holding') {
|
|
268
|
+
verificationData = {
|
|
269
|
+
ownerAddress: walletAddress,
|
|
270
|
+
contractAddress: data?.contractAddress,
|
|
271
|
+
minBalance: data?.minBalance,
|
|
272
|
+
chainId: data?.chainId
|
|
273
|
+
};
|
|
274
|
+
} else if (verifier === 'nft-ownership') {
|
|
275
|
+
verificationData = {
|
|
276
|
+
ownerAddress: walletAddress,
|
|
277
|
+
contractAddress: data?.contractAddress,
|
|
278
|
+
tokenId: data?.tokenId,
|
|
279
|
+
chainId: data?.chainId,
|
|
280
|
+
tokenType: data?.tokenType || 'erc721'
|
|
281
|
+
};
|
|
282
|
+
} else {
|
|
283
|
+
// Default structure for unknown verifiers
|
|
284
|
+
verificationData = data
|
|
285
|
+
? {
|
|
286
|
+
content,
|
|
287
|
+
owner: walletAddress,
|
|
288
|
+
...data
|
|
289
|
+
}
|
|
290
|
+
: {
|
|
291
|
+
content,
|
|
292
|
+
owner: walletAddress
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const signedTimestamp = Date.now();
|
|
297
|
+
const verifierIds = [verifier];
|
|
298
|
+
const message = constructVerificationMessage({
|
|
299
|
+
walletAddress,
|
|
300
|
+
signedTimestamp,
|
|
301
|
+
data: verificationData,
|
|
302
|
+
verifierIds,
|
|
303
|
+
chainId: NEUS_CONSTANTS.HUB_CHAIN_ID // Protocol-managed chain
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
let signature;
|
|
307
|
+
try {
|
|
308
|
+
signature = await provider.request({
|
|
309
|
+
method: 'personal_sign',
|
|
310
|
+
params: [message, walletAddress]
|
|
311
|
+
});
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error.code === 4001) {
|
|
314
|
+
throw new ValidationError(
|
|
315
|
+
'User rejected the signature request. Signature is required to create proofs.'
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
throw new ValidationError(`Failed to sign verification message: ${error.message}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return this.verify({
|
|
322
|
+
verifierIds,
|
|
323
|
+
data: verificationData,
|
|
324
|
+
walletAddress,
|
|
325
|
+
signature,
|
|
326
|
+
signedTimestamp,
|
|
327
|
+
options
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const {
|
|
332
|
+
verifierIds,
|
|
333
|
+
data,
|
|
334
|
+
walletAddress,
|
|
335
|
+
signature,
|
|
336
|
+
signedTimestamp,
|
|
337
|
+
chainId = NEUS_CONSTANTS.HUB_CHAIN_ID,
|
|
338
|
+
options = {}
|
|
339
|
+
} = params;
|
|
340
|
+
|
|
341
|
+
// Normalize verifier IDs
|
|
342
|
+
const normalizeVerifierId = id => {
|
|
343
|
+
if (typeof id !== 'string') return id;
|
|
344
|
+
const match = id.match(/^(.*)@\d+$/);
|
|
345
|
+
return match ? match[1] : id;
|
|
346
|
+
};
|
|
347
|
+
const normalizedVerifierIds = Array.isArray(verifierIds)
|
|
348
|
+
? verifierIds.map(normalizeVerifierId)
|
|
349
|
+
: [];
|
|
350
|
+
|
|
351
|
+
// Validate required parameters
|
|
352
|
+
if (!normalizedVerifierIds || normalizedVerifierIds.length === 0) {
|
|
353
|
+
throw new ValidationError('verifierIds array is required');
|
|
354
|
+
}
|
|
355
|
+
if (!data || typeof data !== 'object') {
|
|
356
|
+
throw new ValidationError('data object is required');
|
|
357
|
+
}
|
|
358
|
+
if (!walletAddress || !/^0x[a-fA-F0-9]{40}$/i.test(walletAddress)) {
|
|
359
|
+
throw new ValidationError('Valid walletAddress is required');
|
|
360
|
+
}
|
|
361
|
+
if (!signature) {
|
|
362
|
+
throw new ValidationError('signature is required');
|
|
363
|
+
}
|
|
364
|
+
if (!signedTimestamp || typeof signedTimestamp !== 'number') {
|
|
365
|
+
throw new ValidationError('signedTimestamp is required');
|
|
366
|
+
}
|
|
367
|
+
if (typeof chainId !== 'number') {
|
|
368
|
+
throw new ValidationError('chainId must be a number');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Validate verifier data
|
|
372
|
+
for (const verifierId of normalizedVerifierIds) {
|
|
373
|
+
const validation = validateVerifierData(verifierId, data);
|
|
374
|
+
if (!validation.valid) {
|
|
375
|
+
throw new ValidationError(
|
|
376
|
+
`Validation failed for verifier '${verifierId}': ${validation.error}`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const requestData = {
|
|
382
|
+
verifierIds: normalizedVerifierIds,
|
|
383
|
+
data,
|
|
384
|
+
walletAddress,
|
|
385
|
+
signature,
|
|
386
|
+
signedTimestamp,
|
|
387
|
+
chainId,
|
|
388
|
+
options: {
|
|
389
|
+
...options,
|
|
390
|
+
targetChains: options?.targetChains || [],
|
|
391
|
+
// Privacy and storage options
|
|
392
|
+
privacyLevel: options?.privacyLevel || 'private',
|
|
393
|
+
publicDisplay: options?.publicDisplay || false,
|
|
394
|
+
storeOriginalContent: options?.storeOriginalContent || false,
|
|
395
|
+
enableIpfs: options?.enableIpfs || false,
|
|
396
|
+
forceZK: options?.forceZK || false
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const response = await this._makeRequest('POST', '/api/v1/verification', requestData, {
|
|
401
|
+
Authorization: `Bearer ${signature}`
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
if (!response.success) {
|
|
405
|
+
throw new ApiError(
|
|
406
|
+
`Verification failed: ${response.error?.message || 'Unknown error'}`,
|
|
407
|
+
response.error
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return this._formatResponse(response);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ============================================================================
|
|
415
|
+
// STATUS AND UTILITY METHODS
|
|
416
|
+
// ============================================================================
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Get verification status
|
|
420
|
+
*
|
|
421
|
+
* @param {string} qHash - Verification ID (qHash or proofId)
|
|
422
|
+
* @param {Object} auth - Optional authentication for private proofs
|
|
423
|
+
* @returns {Promise<Object>} Verification status and data
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* const result = await client.getStatus('0x...');
|
|
427
|
+
* console.log('Status:', result.status);
|
|
428
|
+
*/
|
|
429
|
+
async getStatus(qHash, auth = undefined) {
|
|
430
|
+
if (!qHash || typeof qHash !== 'string') {
|
|
431
|
+
throw new ValidationError('qHash is required');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const headers = {};
|
|
435
|
+
if (auth?.signature && auth?.walletAddress) {
|
|
436
|
+
headers.Authorization = `Bearer ${auth.signature}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const response = await this._makeRequest(
|
|
440
|
+
'GET',
|
|
441
|
+
`/api/v1/verification/status/${qHash}`,
|
|
442
|
+
null,
|
|
443
|
+
headers
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
if (!response.success) {
|
|
447
|
+
throw new ApiError(
|
|
448
|
+
`Failed to get status: ${response.error?.message || 'Unknown error'}`,
|
|
449
|
+
response.error
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return this._formatResponse(response);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Get private proof status with wallet signature
|
|
458
|
+
*
|
|
459
|
+
* @param {string} qHash - Verification ID
|
|
460
|
+
* @param {Object} wallet - Wallet provider (window.ethereum or ethers Wallet)
|
|
461
|
+
* @returns {Promise<Object>} Private verification status and data
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* // Access private proof
|
|
465
|
+
* const privateData = await client.getPrivateStatus(qHash, window.ethereum);
|
|
466
|
+
*/
|
|
467
|
+
async getPrivateStatus(qHash, wallet = null) {
|
|
468
|
+
if (!qHash || typeof qHash !== 'string') {
|
|
469
|
+
throw new ValidationError('qHash is required');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Auto-detect wallet if not provided
|
|
473
|
+
if (!wallet) {
|
|
474
|
+
if (typeof window === 'undefined' || !window.ethereum) {
|
|
475
|
+
throw new ConfigurationError('No wallet provider available');
|
|
476
|
+
}
|
|
477
|
+
wallet = window.ethereum;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
let walletAddress, provider;
|
|
481
|
+
|
|
482
|
+
// Handle different wallet types
|
|
483
|
+
if (wallet.address) {
|
|
484
|
+
// ethers Wallet
|
|
485
|
+
walletAddress = wallet.address;
|
|
486
|
+
provider = wallet;
|
|
487
|
+
} else if (wallet.selectedAddress || wallet.request) {
|
|
488
|
+
// Browser provider (MetaMask, etc.)
|
|
489
|
+
provider = wallet;
|
|
490
|
+
if (wallet.selectedAddress) {
|
|
491
|
+
walletAddress = wallet.selectedAddress;
|
|
492
|
+
} else {
|
|
493
|
+
const accounts = await provider.request({ method: 'eth_accounts' });
|
|
494
|
+
if (!accounts || accounts.length === 0) {
|
|
495
|
+
throw new ConfigurationError('No wallet accounts available');
|
|
496
|
+
}
|
|
497
|
+
walletAddress = accounts[0];
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
throw new ConfigurationError('Invalid wallet provider');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const signedTimestamp = Date.now();
|
|
504
|
+
|
|
505
|
+
// Use existing working message format
|
|
506
|
+
const message = `Access private proof: ${qHash}`;
|
|
507
|
+
|
|
508
|
+
let signature;
|
|
509
|
+
try {
|
|
510
|
+
if (provider.signMessage) {
|
|
511
|
+
// ethers Wallet
|
|
512
|
+
signature = await provider.signMessage(message);
|
|
513
|
+
} else {
|
|
514
|
+
// Browser provider
|
|
515
|
+
signature = await provider.request({
|
|
516
|
+
method: 'personal_sign',
|
|
517
|
+
params: [message, walletAddress]
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (error.code === 4001) {
|
|
522
|
+
throw new ValidationError('User rejected signature request');
|
|
523
|
+
}
|
|
524
|
+
throw new ValidationError(`Failed to sign message: ${error.message}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Make request with signature headers
|
|
528
|
+
const response = await this._makeRequest('GET', `/api/v1/verification/status/${qHash}`, null, {
|
|
529
|
+
'x-wallet-address': walletAddress,
|
|
530
|
+
'x-signature': signature,
|
|
531
|
+
'x-signed-timestamp': signedTimestamp.toString()
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
if (!response.success) {
|
|
535
|
+
throw new ApiError(
|
|
536
|
+
`Failed to access private proof: ${response.error?.message || 'Unauthorized'}`,
|
|
537
|
+
response.error
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return this._formatResponse(response);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Check API health
|
|
546
|
+
*
|
|
547
|
+
* @returns {Promise<boolean>} True if API is healthy
|
|
548
|
+
*/
|
|
549
|
+
async isHealthy() {
|
|
550
|
+
try {
|
|
551
|
+
const response = await this._makeRequest('GET', '/api/v1/health');
|
|
552
|
+
return response.success === true;
|
|
553
|
+
} catch {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* List available verifiers
|
|
560
|
+
*
|
|
561
|
+
* @returns {Promise<string[]>} Array of verifier IDs
|
|
562
|
+
*/
|
|
563
|
+
async getVerifiers() {
|
|
564
|
+
const response = await this._makeRequest('GET', '/api/v1/verification/verifiers');
|
|
565
|
+
if (!response.success) {
|
|
566
|
+
throw new ApiError(
|
|
567
|
+
`Failed to get verifiers: ${response.error?.message || 'Unknown error'}`,
|
|
568
|
+
response.error
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return Array.isArray(response.data) ? response.data : [];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* POLL PROOF STATUS - Wait for verification completion
|
|
576
|
+
*
|
|
577
|
+
* Polls the verification status until it reaches a terminal state (completed or failed).
|
|
578
|
+
* Useful for providing real-time feedback to users during verification.
|
|
579
|
+
*
|
|
580
|
+
* @param {string} qHash - Verification ID to poll
|
|
581
|
+
* @param {Object} [options] - Polling options
|
|
582
|
+
* @param {number} [options.interval=5000] - Polling interval in ms
|
|
583
|
+
* @param {number} [options.timeout=120000] - Total timeout in ms
|
|
584
|
+
* @param {Function} [options.onProgress] - Progress callback function
|
|
585
|
+
* @returns {Promise<Object>} Final verification status
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* const finalStatus = await client.pollProofStatus(qHash, {
|
|
589
|
+
* interval: 3000,
|
|
590
|
+
* timeout: 60000,
|
|
591
|
+
* onProgress: (status) => {
|
|
592
|
+
* console.log('Current status:', status.status);
|
|
593
|
+
* if (status.crosschain) {
|
|
594
|
+
* console.log(`Cross-chain: ${status.crosschain.finalized}/${status.crosschain.totalChains}`);
|
|
595
|
+
* }
|
|
596
|
+
* }
|
|
597
|
+
* });
|
|
598
|
+
*/
|
|
599
|
+
async pollProofStatus(qHash, options = {}) {
|
|
600
|
+
const { interval = 5000, timeout = 120000, onProgress } = options;
|
|
601
|
+
|
|
602
|
+
if (!qHash || typeof qHash !== 'string') {
|
|
603
|
+
throw new ValidationError('qHash is required');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const startTime = Date.now();
|
|
607
|
+
|
|
608
|
+
while (Date.now() - startTime < timeout) {
|
|
609
|
+
try {
|
|
610
|
+
const status = await this.getStatus(qHash);
|
|
611
|
+
|
|
612
|
+
// Call progress callback if provided
|
|
613
|
+
if (onProgress && typeof onProgress === 'function') {
|
|
614
|
+
onProgress(status.data || status);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Check for terminal states
|
|
618
|
+
const currentStatus = status.data?.status || status.status;
|
|
619
|
+
if (this._isTerminalStatus(currentStatus)) {
|
|
620
|
+
this._log('Verification completed', {
|
|
621
|
+
status: currentStatus,
|
|
622
|
+
duration: Date.now() - startTime
|
|
623
|
+
});
|
|
624
|
+
return status;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Wait before next poll
|
|
628
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
this._log('Status poll error', error.message);
|
|
631
|
+
// Continue polling unless it's a validation error
|
|
632
|
+
if (error instanceof ValidationError) {
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
throw new NetworkError(`Polling timeout after ${timeout}ms`, 'POLLING_TIMEOUT');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* DETECT CHAIN ID - Get current wallet chain
|
|
644
|
+
*
|
|
645
|
+
* @returns {Promise<number>} Current chain ID
|
|
646
|
+
*/
|
|
647
|
+
async detectChainId() {
|
|
648
|
+
if (typeof window === 'undefined' || !window.ethereum) {
|
|
649
|
+
throw new ConfigurationError('No Web3 wallet detected');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const chainId = await window.ethereum.request({ method: 'eth_chainId' });
|
|
654
|
+
return parseInt(chainId, 16);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
throw new NetworkError(`Failed to detect chain ID: ${error.message}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ============================================================================
|
|
661
|
+
// SPECIALIZED VERIFIER METHODS
|
|
662
|
+
// ============================================================================
|
|
663
|
+
|
|
664
|
+
/** Revoke your own proof (owner-signed) */
|
|
665
|
+
async revokeOwnProof(qHash, wallet) {
|
|
666
|
+
if (!qHash || typeof qHash !== 'string') {
|
|
667
|
+
throw new ValidationError('qHash is required');
|
|
668
|
+
}
|
|
669
|
+
const address = wallet?.address || (await this._getWalletAddress());
|
|
670
|
+
const signedTimestamp = Date.now();
|
|
671
|
+
const hubChainId = NEUS_CONSTANTS.HUB_CHAIN_ID;
|
|
672
|
+
|
|
673
|
+
const message = constructVerificationMessage({
|
|
674
|
+
walletAddress: address,
|
|
675
|
+
signedTimestamp,
|
|
676
|
+
data: { action: 'revoke_proof', qHash },
|
|
677
|
+
verifierIds: ['ownership-basic'],
|
|
678
|
+
chainId: hubChainId
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const signature = await window.ethereum.request({
|
|
682
|
+
method: 'personal_sign',
|
|
683
|
+
params: [message, address]
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const res = await fetch(`${this.config.apiUrl}/api/v1/proofs/${qHash}/revoke-self`, {
|
|
687
|
+
method: 'POST',
|
|
688
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${signature}` },
|
|
689
|
+
body: JSON.stringify({ walletAddress: address, signature, signedTimestamp })
|
|
690
|
+
});
|
|
691
|
+
const json = await res.json();
|
|
692
|
+
if (!json.success) {
|
|
693
|
+
throw new ApiError(json.error?.message || 'Failed to revoke proof', json.error);
|
|
694
|
+
}
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ============================================================================
|
|
699
|
+
// PRIVATE UTILITY METHODS
|
|
700
|
+
// ============================================================================
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Get connected wallet address
|
|
704
|
+
* @private
|
|
705
|
+
*/
|
|
706
|
+
async _getWalletAddress() {
|
|
707
|
+
if (typeof window === 'undefined' || !window.ethereum) {
|
|
708
|
+
throw new ConfigurationError('No Web3 wallet detected');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
|
|
712
|
+
if (!accounts || accounts.length === 0) {
|
|
713
|
+
throw new ConfigurationError('No wallet accounts available');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return accounts[0];
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Make HTTP request to API
|
|
721
|
+
* @private
|
|
722
|
+
*/
|
|
723
|
+
async _makeRequest(method, endpoint, data = null, headersOverride = null) {
|
|
724
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
725
|
+
|
|
726
|
+
const controller = new AbortController();
|
|
727
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
728
|
+
|
|
729
|
+
const options = {
|
|
730
|
+
method,
|
|
731
|
+
headers: { ...this.defaultHeaders, ...(headersOverride || {}) },
|
|
732
|
+
signal: controller.signal
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
736
|
+
options.body = JSON.stringify(data);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
this._log(`${method} ${endpoint}`, data ? { requestBodyKeys: Object.keys(data) } : {});
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
let response = await fetch(url, options);
|
|
743
|
+
// Fallback: if local baseUrl is misconfigured and returns 404/405, retry against public API
|
|
744
|
+
if (
|
|
745
|
+
this.config.allowPublicFallback &&
|
|
746
|
+
!response.ok &&
|
|
747
|
+
(response.status === 404 || response.status === 405)
|
|
748
|
+
) {
|
|
749
|
+
const isLocalBase =
|
|
750
|
+
this.baseUrl.includes('localhost') ||
|
|
751
|
+
(typeof window !== 'undefined' && this.baseUrl.startsWith(window.location.origin));
|
|
752
|
+
const publicBase = 'https://api.neus.network';
|
|
753
|
+
if (isLocalBase && this.baseUrl !== publicBase && endpoint.startsWith('/api/v1/')) {
|
|
754
|
+
this._log('Local API not found, retrying against public API', { endpoint });
|
|
755
|
+
response = await fetch(`${publicBase}${endpoint}`, options);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
clearTimeout(timeoutId);
|
|
759
|
+
|
|
760
|
+
let responseData;
|
|
761
|
+
try {
|
|
762
|
+
responseData = await response.json();
|
|
763
|
+
} catch {
|
|
764
|
+
responseData = { error: { message: 'Invalid JSON response' } };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
throw ApiError.fromResponse(response, responseData);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return responseData;
|
|
772
|
+
} catch (error) {
|
|
773
|
+
clearTimeout(timeoutId);
|
|
774
|
+
|
|
775
|
+
if (error.name === 'AbortError') {
|
|
776
|
+
throw new NetworkError(`Request timeout after ${this.config.timeout}ms`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (error instanceof ApiError) {
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
throw new NetworkError(`Network error: ${error.message}`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Format API response for consistent structure
|
|
789
|
+
* @private
|
|
790
|
+
*/
|
|
791
|
+
_formatResponse(response) {
|
|
792
|
+
const qHash =
|
|
793
|
+
response?.data?.qHash ||
|
|
794
|
+
response?.qHash ||
|
|
795
|
+
response?.data?.resource?.qHash ||
|
|
796
|
+
response?.data?.id;
|
|
797
|
+
|
|
798
|
+
const status =
|
|
799
|
+
response?.data?.status ||
|
|
800
|
+
response?.status ||
|
|
801
|
+
response?.data?.resource?.status ||
|
|
802
|
+
(response?.success ? 'completed' : 'unknown');
|
|
803
|
+
|
|
804
|
+
return {
|
|
805
|
+
success: response.success,
|
|
806
|
+
qHash,
|
|
807
|
+
status,
|
|
808
|
+
data: response.data,
|
|
809
|
+
message: response.message,
|
|
810
|
+
timestamp: Date.now(),
|
|
811
|
+
statusUrl: qHash ? `${this.baseUrl}/api/v1/verification/status/${qHash}` : null
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Check if status is terminal (completed or failed)
|
|
817
|
+
* @private
|
|
818
|
+
*/
|
|
819
|
+
_isTerminalStatus(status) {
|
|
820
|
+
const terminalStates = [
|
|
821
|
+
'verified',
|
|
822
|
+
'verified_crosschain_propagated',
|
|
823
|
+
'completed_all_successful',
|
|
824
|
+
'failed',
|
|
825
|
+
'error',
|
|
826
|
+
'rejected',
|
|
827
|
+
'cancelled'
|
|
828
|
+
];
|
|
829
|
+
return typeof status === 'string' && terminalStates.some(state => status.includes(state));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Internal logging
|
|
834
|
+
* @private
|
|
835
|
+
*/
|
|
836
|
+
_log(message, _data = {}) {
|
|
837
|
+
if (this.config.enableLogging) {
|
|
838
|
+
// Logging disabled in production builds
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Export the constructVerificationMessage function for advanced use
|
|
844
|
+
export { constructVerificationMessage };
|