@neus/sdk
Version:
NEUS SDK - Create and verify cryptographic proofs with a simple, clean API
723 lines (646 loc) • 21.8 kB
JavaScript
/**
* NEUS SDK Utilities
* Core utility functions for proof creation and verification
* @license Apache-2.0
*/
import { SDKError } from './errors.js';
/**
* Deterministic JSON stringification for consistent serialization
* @param {Object} obj - Object to stringify
* @returns {string} Deterministic JSON string
*/
function deterministicStringify(obj) {
if (obj === null || obj === undefined) {
return JSON.stringify(obj);
}
if (typeof obj !== 'object') {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
return '[' + obj.map(item => deterministicStringify(item)).join(',') + ']';
}
// Sort object keys for deterministic output
const sortedKeys = Object.keys(obj).sort();
const pairs = sortedKeys.map(key => JSON.stringify(key) + ':' + deterministicStringify(obj[key]));
return '{' + pairs.join(',') + '}';
}
/**
* Construct verification message for wallet signing
*
* @param {Object} params - Message parameters
* @param {string} params.walletAddress - Wallet address
* @param {number} params.signedTimestamp - Unix timestamp
* @param {Object} params.data - Verification data
* @param {Array<string>} params.verifierIds - Array of verifier IDs
* @param {number} params.chainId - Chain ID
* @returns {string} Message for signing
*/
export function constructVerificationMessage({
walletAddress,
signedTimestamp,
data,
verifierIds,
chainId
}) {
// Input validation for critical parameters
if (!walletAddress || typeof walletAddress !== 'string') {
throw new SDKError('walletAddress is required and must be a string', 'INVALID_WALLET_ADDRESS');
}
if (!signedTimestamp || typeof signedTimestamp !== 'number') {
throw new SDKError('signedTimestamp is required and must be a number', 'INVALID_TIMESTAMP');
}
if (!data || typeof data !== 'object') {
throw new SDKError('data is required and must be an object', 'INVALID_DATA');
}
if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
throw new SDKError(
'verifierIds is required and must be a non-empty array',
'INVALID_VERIFIER_IDS'
);
}
if (!chainId || typeof chainId !== 'number') {
throw new SDKError('chainId is required and must be a number', 'INVALID_CHAIN_ID');
}
// Normalize wallet address to lowercase for consistency (CRITICAL for signature recovery)
const normalizedWalletAddress = walletAddress.toLowerCase();
// CRITICAL: Use deterministic JSON serialization for consistency with backend
// This MUST match backend serialization exactly for signature verification to work
// NOTE: Any modification to this serialization will break signature verification
const dataString = deterministicStringify(data);
// Create canonical message format - EXACT format expected by backend
const messageComponents = [
'NEUS Verification Request',
`Wallet: ${normalizedWalletAddress}`,
`Chain: ${chainId}`,
`Verifiers: ${verifierIds.join(',')}`,
`Data: ${dataString}`,
`Timestamp: ${signedTimestamp}`
];
// Join with newlines - this is the message that gets signed
return messageComponents.join('\n');
}
/**
* Validate Ethereum wallet address format
*
* @param {string} address - Address to validate
* @returns {boolean} True if valid Ethereum address
*/
export function validateWalletAddress(address) {
if (!address || typeof address !== 'string') {
return false;
}
// Basic Ethereum address validation
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
/**
* Validate timestamp freshness
*
* @param {number} timestamp - Timestamp to validate
* @param {number} maxAgeMs - Maximum age in milliseconds (default: 5 minutes)
* @returns {boolean} True if timestamp is valid and recent
*/
export function validateTimestamp(timestamp, maxAgeMs = 5 * 60 * 1000) {
if (!timestamp || typeof timestamp !== 'number') {
return false;
}
const now = Date.now();
const age = now - timestamp;
// Check if timestamp is in the past and within allowed age
return age >= 0 && age <= maxAgeMs;
}
/**
* Create formatted verification data object
*
* @param {string} content - Content to verify
* @param {string} owner - Owner wallet address
* @param {Object} reference - Reference object
* @returns {Object} Formatted verification data
*/
export function createVerificationData(content, owner, reference = null) {
return {
content,
owner: owner.toLowerCase(),
reference: reference || {
type: 'content',
id: content.substring(0, 32)
}
};
}
/**
* DERIVE DID FROM ADDRESS AND CHAIN
* did:pkh:eip155:<chainId>:<address_lowercase>
*/
export function deriveDid(address, chainId = 84532) {
if (!address || typeof address !== 'string') {
throw new SDKError('deriveDid: address is required', 'INVALID_ARGUMENT');
}
if (!validateWalletAddress(address)) {
throw new SDKError('deriveDid: invalid wallet address format', 'INVALID_ARGUMENT');
}
if (!chainId || typeof chainId !== 'number') {
throw new SDKError('deriveDid: chainId (number) is required', 'INVALID_ARGUMENT');
}
return `did:pkh:eip155:${chainId}:${address.toLowerCase()}`;
}
/**
* Determine if a verification status is terminal (completed or failed)
* @param {string} status - The verification status
* @returns {boolean} Whether the status is terminal
*/
export function isTerminalStatus(status) {
if (!status || typeof status !== 'string') return false;
// Success states
const successStates = [
'verified',
'verified_no_verifiers',
'verified_crosschain_propagated',
'partially_verified',
'verified_propagation_failed'
];
// Failure states
const failureStates = [
'rejected',
'rejected_verifier_failure',
'rejected_zk_initiation_failure',
'error_processing_exception',
'error_initialization',
'error_storage_unavailable',
'error_storage_query',
'not_found'
];
return successStates.includes(status) || failureStates.includes(status);
}
/**
* Determine if a verification status indicates success
* @param {string} status - The verification status
* @returns {boolean} Whether the status indicates success
*/
export function isSuccessStatus(status) {
if (!status || typeof status !== 'string') return false;
const successStates = [
'verified',
'verified_no_verifiers',
'verified_crosschain_propagated',
'partially_verified',
'verified_propagation_failed'
];
return successStates.includes(status);
}
/**
* Determine if a verification status indicates failure
* @param {string} status - The verification status
* @returns {boolean} Whether the status indicates failure
*/
export function isFailureStatus(status) {
if (!status || typeof status !== 'string') return false;
const failureStates = [
'rejected',
'rejected_verifier_failure',
'rejected_zk_initiation_failure',
'error_processing_exception',
'error_initialization',
'error_storage_unavailable',
'error_storage_query',
'not_found'
];
return failureStates.includes(status);
}
/**
* Format verification status for display
* @param {string} status - Raw status from API
* @returns {Object} Formatted status information
*/
export function formatVerificationStatus(status) {
const statusMap = {
processing_verifiers: {
label: 'Processing',
description: 'Verifiers are being executed',
category: 'processing',
color: 'blue'
},
processing_zk_proofs: {
label: 'Generating ZK Proofs',
description: 'Zero-knowledge proofs are being generated',
category: 'processing',
color: 'blue'
},
verified: {
label: 'Verified',
description: 'Verification completed successfully',
category: 'success',
color: 'green'
},
verified_crosschain_initiated: {
label: 'Cross-chain Initiated',
description: 'Verification successful, cross-chain propagation started',
category: 'processing',
color: 'blue'
},
verified_crosschain_propagating: {
label: 'Cross-chain Propagating',
description: 'Verification successful, transactions propagating to spoke chains',
category: 'processing',
color: 'blue'
},
verified_crosschain_propagated: {
label: 'Fully Propagated',
description: 'Verification completed and propagated to all target chains',
category: 'success',
color: 'green'
},
verified_no_verifiers: {
label: 'Verified (No Verifiers)',
description: 'Verification completed without specific verifiers',
category: 'success',
color: 'green'
},
verified_propagation_failed: {
label: 'Propagation Failed',
description: 'Verification successful but cross-chain propagation failed',
category: 'warning',
color: 'orange'
},
partially_verified: {
label: 'Partially Verified',
description: 'Some verifiers succeeded, others failed',
category: 'warning',
color: 'orange'
},
rejected: {
label: 'Rejected',
description: 'Verification failed',
category: 'error',
color: 'red'
},
rejected_verifier_failure: {
label: 'Verifier Failed',
description: 'One or more verifiers failed',
category: 'error',
color: 'red'
},
rejected_zk_initiation_failure: {
label: 'ZK Initiation Failed',
description: 'Zero-knowledge proof generation failed to start',
category: 'error',
color: 'red'
},
error_processing_exception: {
label: 'Processing Error',
description: 'An error occurred during verification processing',
category: 'error',
color: 'red'
},
error_initialization: {
label: 'Initialization Error',
description: 'Failed to initialize verification',
category: 'error',
color: 'red'
},
not_found: {
label: 'Not Found',
description: 'Verification record not found',
category: 'error',
color: 'red'
}
};
return (
statusMap[status] || {
label: status?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Unknown',
description: 'Unknown status',
category: 'unknown',
color: 'gray'
}
);
}
/**
* Compute keccak256 content hash (0x-prefixed) for arbitrary input
* Uses ethers (peer dependency) via dynamic import to avoid hard bundling
*
* @param {string|Uint8Array} input - Raw string (UTF-8) or bytes
* @returns {Promise<string>} 0x-prefixed keccak256 hash
*/
export async function computeContentHash(input) {
try {
const ethers = await import('ethers');
const toBytes = typeof input === 'string' ? ethers.toUtf8Bytes(input) : input;
return ethers.keccak256(toBytes);
} catch {
throw new SDKError(
'computeContentHash requires peer dependency "ethers" >= 6.0.0',
'MISSING_PEER_DEP'
);
}
}
/**
* Create a delay/sleep function
* @param {number} ms - Milliseconds to wait
* @returns {Promise} Promise that resolves after the delay
*/
export function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Status Polling Utility for tracking verification progress
*/
export class StatusPoller {
constructor(client, qHash, options = {}) {
this.client = client;
this.qHash = qHash;
this.options = {
interval: 2000, // 2 seconds
maxAttempts: 150, // 5 minutes total
exponentialBackoff: true,
maxInterval: 10000, // 10 seconds max
...options
};
this.attempt = 0;
this.currentInterval = this.options.interval;
}
async poll() {
return new Promise((resolve, reject) => {
const pollAttempt = async () => {
try {
this.attempt++;
const response = await this.client.getStatus(this.qHash);
// Check if verification is complete using the terminal status utility
if (isTerminalStatus(response.status)) {
resolve(response);
return;
}
// Check if we've exceeded max attempts
if (this.attempt >= this.options.maxAttempts) {
reject(new SDKError('Verification polling timeout', 'POLLING_TIMEOUT'));
return;
}
// Schedule next poll with optional exponential backoff
if (this.options.exponentialBackoff) {
this.currentInterval = Math.min(this.currentInterval * 1.5, this.options.maxInterval);
}
setTimeout(pollAttempt, this.currentInterval);
} catch (error) {
reject(new SDKError(`Polling failed: ${error.message}`, 'POLLING_ERROR'));
}
};
// Start polling immediately
pollAttempt();
});
}
}
/**
* NEUS Network Constants
*/
export const NEUS_CONSTANTS = {
// Hub chain (where all verifications occur)
HUB_CHAIN_ID: 84532, // Base Sepolia
// Supported target chains for cross-chain propagation
TESTNET_CHAINS: [
11155111, // Ethereum Sepolia
11155420, // Optimism Sepolia
421614, // Arbitrum Sepolia
80002 // Polygon Amoy
],
// API endpoints
API_BASE_URL: 'https://api.neus.network',
API_VERSION: 'v1',
// Timeouts and limits
SIGNATURE_MAX_AGE_MS: 5 * 60 * 1000, // 5 minutes
REQUEST_TIMEOUT_MS: 30 * 1000, // 30 seconds
// Day-one verifiers (available at launch)
DEFAULT_VERIFIERS: ['ownership-basic', 'nft-ownership', 'token-holding', 'ownership-licensed']
};
/**
* Additional validation and utility helpers
*/
/**
* Validate qHash format (0x + 64 hex chars)
* @param {string} qHash - The qHash to validate
* @returns {boolean} True if valid qHash format
*/
export function validateQHash(qHash) {
return typeof qHash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(qHash);
}
/**
* Format timestamp to human readable string
* @param {number} timestamp - Unix timestamp
* @returns {string} Formatted date string
*/
export function formatTimestamp(timestamp) {
return new Date(timestamp).toLocaleString();
}
/**
* Check if a chain ID is supported for cross-chain propagation
* @param {number} chainId - Chain ID to check
* @returns {boolean} True if supported
*/
export function isSupportedChain(chainId) {
return NEUS_CONSTANTS.TESTNET_CHAINS.includes(chainId) || chainId === NEUS_CONSTANTS.HUB_CHAIN_ID;
}
/**
* Normalize wallet address to lowercase (EIP-55 agnostic)
* @param {string} address - Wallet address to normalize
* @returns {string} Lowercase address
*/
export function normalizeAddress(address) {
if (!validateWalletAddress(address)) {
throw new SDKError('Invalid wallet address format', 'INVALID_ADDRESS');
}
return address.toLowerCase();
}
/**
* Validate a verifier payload for basic structural integrity.
* Lightweight validation checks; verifier authors should document complete schemas.
* @param {string} verifierId - Verifier identifier (e.g., 'ownership-basic' or custom)
* @param {any} data - Verifier-specific payload
* @returns {{ valid: boolean, error?: string, missing?: string[], warnings?: string[] }}
*/
export function validateVerifierPayload(verifierId, data) {
const result = { valid: true, missing: [], warnings: [] };
if (!verifierId || typeof verifierId !== 'string') {
return { valid: false, error: 'verifierId is required and must be a string' };
}
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
return { valid: false, error: 'data must be a non-null object' };
}
// Best-effort field hints for day-one built-ins
const id = verifierId.replace(/@\d+$/, '');
if (id === 'nft-ownership') {
['ownerAddress', 'contractAddress', 'tokenId', 'chainId'].forEach(key => {
if (!(key in data)) result.missing.push(key);
});
} else if (id === 'token-holding') {
['ownerAddress', 'contractAddress', 'minBalance', 'chainId'].forEach(key => {
if (!(key in data)) result.missing.push(key);
});
} else if (id === 'ownership-basic') {
['content'].forEach(key => {
if (!(key in data)) result.missing.push(key);
});
} else if (id === 'ownership-licensed') {
['content', 'owner', 'license'].forEach(key => {
if (!(key in data)) result.missing.push(key);
});
if (data.license && typeof data.license === 'object') {
['contractAddress', 'tokenId', 'chainId', 'ownerAddress'].forEach(key => {
if (!(key in data.license)) result.missing.push(`license.${key}`);
});
}
}
if (result.missing.length > 0) {
result.valid = false;
result.error = `Missing required fields: ${result.missing.join(', ')}`;
}
return result;
}
/**
* Build a canonical verification request and signing message for manual flows.
* Returns the message to sign and the request body (sans signature).
* @param {Object} params
* @param {string[]} params.verifierIds
* @param {object} params.data
* @param {string} params.walletAddress
* @param {number} [params.chainId=NEUS_CONSTANTS.HUB_CHAIN_ID]
* @param {object} [params.options]
* @param {number} [params.signedTimestamp=Date.now()]
* @returns {{ message: string, request: { verifierIds: string[], data: object, walletAddress: string, signedTimestamp: number, chainId: number, options?: object } }}
*/
export function buildVerificationRequest({
verifierIds,
data,
walletAddress,
chainId = NEUS_CONSTANTS.HUB_CHAIN_ID,
options = undefined,
signedTimestamp = Date.now()
}) {
if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
throw new SDKError('verifierIds must be a non-empty array', 'INVALID_ARGUMENT');
}
if (!validateWalletAddress(walletAddress)) {
throw new SDKError('walletAddress must be a valid 0x address', 'INVALID_ARGUMENT');
}
if (!data || typeof data !== 'object') {
throw new SDKError('data must be a non-null object', 'INVALID_ARGUMENT');
}
if (typeof chainId !== 'number') {
throw new SDKError('chainId must be a number', 'INVALID_ARGUMENT');
}
const message = constructVerificationMessage({
walletAddress,
signedTimestamp,
data,
verifierIds,
chainId
});
const request = {
verifierIds,
data,
walletAddress,
signedTimestamp,
chainId,
...(options ? { options } : {})
};
return { message, request };
}
/**
* Create a retry utility with exponential backoff
* @param {Function} fn - Function to retry
* @param {Object} options - Retry options
* @returns {Promise} Promise that resolves with function result
*/
export async function withRetry(fn, options = {}) {
const { maxAttempts = 3, baseDelay = 1000, maxDelay = 10000, backoffFactor = 2 } = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) break;
const delayMs = Math.min(baseDelay * Math.pow(backoffFactor, attempt - 1), maxDelay);
await delay(delayMs);
}
}
throw lastError;
}
/**
* Validate signature components for debugging signature verification issues
* @param {Object} params - Signature components to validate
* @param {string} params.walletAddress - Wallet address
* @param {string} params.signature - EIP-191 signature
* @param {number} params.signedTimestamp - Unix timestamp
* @param {Object} params.data - Verification data
* @param {Array<string>} params.verifierIds - Array of verifier IDs
* @param {number} params.chainId - Chain ID
* @returns {Object} Validation result with detailed feedback
*/
export function validateSignatureComponents({
walletAddress,
signature,
signedTimestamp,
data,
verifierIds,
chainId
}) {
const result = {
valid: true,
errors: [],
warnings: [],
debugInfo: {}
};
// Validate wallet address
if (!validateWalletAddress(walletAddress)) {
result.valid = false;
result.errors.push('Invalid wallet address format - must be 0x + 40 hex characters');
} else {
result.debugInfo.normalizedAddress = walletAddress.toLowerCase();
if (walletAddress !== walletAddress.toLowerCase()) {
result.warnings.push('Wallet address should be lowercase for consistency');
}
}
// Validate signature format
if (!signature || typeof signature !== 'string') {
result.valid = false;
result.errors.push('Signature is required and must be a string');
} else if (!/^0x[a-fA-F0-9]{130}$/.test(signature)) {
result.valid = false;
result.errors.push('Invalid signature format - must be 0x + 130 hex characters (65 bytes)');
}
// Validate timestamp
if (!validateTimestamp(signedTimestamp)) {
result.valid = false;
result.errors.push('Invalid or expired timestamp - must be within 5 minutes');
} else {
result.debugInfo.timestampAge = Date.now() - signedTimestamp;
}
// Validate data object
if (!data || typeof data !== 'object' || Array.isArray(data)) {
result.valid = false;
result.errors.push('Data must be a non-null object');
} else {
result.debugInfo.dataString = deterministicStringify(data);
}
// Validate verifier IDs
if (!Array.isArray(verifierIds) || verifierIds.length === 0) {
result.valid = false;
result.errors.push('VerifierIds must be a non-empty array');
}
// Validate chain ID
if (typeof chainId !== 'number') {
result.valid = false;
result.errors.push('ChainId must be a number');
}
// Generate the message that would be signed
if (result.valid || result.errors.length < 3) {
try {
result.debugInfo.messageToSign = constructVerificationMessage({
walletAddress: walletAddress?.toLowerCase() || walletAddress,
signedTimestamp,
data,
verifierIds,
chainId
});
} catch (error) {
result.errors.push(`Failed to construct message: ${error.message}`);
}
}
return result;
}