UNPKG

minimal-xec-wallet

Version:

A minimalist eCash (XEC) wallet npm library, for use in web apps. Supports eTokens.

731 lines (627 loc) 23.5 kB
/* UTXO Health Monitoring System for minimal-xec-wallet Provides real-time health assessment and monitoring for UTXOs including: - Health status tracking (healthy, at-risk, unhealthy, stuck) - Dust attack detection and prevention - Economic viability analysis - Consolidation recommendations - Performance impact assessment */ 'use strict' class UtxoHealthMonitor { constructor (config = {}) { // Health assessment thresholds this.healthConfig = { dustLimit: config.dustLimit || 546, economicalThreshold: config.economicalThreshold || 2.0, // 2x input cost stuckThreshold: config.stuckThreshold || 144, // blocks before considering stuck suspiciousPatterns: config.suspiciousPatterns || { dustAttackSize: 10, // Number of micro-UTXOs to trigger alert rapidDeposits: 5, // Rapid deposits within timeframe timeWindow: 3600000 // 1 hour in milliseconds } } // Monitoring state this.healthHistory = new Map() // UTXO ID -> health history this.alerts = [] this.lastScan = null this.dustAttackPatterns = new Map() // Address -> pattern data // Performance metrics this.metrics = { totalScanned: 0, healthyCount: 0, unhealthyCount: 0, dustCount: 0, stuckCount: 0, lastUpdateTime: null } // Debug mode this.debug = config.debug || false } /** * Comprehensive health assessment of a single UTXO * @param {Object} utxo - UTXO to assess * @param {Object} classification - Optional pre-computed classification * @param {number} currentFeeRate - Current network fee rate (sats/byte) * @returns {Object} Detailed health assessment */ assessUtxoHealth (utxo, classification = null, currentFeeRate = 1.0) { try { // Validate UTXO structure if (!utxo || typeof utxo !== 'object') { throw new Error('Invalid UTXO: must be an object') } // Handle different UTXO formats (outpoint vs direct properties) const txid = utxo.outpoint?.txid || utxo.tx_hash || utxo.txid const outIdx = utxo.outpoint?.outIdx !== undefined ? utxo.outpoint.outIdx : utxo.tx_pos !== undefined ? utxo.tx_pos : utxo.outIdx if (!txid || outIdx === undefined || outIdx === null) { throw new Error('Invalid UTXO: missing txid or output index') } const utxoId = `${txid}:${outIdx}` const satsValue = this._extractSatsFromUtxo(utxo) // Basic health indicators const isDust = satsValue < this.healthConfig.dustLimit const isEconomical = this._isEconomicalToSpend(satsValue, currentFeeRate) const isConfirmed = utxo.blockHeight !== -1 const isStuck = this._isStuckUtxo(utxo) const isSuspicious = this._isSuspiciousUtxo(utxo, satsValue) // Determine primary health status let status = 'healthy' let severity = 'none' const recommendations = [] if (isDust) { status = 'dust' severity = 'critical' recommendations.push('Consider consolidating with other UTXOs') } else if (isSuspicious) { status = 'suspicious' severity = 'high' recommendations.push('Quarantine - possible dust attack') recommendations.push('Do not spend without careful analysis') } else if (!isConfirmed) { status = 'unconfirmed' severity = 'low' recommendations.push('Wait for confirmation') } else if (isStuck) { status = 'stuck' severity = 'medium' recommendations.push('Consider using Child-Pays-For-Parent (CPFP)') } else if (!isEconomical) { status = 'uneconomical' severity = 'medium' recommendations.push('Wait for lower fee rates or consolidate') } // Calculate detailed health metrics const healthScore = this._calculateDetailedHealthScore(utxo, satsValue, currentFeeRate) const spendingCost = this._calculateSpendingCost(utxo, currentFeeRate) const efficiency = satsValue > 0 ? (satsValue - spendingCost) / satsValue : 0 const healthAssessment = { utxoId, status, severity, healthScore, // Economic analysis economic: { isEconomical, spendingCost, efficiency: Math.max(0, efficiency), profitMargin: satsValue - spendingCost, breakEvenFeeRate: this._calculateBreakEvenFeeRate(satsValue), feeEfficiency: this._calculateFeeEfficiency(satsValue, currentFeeRate) }, // Risk analysis risks: { isDust, isStuck, isSuspicious, isUnconfirmed: !isConfirmed, hasToken: utxo.token !== undefined, riskFactors: this._identifyRiskFactors(utxo, satsValue) }, // Recommendations recommendations, // Technical details technical: { satsValue, blockHeight: utxo.blockHeight, estimatedInputSize: this._estimateInputSize(utxo), currentFeeRate, hasToken: utxo.token !== undefined }, // Timestamps assessedAt: Date.now(), lastUpdated: Date.now() } // Update health history this._updateHealthHistory(utxoId, healthAssessment) if (this.debug) { console.log(`Health assessed for ${utxoId}: ${status} (${healthScore}/100)`) } return healthAssessment } catch (err) { console.warn(`Health assessment failed for ${utxo.outpoint?.txid}:${utxo.outpoint?.outIdx}:`, err.message) throw new Error(`Health assessment failed: ${err.message}`) } } /** * Monitor health status of multiple UTXOs * @param {Array} utxos - Array of UTXOs to monitor * @param {Map} classifications - Optional UTXO classifications * @param {number} currentFeeRate - Current network fee rate * @returns {Object} Comprehensive health report */ monitorUtxoSet (utxos, classifications = null, currentFeeRate = 1.0) { try { const healthAssessments = new Map() const alerts = [] const summary = { total: utxos.length, healthy: 0, atRisk: 0, unhealthy: 0, dust: 0, stuck: 0, suspicious: 0, unconfirmed: 0, totalValue: 0, spendableValue: 0, uneconomicalValue: 0, tokenUtxos: 0 } // Assess each UTXO for (const utxo of utxos) { try { // Handle different UTXO formats for classification lookup const txid = utxo.outpoint?.txid || utxo.tx_hash || utxo.txid const outIdx = utxo.outpoint?.outIdx !== undefined ? utxo.outpoint.outIdx : utxo.tx_pos !== undefined ? utxo.tx_pos : utxo.outIdx const classification = classifications?.get(`${txid}:${outIdx}`) const assessment = this.assessUtxoHealth(utxo, classification, currentFeeRate) healthAssessments.set(assessment.utxoId, assessment) // Update summary statistics summary.totalValue += assessment.technical.satsValue // Count token UTXOs if (assessment.technical.hasToken) summary.tokenUtxos++ switch (assessment.status) { case 'healthy': summary.healthy++ summary.spendableValue += assessment.technical.satsValue break case 'dust': summary.dust++ summary.uneconomicalValue += assessment.technical.satsValue break case 'stuck': summary.stuck++ break case 'suspicious': summary.suspicious++ alerts.push(this._createSuspiciousUtxoAlert(assessment)) break case 'uneconomical': summary.atRisk++ summary.uneconomicalValue += assessment.technical.satsValue break case 'unconfirmed': summary.unconfirmed++ summary.spendableValue += assessment.technical.satsValue // Usually spendable break default: summary.atRisk++ } // Generate alerts for critical issues if (assessment.severity === 'critical' || assessment.severity === 'high') { alerts.push(this._createHealthAlert(assessment)) } } catch (err) { if (this.debug) { console.warn(`Failed to assess UTXO ${utxo.outpoint?.txid}:${utxo.outpoint?.outIdx}:`, err.message) } } } // Analyze patterns and generate system-wide alerts const patternAlerts = this._analyzeHealthPatterns(healthAssessments) alerts.push(...patternAlerts) // Calculate health metrics summary.healthPercentage = summary.total > 0 ? (summary.healthy / summary.total) * 100 : 0 summary.spendablePercentage = summary.totalValue > 0 ? (summary.spendableValue / summary.totalValue) * 100 : 0 // Update monitoring metrics this._updateMonitoringMetrics(summary) const report = { summary, assessments: healthAssessments, alerts, recommendations: this._generateSystemRecommendations(summary), lastScan: Date.now(), feeRate: currentFeeRate } this.lastScan = Date.now() this.alerts.push(...alerts) if (this.debug) { console.log(`Health monitoring complete: ${summary.healthy}/${summary.total} healthy UTXOs`) } return report } catch (err) { console.warn('UTXO set monitoring failed:', err.message) throw new Error(`UTXO set monitoring failed: ${err.message}`) } } /** * Detect potential dust attacks * @param {Array} utxos - Recent UTXOs to analyze * @param {string} address - Wallet address being analyzed * @returns {Object} Dust attack analysis */ detectDustAttack (utxos, address) { try { // Validate inputs if (!Array.isArray(utxos)) { throw new Error('UTXOs must be an array') } // Validate UTXO structures for (const utxo of utxos) { if (!utxo || typeof utxo !== 'object') { throw new Error('Invalid UTXO: must be an object') } if (!utxo.outpoint && !utxo.tx_hash && !utxo.txid) { throw new Error('Invalid UTXO: missing transaction identifier') } } const suspiciousPattern = { address, detectedAt: Date.now(), severity: 'none', indicators: [], utxos: [], recommendations: [] } // Analyze UTXO patterns const microUtxos = utxos.filter(utxo => { const value = this._extractSatsFromUtxo(utxo) return value > this.healthConfig.dustLimit && value < this.healthConfig.dustLimit * 5 }) const recentMicroUtxos = microUtxos.filter(utxo => { // Check if received recently (within time window) return utxo.blockHeight === -1 || this._isRecentUtxo(utxo) }) // Check for dust attack indicators if (recentMicroUtxos.length >= this.healthConfig.suspiciousPatterns.dustAttackSize) { suspiciousPattern.severity = 'high' suspiciousPattern.indicators.push('Multiple micro-UTXOs received rapidly') suspiciousPattern.utxos = recentMicroUtxos suspiciousPattern.recommendations.push('Do not spend micro-UTXOs') suspiciousPattern.recommendations.push('Consider using privacy features') } // Check for round number patterns (common in attacks) const roundNumberUtxos = recentMicroUtxos.filter(utxo => { const value = this._extractSatsFromUtxo(utxo) return this._isRoundNumber(value) }) if (roundNumberUtxos.length >= 3) { suspiciousPattern.severity = suspiciousPattern.severity === 'high' ? 'critical' : 'high' suspiciousPattern.indicators.push('Round number amounts detected') } // Check for identical amounts (strong indicator) const amountGroups = new Map() recentMicroUtxos.forEach(utxo => { const value = this._extractSatsFromUtxo(utxo) const count = amountGroups.get(value) || 0 amountGroups.set(value, count + 1) }) for (const [amount, count] of amountGroups) { if (count >= 3) { suspiciousPattern.severity = 'critical' suspiciousPattern.indicators.push(`${count} identical amounts of ${amount} sats`) } } // Check for rapid sequence deposits if (recentMicroUtxos.length >= this.healthConfig.suspiciousPatterns.rapidDeposits) { suspiciousPattern.indicators.push('Rapid sequence of small deposits') if (suspiciousPattern.severity === 'none') suspiciousPattern.severity = 'medium' } // Store pattern for tracking if (suspiciousPattern.severity !== 'none') { this.dustAttackPatterns.set(address, suspiciousPattern) if (this.debug) { console.log(`Dust attack detected for ${address}: ${suspiciousPattern.severity} severity`) } } return suspiciousPattern } catch (err) { console.warn('Dust attack detection failed:', err.message) throw new Error(`Dust attack detection failed: ${err.message}`) } } // Private Methods _extractSatsFromUtxo (utxo) { const sats = utxo.sats || utxo.value || 0 if (typeof sats === 'bigint') { return Number(sats) } else if (typeof sats === 'string') { return parseInt(sats) || 0 } else { return parseInt(sats) || 0 } } _isEconomicalToSpend (satsValue, feeRate) { const inputCost = 148 * feeRate // P2PKH input size return satsValue > inputCost * this.healthConfig.economicalThreshold } _isStuckUtxo (utxo) { // Simple stuck detection - unconfirmed for too long // In real implementation, would check mempool time return utxo.blockHeight === -1 // Placeholder - needs mempool time tracking } _isSuspiciousUtxo (utxo, satsValue) { // Detect suspicious patterns return ( satsValue > this.healthConfig.dustLimit && satsValue < this.healthConfig.dustLimit * 2 && utxo.blockHeight === -1 ) } _calculateDetailedHealthScore (utxo, satsValue, feeRate) { let score = 100 // Economic viability if (!this._isEconomicalToSpend(satsValue, feeRate)) score -= 40 if (satsValue < this.healthConfig.dustLimit) score = 0 // Confirmation status if (utxo.blockHeight === -1) score -= 20 // Suspicious patterns if (this._isSuspiciousUtxo(utxo, satsValue)) score -= 30 // Fee efficiency penalty (progressive) const efficiency = this._calculateFeeEfficiency(satsValue, feeRate) if (efficiency < 0.5) { score -= 20 } else if (efficiency < 0.8) { score -= 10 } else if (efficiency < 0.95) { score -= 5 } else if (efficiency < 0.99) { score -= 2 } // Token bonus (tokens have additional value) if (utxo.token) score += 10 return Math.max(0, Math.min(100, score)) } _calculateSpendingCost (utxo, feeRate) { const inputSize = this._estimateInputSize(utxo) return inputSize * feeRate } _calculateBreakEvenFeeRate (satsValue) { const inputSize = 148 // Standard P2PKH return satsValue / (inputSize * this.healthConfig.economicalThreshold) } _calculateFeeEfficiency (satsValue, feeRate) { const spendingCost = this._calculateSpendingCost({ script: '' }, feeRate) return Math.max(0, (satsValue - spendingCost) / satsValue) } _estimateInputSize (utxo) { // Simplified input size estimation if (utxo.token) return 160 // Token inputs are slightly larger return 148 // P2PKH standard size } _identifyRiskFactors (utxo, satsValue) { const factors = [] if (satsValue < this.healthConfig.dustLimit * 2) factors.push('very_small_value') if (utxo.blockHeight === -1) factors.push('unconfirmed') if (utxo.isCoinbase) factors.push('coinbase_maturity') if (this._isRoundNumber(satsValue)) factors.push('round_number_amount') if (utxo.token) factors.push('token_utxo') return factors } _isRoundNumber (satsValue) { const xecValue = satsValue / 100 return xecValue % 1 === 0 && (xecValue % 10 === 0 || xecValue % 100 === 0) } _isRecentUtxo (utxo) { // Placeholder - would check actual time difference in real implementation return true } _updateHealthHistory (utxoId, assessment) { if (!this.healthHistory.has(utxoId)) { this.healthHistory.set(utxoId, []) } const history = this.healthHistory.get(utxoId) history.push({ timestamp: assessment.assessedAt, status: assessment.status, healthScore: assessment.healthScore, feeRate: assessment.technical.currentFeeRate }) // Keep only last 10 assessments if (history.length > 10) { history.shift() } } _createSuspiciousUtxoAlert (assessment) { return { type: 'suspicious_utxo', severity: assessment.severity, utxoId: assessment.utxoId, message: `Suspicious UTXO detected: ${assessment.risks.riskFactors.join(', ')}`, recommendations: assessment.recommendations, timestamp: Date.now() } } _createHealthAlert (assessment) { return { type: 'health_issue', severity: assessment.severity, utxoId: assessment.utxoId, message: `UTXO health issue: ${assessment.status}`, recommendations: assessment.recommendations, timestamp: Date.now() } } _analyzeHealthPatterns (assessments) { const alerts = [] const statusCounts = new Map() // Count status occurrences for (const assessment of assessments.values()) { const count = statusCounts.get(assessment.status) || 0 statusCounts.set(assessment.status, count + 1) } // Generate pattern-based alerts const dustCount = statusCounts.get('dust') || 0 const suspiciousCount = statusCounts.get('suspicious') || 0 const uneconomicalCount = statusCounts.get('uneconomical') || 0 if (dustCount > 10) { alerts.push({ type: 'wallet_fragmentation', severity: 'medium', message: `High number of dust UTXOs detected (${dustCount})`, recommendations: ['Consider UTXO consolidation', 'Review dust management strategy'], timestamp: Date.now() }) } if (suspiciousCount > 3) { alerts.push({ type: 'potential_attack', severity: 'high', message: `Multiple suspicious UTXOs detected (${suspiciousCount})`, recommendations: ['Enable privacy mode', 'Avoid spending suspicious UTXOs'], timestamp: Date.now() }) } if (uneconomicalCount > assessments.size * 0.3) { alerts.push({ type: 'economic_inefficiency', severity: 'medium', message: `High percentage of uneconomical UTXOs (${uneconomicalCount})`, recommendations: ['Wait for lower fees', 'Consider consolidation'], timestamp: Date.now() }) } return alerts } _generateSystemRecommendations (summary) { const recommendations = [] if (summary.dust > 0) { recommendations.push({ type: 'consolidation', priority: 'medium', message: `Consider consolidating ${summary.dust} dust UTXOs`, action: 'consolidate_dust', estimatedSavings: 'Reduce transaction complexity' }) } if (summary.spendablePercentage < 80) { recommendations.push({ type: 'wallet_health', priority: 'high', message: `Only ${summary.spendablePercentage.toFixed(1)}% of wallet value is economically spendable`, action: 'improve_utxo_management', estimatedSavings: 'Improve spending efficiency' }) } if (summary.suspicious > 0) { recommendations.push({ type: 'security', priority: 'high', message: `${summary.suspicious} suspicious UTXOs detected - possible attack`, action: 'enable_privacy_features', estimatedSavings: 'Protect privacy' }) } if (summary.unconfirmed > summary.total * 0.2) { recommendations.push({ type: 'confirmation', priority: 'low', message: `High number of unconfirmed UTXOs (${summary.unconfirmed})`, action: 'wait_for_confirmations', estimatedSavings: 'Reduce transaction risk' }) } return recommendations } _updateMonitoringMetrics (summary) { this.metrics = { totalScanned: summary.total, healthyCount: summary.healthy, unhealthyCount: summary.unhealthy + summary.atRisk, dustCount: summary.dust, stuckCount: summary.stuck, lastUpdateTime: Date.now() } } // Public Interface Methods /** * Get health history for a specific UTXO * @param {string} utxoId - UTXO identifier * @returns {Array} Health history */ getHealthHistory (utxoId) { return this.healthHistory.get(utxoId) || [] } /** * Get active alerts from the last 24 hours * @returns {Array} Recent alerts */ getActiveAlerts () { const recentAlerts = this.alerts.filter(alert => Date.now() - alert.timestamp < 24 * 60 * 60 * 1000 // Last 24 hours ) return recentAlerts } /** * Get current monitoring metrics * @returns {Object} Monitoring metrics */ getMonitoringMetrics () { return { ...this.metrics } } /** * Clear all stored alerts */ clearAlerts () { this.alerts = [] } /** * Get detected dust attack patterns * @returns {Map} Dust attack patterns by address */ getDustAttackPatterns () { return new Map(this.dustAttackPatterns) } /** * Generate recommendations for UTXO optimization * @param {Array} utxos - UTXOs to analyze * @param {number} currentFeeRate - Current fee rate * @returns {Object} Optimization recommendations */ generateOptimizationRecommendations (utxos, currentFeeRate = 1.0) { const healthReport = this.monitorUtxoSet(utxos, null, currentFeeRate) return { analysis: { totalUtxos: healthReport.summary.total, dustUtxos: healthReport.summary.dust, fragmentationScore: Math.max(0, 100 - (healthReport.summary.dust * 10)), efficiencyScore: healthReport.summary.spendablePercentage }, recommendations: healthReport.recommendations, consolidation: { recommended: healthReport.summary.dust > 5, candidateUtxos: healthReport.summary.dust + healthReport.summary.atRisk, estimatedCost: this._estimateConsolidationCost(healthReport.summary.dust, currentFeeRate), longTermSavings: this._estimateLongTermSavings(healthReport.summary.dust), breakEvenTxCount: Math.ceil(this._estimateConsolidationCost(healthReport.summary.dust, currentFeeRate) / (currentFeeRate * 148)) } } } _estimateConsolidationCost (dustCount, feeRate) { // Estimate cost to consolidate dust UTXOs const inputSize = dustCount * 148 // Each dust UTXO as input const outputSize = 34 // Single consolidated output const overhead = 10 // Transaction overhead return (inputSize + outputSize + overhead) * feeRate } _estimateLongTermSavings (dustCount) { // Estimate long-term savings from consolidation return dustCount * 148 * 2 // Assume 2 sat/byte average future fee } } module.exports = UtxoHealthMonitor