@neus/sdk
Version:
NEUS SDK - Create and verify cryptographic proofs with a simple, clean API
845 lines (764 loc) • 27.4 kB
JavaScript
/**
* NEUS SDK Client
* Create and verify cryptographic proofs across applications
* @license Apache-2.0
*/
import { ApiError, ValidationError, NetworkError, ConfigurationError } from './errors.js';
import { constructVerificationMessage, validateWalletAddress, NEUS_CONSTANTS } from './utils.js';
// Validation for supported verifiers
const validateVerifierData = (verifierId, data) => {
if (!data || typeof data !== 'object') {
return { valid: false, error: 'Data object is required' };
}
// Validate wallet address if present
// Validate owner/ownerAddress fields based on verifier type
const ownerField =
verifierId === 'nft-ownership' || verifierId === 'token-holding' ? 'ownerAddress' : 'owner';
if (data[ownerField] && !validateWalletAddress(data[ownerField])) {
return { valid: false, error: `Invalid ${ownerField} address` };
}
// Format validation for supported verifiers
switch (verifierId) {
case 'ownership-basic':
if (!data.content) {
return { valid: false, error: 'content is required' };
}
break;
case 'nft-ownership':
if (
!data.ownerAddress ||
!data.contractAddress ||
data.tokenId == null ||
typeof data.chainId !== 'number'
) {
return {
valid: false,
error: 'ownerAddress, contractAddress, tokenId, and chainId are required'
};
}
if (!validateWalletAddress(data.contractAddress)) {
return { valid: false, error: 'Invalid contractAddress' };
}
break;
case 'token-holding':
if (
!data.ownerAddress ||
!data.contractAddress ||
data.minBalance == null ||
typeof data.chainId !== 'number'
) {
return {
valid: false,
error: 'ownerAddress, contractAddress, minBalance, and chainId are required'
};
}
if (!validateWalletAddress(data.contractAddress)) {
return { valid: false, error: 'Invalid contractAddress' };
}
break;
case 'ownership-licensed':
if (!data.content) {
return { valid: false, error: 'content is required for ownership-licensed' };
}
if (!data.owner && !data.license?.ownerAddress) {
return { valid: false, error: 'owner or license.ownerAddress is required' };
}
if (!data.license) {
return { valid: false, error: 'license object is required' };
}
if (!data.license.contractAddress || !validateWalletAddress(data.license.contractAddress)) {
return { valid: false, error: 'license.contractAddress must be a valid Ethereum address' };
}
if (!data.license.tokenId) {
return { valid: false, error: 'license.tokenId is required' };
}
if (typeof data.license.chainId !== 'number') {
return { valid: false, error: 'license.chainId must be a number' };
}
if (!data.license.ownerAddress || !validateWalletAddress(data.license.ownerAddress)) {
return { valid: false, error: 'license.ownerAddress must be a valid Ethereum address' };
}
break;
}
return { valid: true };
};
export class NeusClient {
constructor(config = {}) {
this.config = {
timeout: 30000,
enableLogging: false,
allowPublicFallback: false,
...config
};
// NEUS Network API
this.baseUrl = this.config.apiUrl || 'https://api.neus.network';
// Enforce HTTPS for neus.network domains to satisfy CSP and normalize URLs
try {
const url = new URL(this.baseUrl);
if (url.hostname.endsWith('neus.network') && url.protocol === 'http:') {
url.protocol = 'https:';
}
// Always remove trailing slash for consistency
this.baseUrl = url.toString().replace(/\/$/, '');
} catch (error) {
// If invalid URL string, leave as-is
this.logger?.debug('URL parsing failed, using as-is:', error.message);
}
// Normalize apiUrl on config
this.config.apiUrl = this.baseUrl;
// Default headers for API requests
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Neus-Sdk': 'js'
};
// Optional app-level identification
if (typeof this.config.appId === 'string' && this.config.appId.trim().length > 0) {
this.defaultHeaders['X-Neus-App'] = this.config.appId.trim();
}
try {
// Attach origin in browser environments
if (typeof window !== 'undefined' && window.location && window.location.origin) {
this.defaultHeaders['X-Client-Origin'] = window.location.origin;
}
} catch (error) {
// Ignore origin detection errors
}
this._log('NEUS Network Client initialized');
}
// ============================================================================
// CORE VERIFICATION METHODS
// ============================================================================
/**
* VERIFY - Canonical verification (auto or manual)
*
* Create proofs with complete control over the verification process.
* If signature and walletAddress are omitted but verifier/content are provided,
* this method performs the wallet flow inline (no aliases, no secondary methods).
*
* @param {Object} params - Verification parameters
* @param {Array<string>} [params.verifierIds] - Array of verifier IDs (manual path)
* @param {Object} [params.data] - Verification data object (manual path)
* @param {string} [params.walletAddress] - Wallet address that signed the request (manual path)
* @param {string} [params.signature] - EIP-191 signature (manual path)
* @param {number} [params.signedTimestamp] - Unix timestamp when signature was created (manual path)
* @param {number} [params.chainId] - Chain ID for verification context (optional, managed by protocol)
* @param {Object} [params.options] - Additional options
* @param {string} [params.verifier] - Verifier ID (auto path)
* @param {string} [params.content] - Content/description (auto path)
* @param {Object} [params.wallet] - Optional injected wallet/provider (auto path)
* @returns {Promise<Object>} Verification result with qHash
*
* @example
* const proof = await client.verify({
* verifierIds: ['ownership-basic'],
* data: {
* content: "My content",
* owner: walletAddress, // or ownerAddress for nft-ownership/token-holding
* reference: { type: 'content', id: 'my-unique-identifier' }
* },
* walletAddress: '0x...',
* signature: '0x...',
* signedTimestamp: Date.now(),
* options: { targetChains: [421614, 11155111] }
* });
*/
/**
* Create a verification proof
*
* @param {Object} params - Verification parameters
* @param {string} [params.verifier] - Verifier ID (e.g., 'ownership-basic')
* @param {string} [params.content] - Content to verify
* @param {Object} [params.data] - Structured verification data
* @param {Object} [params.wallet] - Wallet provider
* @param {Object} [params.options] - Additional options
* @returns {Promise<Object>} Verification result with qHash
*
* @example
* // Simple ownership proof
* const proof = await client.verify({
* verifier: 'ownership-basic',
* content: 'Hello World',
* wallet: window.ethereum
* });
*/
async verify(params) {
// Auto path: if no manual signature fields but auto fields are provided, perform inline wallet flow
if (
(!params?.signature || !params?.walletAddress) &&
(params?.verifier || params?.content || params?.data)
) {
const {
content,
verifier = 'ownership-basic',
data = null,
wallet = null,
options = {}
} = params;
if (verifier === 'ownership-basic' && (!content || typeof content !== 'string')) {
throw new ValidationError('content is required and must be a string');
}
const validVerifiers = [
'ownership-basic',
'nft-ownership',
'token-holding',
'ownership-licensed'
];
if (!validVerifiers.includes(verifier)) {
throw new ValidationError(
`Invalid verifier '${verifier}'. Must be one of: ${validVerifiers.join(', ')}`
);
}
// Auto-detect wallet and get address
let walletAddress, provider;
if (wallet) {
walletAddress = wallet.address || wallet.selectedAddress;
provider = wallet.provider || wallet;
} else {
if (typeof window === 'undefined' || !window.ethereum) {
throw new ConfigurationError(
'No Web3 wallet detected. Please install MetaMask or provide wallet parameter.'
);
}
await window.ethereum.request({ method: 'eth_requestAccounts' });
provider = window.ethereum;
const accounts = await provider.request({ method: 'eth_accounts' });
walletAddress = accounts[0];
}
// Prepare verification data based on verifier type
let verificationData;
if (verifier === 'ownership-basic') {
verificationData = {
content: content,
owner: walletAddress,
reference: { type: 'content', id: content.substring(0, 32) }
};
} else if (verifier === 'ownership-licensed') {
if (!data?.license && (!data?.contractAddress || !data?.tokenId)) {
throw new ValidationError(
'ownership-licensed requires either license object or contractAddress + tokenId'
);
}
verificationData = {
content: content || 'Licensed content',
owner: walletAddress,
license: data?.license || {
contractAddress: data?.contractAddress,
tokenId: data?.tokenId,
chainId: data?.chainId,
ownerAddress: walletAddress,
type: data?.licenseType || 'erc721'
}
};
} else if (verifier === 'token-holding') {
verificationData = {
ownerAddress: walletAddress,
contractAddress: data?.contractAddress,
minBalance: data?.minBalance,
chainId: data?.chainId
};
} else if (verifier === 'nft-ownership') {
verificationData = {
ownerAddress: walletAddress,
contractAddress: data?.contractAddress,
tokenId: data?.tokenId,
chainId: data?.chainId,
tokenType: data?.tokenType || 'erc721'
};
} else {
// Default structure for unknown verifiers
verificationData = data
? {
content,
owner: walletAddress,
...data
}
: {
content,
owner: walletAddress
};
}
const signedTimestamp = Date.now();
const verifierIds = [verifier];
const message = constructVerificationMessage({
walletAddress,
signedTimestamp,
data: verificationData,
verifierIds,
chainId: NEUS_CONSTANTS.HUB_CHAIN_ID // Protocol-managed chain
});
let signature;
try {
signature = await provider.request({
method: 'personal_sign',
params: [message, walletAddress]
});
} catch (error) {
if (error.code === 4001) {
throw new ValidationError(
'User rejected the signature request. Signature is required to create proofs.'
);
}
throw new ValidationError(`Failed to sign verification message: ${error.message}`);
}
return this.verify({
verifierIds,
data: verificationData,
walletAddress,
signature,
signedTimestamp,
options
});
}
const {
verifierIds,
data,
walletAddress,
signature,
signedTimestamp,
chainId = NEUS_CONSTANTS.HUB_CHAIN_ID,
options = {}
} = params;
// Normalize verifier IDs
const normalizeVerifierId = id => {
if (typeof id !== 'string') return id;
const match = id.match(/^(.*)@\d+$/);
return match ? match[1] : id;
};
const normalizedVerifierIds = Array.isArray(verifierIds)
? verifierIds.map(normalizeVerifierId)
: [];
// Validate required parameters
if (!normalizedVerifierIds || normalizedVerifierIds.length === 0) {
throw new ValidationError('verifierIds array is required');
}
if (!data || typeof data !== 'object') {
throw new ValidationError('data object is required');
}
if (!walletAddress || !/^0x[a-fA-F0-9]{40}$/i.test(walletAddress)) {
throw new ValidationError('Valid walletAddress is required');
}
if (!signature) {
throw new ValidationError('signature is required');
}
if (!signedTimestamp || typeof signedTimestamp !== 'number') {
throw new ValidationError('signedTimestamp is required');
}
if (typeof chainId !== 'number') {
throw new ValidationError('chainId must be a number');
}
// Validate verifier data
for (const verifierId of normalizedVerifierIds) {
const validation = validateVerifierData(verifierId, data);
if (!validation.valid) {
throw new ValidationError(
`Validation failed for verifier '${verifierId}': ${validation.error}`
);
}
}
const requestData = {
verifierIds: normalizedVerifierIds,
data,
walletAddress,
signature,
signedTimestamp,
chainId,
options: {
...options,
targetChains: options?.targetChains || [],
// Privacy and storage options
privacyLevel: options?.privacyLevel || 'private',
publicDisplay: options?.publicDisplay || false,
storeOriginalContent: options?.storeOriginalContent || false,
enableIpfs: options?.enableIpfs || false,
forceZK: options?.forceZK || false
}
};
const response = await this._makeRequest('POST', '/api/v1/verification', requestData, {
Authorization: `Bearer ${signature}`
});
if (!response.success) {
throw new ApiError(
`Verification failed: ${response.error?.message || 'Unknown error'}`,
response.error
);
}
return this._formatResponse(response);
}
// ============================================================================
// STATUS AND UTILITY METHODS
// ============================================================================
/**
* Get verification status
*
* @param {string} qHash - Verification ID (qHash or proofId)
* @param {Object} auth - Optional authentication for private proofs
* @returns {Promise<Object>} Verification status and data
*
* @example
* const result = await client.getStatus('0x...');
* console.log('Status:', result.status);
*/
async getStatus(qHash, auth = undefined) {
if (!qHash || typeof qHash !== 'string') {
throw new ValidationError('qHash is required');
}
const headers = {};
if (auth?.signature && auth?.walletAddress) {
headers.Authorization = `Bearer ${auth.signature}`;
}
const response = await this._makeRequest(
'GET',
`/api/v1/verification/status/${qHash}`,
null,
headers
);
if (!response.success) {
throw new ApiError(
`Failed to get status: ${response.error?.message || 'Unknown error'}`,
response.error
);
}
return this._formatResponse(response);
}
/**
* Get private proof status with wallet signature
*
* @param {string} qHash - Verification ID
* @param {Object} wallet - Wallet provider (window.ethereum or ethers Wallet)
* @returns {Promise<Object>} Private verification status and data
*
* @example
* // Access private proof
* const privateData = await client.getPrivateStatus(qHash, window.ethereum);
*/
async getPrivateStatus(qHash, wallet = null) {
if (!qHash || typeof qHash !== 'string') {
throw new ValidationError('qHash is required');
}
// Auto-detect wallet if not provided
if (!wallet) {
if (typeof window === 'undefined' || !window.ethereum) {
throw new ConfigurationError('No wallet provider available');
}
wallet = window.ethereum;
}
let walletAddress, provider;
// Handle different wallet types
if (wallet.address) {
// ethers Wallet
walletAddress = wallet.address;
provider = wallet;
} else if (wallet.selectedAddress || wallet.request) {
// Browser provider (MetaMask, etc.)
provider = wallet;
if (wallet.selectedAddress) {
walletAddress = wallet.selectedAddress;
} else {
const accounts = await provider.request({ method: 'eth_accounts' });
if (!accounts || accounts.length === 0) {
throw new ConfigurationError('No wallet accounts available');
}
walletAddress = accounts[0];
}
} else {
throw new ConfigurationError('Invalid wallet provider');
}
const signedTimestamp = Date.now();
// Use existing working message format
const message = `Access private proof: ${qHash}`;
let signature;
try {
if (provider.signMessage) {
// ethers Wallet
signature = await provider.signMessage(message);
} else {
// Browser provider
signature = await provider.request({
method: 'personal_sign',
params: [message, walletAddress]
});
}
} catch (error) {
if (error.code === 4001) {
throw new ValidationError('User rejected signature request');
}
throw new ValidationError(`Failed to sign message: ${error.message}`);
}
// Make request with signature headers
const response = await this._makeRequest('GET', `/api/v1/verification/status/${qHash}`, null, {
'x-wallet-address': walletAddress,
'x-signature': signature,
'x-signed-timestamp': signedTimestamp.toString()
});
if (!response.success) {
throw new ApiError(
`Failed to access private proof: ${response.error?.message || 'Unauthorized'}`,
response.error
);
}
return this._formatResponse(response);
}
/**
* Check API health
*
* @returns {Promise<boolean>} True if API is healthy
*/
async isHealthy() {
try {
const response = await this._makeRequest('GET', '/api/v1/health');
return response.success === true;
} catch {
return false;
}
}
/**
* List available verifiers
*
* @returns {Promise<string[]>} Array of verifier IDs
*/
async getVerifiers() {
const response = await this._makeRequest('GET', '/api/v1/verification/verifiers');
if (!response.success) {
throw new ApiError(
`Failed to get verifiers: ${response.error?.message || 'Unknown error'}`,
response.error
);
}
return Array.isArray(response.data) ? response.data : [];
}
/**
* POLL PROOF STATUS - Wait for verification completion
*
* Polls the verification status until it reaches a terminal state (completed or failed).
* Useful for providing real-time feedback to users during verification.
*
* @param {string} qHash - Verification ID to poll
* @param {Object} [options] - Polling options
* @param {number} [options.interval=5000] - Polling interval in ms
* @param {number} [options.timeout=120000] - Total timeout in ms
* @param {Function} [options.onProgress] - Progress callback function
* @returns {Promise<Object>} Final verification status
*
* @example
* const finalStatus = await client.pollProofStatus(qHash, {
* interval: 3000,
* timeout: 60000,
* onProgress: (status) => {
* console.log('Current status:', status.status);
* if (status.crosschain) {
* console.log(`Cross-chain: ${status.crosschain.finalized}/${status.crosschain.totalChains}`);
* }
* }
* });
*/
async pollProofStatus(qHash, options = {}) {
const { interval = 5000, timeout = 120000, onProgress } = options;
if (!qHash || typeof qHash !== 'string') {
throw new ValidationError('qHash is required');
}
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const status = await this.getStatus(qHash);
// Call progress callback if provided
if (onProgress && typeof onProgress === 'function') {
onProgress(status.data || status);
}
// Check for terminal states
const currentStatus = status.data?.status || status.status;
if (this._isTerminalStatus(currentStatus)) {
this._log('Verification completed', {
status: currentStatus,
duration: Date.now() - startTime
});
return status;
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
this._log('Status poll error', error.message);
// Continue polling unless it's a validation error
if (error instanceof ValidationError) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
}
throw new NetworkError(`Polling timeout after ${timeout}ms`, 'POLLING_TIMEOUT');
}
/**
* DETECT CHAIN ID - Get current wallet chain
*
* @returns {Promise<number>} Current chain ID
*/
async detectChainId() {
if (typeof window === 'undefined' || !window.ethereum) {
throw new ConfigurationError('No Web3 wallet detected');
}
try {
const chainId = await window.ethereum.request({ method: 'eth_chainId' });
return parseInt(chainId, 16);
} catch (error) {
throw new NetworkError(`Failed to detect chain ID: ${error.message}`);
}
}
// ============================================================================
// SPECIALIZED VERIFIER METHODS
// ============================================================================
/** Revoke your own proof (owner-signed) */
async revokeOwnProof(qHash, wallet) {
if (!qHash || typeof qHash !== 'string') {
throw new ValidationError('qHash is required');
}
const address = wallet?.address || (await this._getWalletAddress());
const signedTimestamp = Date.now();
const hubChainId = NEUS_CONSTANTS.HUB_CHAIN_ID;
const message = constructVerificationMessage({
walletAddress: address,
signedTimestamp,
data: { action: 'revoke_proof', qHash },
verifierIds: ['ownership-basic'],
chainId: hubChainId
});
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, address]
});
const res = await fetch(`${this.config.apiUrl}/api/v1/proofs/${qHash}/revoke-self`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${signature}` },
body: JSON.stringify({ walletAddress: address, signature, signedTimestamp })
});
const json = await res.json();
if (!json.success) {
throw new ApiError(json.error?.message || 'Failed to revoke proof', json.error);
}
return true;
}
// ============================================================================
// PRIVATE UTILITY METHODS
// ============================================================================
/**
* Get connected wallet address
* @private
*/
async _getWalletAddress() {
if (typeof window === 'undefined' || !window.ethereum) {
throw new ConfigurationError('No Web3 wallet detected');
}
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
if (!accounts || accounts.length === 0) {
throw new ConfigurationError('No wallet accounts available');
}
return accounts[0];
}
/**
* Make HTTP request to API
* @private
*/
async _makeRequest(method, endpoint, data = null, headersOverride = null) {
const url = `${this.baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const options = {
method,
headers: { ...this.defaultHeaders, ...(headersOverride || {}) },
signal: controller.signal
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.body = JSON.stringify(data);
}
this._log(`${method} ${endpoint}`, data ? { requestBodyKeys: Object.keys(data) } : {});
try {
let response = await fetch(url, options);
// Fallback: if local baseUrl is misconfigured and returns 404/405, retry against public API
if (
this.config.allowPublicFallback &&
!response.ok &&
(response.status === 404 || response.status === 405)
) {
const isLocalBase =
this.baseUrl.includes('localhost') ||
(typeof window !== 'undefined' && this.baseUrl.startsWith(window.location.origin));
const publicBase = 'https://api.neus.network';
if (isLocalBase && this.baseUrl !== publicBase && endpoint.startsWith('/api/v1/')) {
this._log('Local API not found, retrying against public API', { endpoint });
response = await fetch(`${publicBase}${endpoint}`, options);
}
}
clearTimeout(timeoutId);
let responseData;
try {
responseData = await response.json();
} catch {
responseData = { error: { message: 'Invalid JSON response' } };
}
if (!response.ok) {
throw ApiError.fromResponse(response, responseData);
}
return responseData;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new NetworkError(`Request timeout after ${this.config.timeout}ms`);
}
if (error instanceof ApiError) {
throw error;
}
throw new NetworkError(`Network error: ${error.message}`);
}
}
/**
* Format API response for consistent structure
* @private
*/
_formatResponse(response) {
const qHash =
response?.data?.qHash ||
response?.qHash ||
response?.data?.resource?.qHash ||
response?.data?.id;
const status =
response?.data?.status ||
response?.status ||
response?.data?.resource?.status ||
(response?.success ? 'completed' : 'unknown');
return {
success: response.success,
qHash,
status,
data: response.data,
message: response.message,
timestamp: Date.now(),
statusUrl: qHash ? `${this.baseUrl}/api/v1/verification/status/${qHash}` : null
};
}
/**
* Check if status is terminal (completed or failed)
* @private
*/
_isTerminalStatus(status) {
const terminalStates = [
'verified',
'verified_crosschain_propagated',
'completed_all_successful',
'failed',
'error',
'rejected',
'cancelled'
];
return typeof status === 'string' && terminalStates.some(state => status.includes(state));
}
/**
* Internal logging
* @private
*/
_log(message, _data = {}) {
if (this.config.enableLogging) {
// Logging disabled in production builds
}
}
}
// Export the constructVerificationMessage function for advanced use
export { constructVerificationMessage };