minimal-xec-wallet
Version:
A minimalist eCash (XEC) wallet npm library, for use in web apps. Supports eTokens.
781 lines (670 loc) • 23.3 kB
JavaScript
/*
Enhanced UTXO management for minimal XEC wallet
Core functionality:
- Fetch UTXOs from chronik
- Basic validation and filtering
- Simple caching
- Essential security checks
Advanced analytics (optional):
- UTXO classification (age, value, privacy analysis)
- Health monitoring and dust attack detection
- Smart coin selection strategies
- Optimization recommendations
*/
const SecurityValidator = require('./security')
// Optional analytics modules (loaded only when enabled)
let UtxoClassifier, UtxoHealthMonitor
try {
UtxoClassifier = require('./utxo-analytics/UtxoClassifier')
UtxoHealthMonitor = require('./utxo-analytics/UtxoHealthMonitor')
} catch (error) {
// Analytics modules not available - continue with basic functionality
}
class Utxos {
constructor (localConfig = {}) {
this.chronik = localConfig.chronik
this.ar = localConfig.ar
if (!this.ar) {
throw new Error('AdapterRouter instance required for UTXO management')
}
// Simple UTXO store
this.utxoStore = {
xecUtxos: [],
lastUpdated: null,
cacheKey: null
}
// Security validator
this.security = new SecurityValidator(localConfig.security)
// Simple configuration
this.maxRetries = localConfig.maxRetries || 3
this.retryDelay = localConfig.retryDelay || 1000
this.cacheTimeout = localConfig.cacheTimeout || 30000 // 30 seconds
// Analytics configuration
this.analyticsConfig = localConfig.utxoAnalytics || { enabled: false }
this.analyticsEnabled = !!(this.analyticsConfig.enabled && UtxoClassifier && UtxoHealthMonitor)
// Initialize analytics modules if enabled
this.classifier = null
this.healthMonitor = null
this.classifications = new Map()
this.lastHealthReport = null
if (this.analyticsEnabled) {
try {
this.classifier = new UtxoClassifier(this.analyticsConfig.classification)
this.healthMonitor = new UtxoHealthMonitor(this.analyticsConfig.health)
if (this.analyticsConfig.debug) {
console.log('UTXO analytics enabled')
}
} catch (error) {
console.warn('Failed to initialize UTXO analytics:', error.message)
this.analyticsEnabled = false
}
}
// Performance tracking (basic)
this.performanceMetrics = {
totalRequests: 0,
cacheHits: 0,
lastRefreshTime: null,
totalResponseTime: 0,
averageResponseTime: 0,
analyticsTime: 0
}
}
/**
* Initialize UTXO store for an address
* @param {string} addr - XEC address
* @param {boolean} forceRefresh - Force refresh cache
* @returns {boolean} - Success status
*/
async initUtxoStore (addr, forceRefresh = false) {
try {
this.performanceMetrics.totalRequests++
// Check cache validity
if (!forceRefresh && this._isCacheValid(addr)) {
this.performanceMetrics.cacheHits++
return true
}
// Fetch fresh UTXO data
const utxosResult = await this._fetchUtxosWithRetry(addr)
// Process and store UTXOs
this._processUtxos(utxosResult, addr)
// Perform analytics if enabled
if (this.analyticsEnabled) {
await this._performAnalytics()
}
return true
} catch (err) {
throw new Error(`UTXO initialization failed: ${err.message}`)
}
}
/**
* Get spendable XEC UTXOs with enhanced filtering
* @param {Object} options - Filtering options
* @returns {Array} - Filtered UTXOs
*/
getSpendableXecUtxos (options = {}) {
const {
includeUnconfirmed = false,
excludeDustAttack = true,
useClassifications = false,
minHealthScore = 0,
minPrivacyScore = 0,
strategy = 'default'
} = options
let filteredUtxos = this.security.filterSecureUtxos(this.utxoStore.xecUtxos, {
includeUnconfirmed,
excludeDustAttack
})
// Apply classification-based filtering if analytics enabled
if (useClassifications && this.analyticsEnabled && this.classifications.size > 0) {
filteredUtxos = this._filterByClassifications(filteredUtxos, {
minHealthScore,
minPrivacyScore,
strategy
})
}
return {
utxos: filteredUtxos,
total: filteredUtxos.length,
analyticsEnabled: this.analyticsEnabled
}
}
/**
* Enhanced UTXO selection with smart strategies
* @param {number} targetAmount - Target amount in satoshis
* @param {Object} options - Selection options
* @returns {Object} - Selection result
*/
selectOptimalUtxos (targetAmount, options = {}) {
const {
strategy = 'efficient',
useClassifications = true,
feeRate = 1.0
} = options
// Get spendable UTXOs with classification filtering if enabled
const spendableUtxosResult = this.getSpendableXecUtxos({
...options,
useClassifications: useClassifications && this.analyticsEnabled
})
const spendableUtxos = spendableUtxosResult.utxos || spendableUtxosResult
if (spendableUtxos.length === 0) {
throw new Error('No spendable UTXOs available')
}
// Use smart selection if classifications available
if (useClassifications && this.analyticsEnabled && this.classifications.size > 0) {
const result = this._selectWithClassifications(spendableUtxos, targetAmount, strategy, feeRate)
result.strategy = strategy
return result
}
// Fallback to basic selection
const result = this._selectBasic(spendableUtxos, targetAmount, feeRate)
result.strategy = strategy
return result
}
/**
* Get current balance
* @returns {Object} - Balance information
*/
getBalance () {
const utxos = this.utxoStore.xecUtxos
let confirmed = 0
let unconfirmed = 0
utxos.forEach(utxo => {
const value = this._getUtxoValue(utxo)
if (utxo.blockHeight === -1) {
unconfirmed += value
} else {
confirmed += value
}
})
return {
confirmed,
unconfirmed,
total: confirmed + unconfirmed
}
}
/**
* Clear cache
*/
clearCache () {
this.utxoStore = {
xecUtxos: [],
lastUpdated: null,
cacheKey: null
}
}
/**
* Get spendable eToken UTXOs (Phase 2 - not implemented)
* @returns {Array} - Empty array for Phase 1
*/
getSpendableETokenUtxos () {
return []
}
/**
* Refresh cache for address
* @param {string} addr - Address to refresh
* @returns {boolean} - Success status
*/
async refreshCache (addr) {
return await this.initUtxoStore(addr, true)
}
/**
* Filter dust UTXOs (legacy method)
* @param {Array} utxos - UTXOs to filter
* @returns {Array} - Non-dust UTXOs
*/
_filterDustUtxos (utxos) {
return utxos.filter(utxo => this._getUtxoValue(utxo) >= 1000)
}
/**
* Sort UTXOs by value (legacy method)
* @param {Array} utxos - UTXOs to sort
* @param {string} order - 'asc' or 'desc'
* @returns {Array} - Sorted UTXOs
*/
_sortUtxosByValue (utxos, order = 'desc') {
return [...utxos].sort((a, b) => {
const aValue = this._getUtxoValue(a)
const bValue = this._getUtxoValue(b)
return order === 'desc' ? bValue - aValue : aValue - bValue
})
}
// Analytics Methods (available when analytics enabled)
/**
* Get UTXO classifications with aggregated statistics
* @returns {Object} - Classification statistics and data
*/
getUtxoClassifications () {
if (!this.analyticsEnabled) {
throw new Error('UTXO analytics not enabled')
}
const stats = this.classifier.getClassificationStats(this.classifications)
const byPrivacy = Array.from(this.classifications.values())
.sort((a, b) => b.privacy - a.privacy)
.slice(0, 10) // Top 10 most private UTXOs
return {
byAge: stats.byAge,
byValue: stats.byValue,
byPrivacy: byPrivacy,
statistics: {
totalUtxos: stats.total,
totalValue: stats.totalValue,
averageAge: stats.averageAgeScore,
averageValue: stats.averageValueScore,
averagePrivacyScore: stats.averagePrivacyScore
}
}
}
/**
* Get comprehensive wallet health report
* @param {number} currentFeeRate - Current network fee rate
* @returns {Object} - Health report
*/
getWalletHealthReport (currentFeeRate = 1.0) {
if (!this.analyticsEnabled) {
throw new Error('UTXO analytics not enabled')
}
if (!this.lastHealthReport || this._shouldRefreshHealthReport()) {
const rawReport = this.healthMonitor.monitorUtxoSet(
this.utxoStore.xecUtxos,
this.classifications,
currentFeeRate
)
// Add overall health assessment
const healthyPercentage = rawReport.summary.healthPercentage || 0
let overallHealth = 'critical'
if (healthyPercentage >= 80) overallHealth = 'healthy'
else if (healthyPercentage >= 60) overallHealth = 'good'
else if (healthyPercentage >= 40) overallHealth = 'fair'
else if (healthyPercentage >= 20) overallHealth = 'poor'
this.lastHealthReport = {
overallHealth,
metrics: {
totalUtxos: rawReport.summary.total,
healthyUtxos: rawReport.summary.healthy,
unhealthyUtxos: rawReport.summary.unhealthy,
dustUtxos: rawReport.summary.dust,
suspiciousUtxos: rawReport.summary.suspicious
},
alerts: rawReport.alerts,
recommendations: rawReport.recommendations,
summary: rawReport.summary,
assessments: rawReport.assessments
}
}
return this.lastHealthReport
}
/**
* Get optimization recommendations
* @param {number} currentFeeRate - Current network fee rate
* @returns {Object} - Optimization recommendations
*/
getOptimizationRecommendations (currentFeeRate = 1.0) {
if (!this.analyticsEnabled) {
throw new Error('UTXO analytics not enabled')
}
const rawRecommendations = this.healthMonitor.generateOptimizationRecommendations(
this.utxoStore.xecUtxos,
currentFeeRate
)
// Transform to expected format
const lowPrivacyUtxos = Array.from(this.classifications.values())
.filter(c => c.privacy < 60)
.map(c => c.id)
return {
consolidation: {
...rawRecommendations.consolidation,
estimatedSavings: rawRecommendations.consolidation.longTermSavings || 0
},
privacy: {
lowPrivacyUtxos: lowPrivacyUtxos,
recommendations: rawRecommendations.recommendations.filter(r => r.type === 'privacy')
},
efficiency: {
dustUtxos: rawRecommendations.analysis.dustUtxos,
fragmentedValue: rawRecommendations.analysis.fragmentationScore
}
}
}
/**
* Detect potential dust attacks
* @param {string} address - Wallet address
* @returns {Object} - Dust attack analysis
*/
detectSecurityThreats (address) {
if (!this.analyticsEnabled) {
throw new Error('UTXO analytics not enabled')
}
const dustAttackResult = this.healthMonitor.detectDustAttack(this.utxoStore.xecUtxos, address)
return {
dustAttack: {
detected: dustAttackResult.severity !== 'none',
suspiciousUtxos: dustAttackResult.utxos || [],
confidence: this._calculateConfidence(dustAttackResult.severity)
},
suspiciousPatterns: dustAttackResult.indicators || [],
riskLevel: this._mapSeverityToRiskLevel(dustAttackResult.severity)
}
}
_calculateConfidence (severity) {
switch (severity) {
case 'critical': return 90
case 'high': return 75
case 'medium': return 60
case 'low': return 40
default: return 0
}
}
_mapSeverityToRiskLevel (severity) {
switch (severity) {
case 'critical': return 'critical'
case 'high': return 'high'
case 'medium': return 'medium'
default: return 'low'
}
}
/**
* Get classification statistics
* @returns {Object} - Classification statistics
*/
getClassificationStats () {
if (!this.analyticsEnabled) {
throw new Error('UTXO analytics not enabled')
}
return this.classifier.getClassificationStats(this.classifications)
}
/**
* Check if analytics features are available
* @returns {boolean} - True if analytics enabled
*/
hasAnalytics () {
return this.analyticsEnabled
}
/**
* Get performance metrics including analytics timing
* @returns {Object} - Enhanced performance data
*/
getPerformanceMetrics () {
const baseMetrics = this._getBasePerformanceMetrics()
if (this.analyticsEnabled) {
baseMetrics.analyticsEnabled = true
baseMetrics.classificationsCount = this.classifications.size
baseMetrics.analyticsTime = this.performanceMetrics.analyticsTime
baseMetrics.lastAnalyticsRun = this.lastHealthReport?.lastScan || null
} else {
baseMetrics.analyticsEnabled = false
}
return baseMetrics
}
// Private methods
async _fetchUtxosWithRetry (addr, maxRetries = null) {
const retryLimit = maxRetries || this.maxRetries
let attempt = 1
while (attempt <= retryLimit) {
try {
const utxosResult = await this.ar.getUtxos(addr)
return utxosResult
} catch (err) {
if (attempt === retryLimit) {
throw new Error(`Failed to fetch UTXOs after ${retryLimit} attempts: ${err.message}`)
}
await this._delay(this.retryDelay * attempt)
attempt++
}
}
}
_processUtxos (utxosResult, addr) {
if (!utxosResult || !Array.isArray(utxosResult.utxos)) {
throw new Error('Invalid UTXO response format')
}
// Filter and validate UTXOs
const validUtxos = utxosResult.utxos.filter(utxo => this._isValidUtxo(utxo))
// Store UTXOs
this.utxoStore.xecUtxos = validUtxos
this.utxoStore.lastUpdated = Date.now()
this.utxoStore.cacheKey = addr
this.performanceMetrics.lastRefreshTime = Date.now()
}
_isValidUtxo (utxo) {
return this.security.isValidUtxoStructure(utxo) && this._getUtxoValue(utxo) > 0
}
_getUtxoValue (utxo) {
if (utxo.sats !== undefined) {
return typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
}
if (utxo.value !== undefined) {
return typeof utxo.value === 'bigint' ? Number(utxo.value) : parseInt(utxo.value)
}
return 0
}
_isCacheValid (addr) {
return (
this.utxoStore.cacheKey === addr &&
this.utxoStore.lastUpdated &&
(Date.now() - this.utxoStore.lastUpdated) < this.cacheTimeout
)
}
_delay (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// Analytics Private Methods
/**
* Perform UTXO analytics (classification and health monitoring)
* @private
*/
async _performAnalytics () {
if (!this.analyticsEnabled || this.utxoStore.xecUtxos.length === 0) {
return
}
try {
const startTime = Date.now()
// Get current block height for age calculations (placeholder - needs real implementation)
const currentBlockHeight = await this._getCurrentBlockHeight()
// Classify UTXOs
this.classifications = this.classifier.classifyUtxos(
this.utxoStore.xecUtxos,
currentBlockHeight
)
// Clear cached health report to force refresh
this.lastHealthReport = null
const analyticsTime = Date.now() - startTime
this.performanceMetrics.analyticsTime = analyticsTime
if (this.analyticsConfig.debug) {
console.log(`Analytics completed in ${analyticsTime}ms: ${this.classifications.size} UTXOs classified`)
}
} catch (error) {
console.warn('Analytics processing failed:', error.message)
// Continue without analytics rather than failing completely
}
}
/**
* Get current block height (placeholder implementation)
* @private
* @returns {number} - Current block height
*/
async _getCurrentBlockHeight () {
// This would need to be implemented with actual chronik call
// For now, return a reasonable default
return 800000 // Placeholder
}
/**
* Filter UTXOs by classification criteria
* @private
* @param {Array} utxos - UTXOs to filter
* @param {Object} criteria - Filter criteria
* @returns {Array} - Filtered UTXOs
*/
_filterByClassifications (utxos, criteria) {
const { minHealthScore, minPrivacyScore, strategy } = criteria
return utxos.filter(utxo => {
const utxoId = `${utxo.outpoint.txid}:${utxo.outpoint.outIdx}`
const classification = this.classifications.get(utxoId)
if (!classification) return true // Include unclassified UTXOs
// Apply filters based on strategy
switch (strategy) {
case 'privacy':
return classification.privacy >= Math.max(60, minPrivacyScore)
case 'health':
return classification.healthScore >= Math.max(70, minHealthScore)
case 'efficiency':
return classification.metadata.isEconomical
default:
return (
classification.healthScore >= minHealthScore &&
classification.privacy >= minPrivacyScore
)
}
})
}
/**
* Smart UTXO selection using classifications
* @private
* @param {Array} utxos - Available UTXOs
* @param {number} targetAmount - Target amount
* @param {string} strategy - Selection strategy
* @param {number} feeRate - Fee rate
* @returns {Object} - Selection result
*/
_selectWithClassifications (utxos, targetAmount, strategy, feeRate) {
// Get sorted UTXOs based on strategy
const sortedUtxos = this._sortUtxosByStrategy(utxos, strategy)
return this._selectFromSorted(sortedUtxos, targetAmount, feeRate)
}
/**
* Sort UTXOs by selection strategy
* @private
* @param {Array} utxos - UTXOs to sort
* @param {string} strategy - Selection strategy
* @returns {Array} - Sorted UTXOs
*/
_sortUtxosByStrategy (utxos, strategy) {
return [...utxos].sort((a, b) => {
const aId = `${a.outpoint.txid}:${a.outpoint.outIdx}`
const bId = `${b.outpoint.txid}:${b.outpoint.outIdx}`
const aClassification = this.classifications.get(aId)
const bClassification = this.classifications.get(bId)
// Fallback to value-based sorting if no classification
if (!aClassification || !bClassification) {
return this._getUtxoValue(b) - this._getUtxoValue(a)
}
switch (strategy) {
case 'privacy':
// Prefer higher privacy scores, then age
if (aClassification.privacy !== bClassification.privacy) {
return bClassification.privacy - aClassification.privacy
}
return bClassification.ageScore - aClassification.ageScore
case 'efficient':
// Prefer economical UTXOs, then efficiency
if (aClassification.metadata.isEconomical !== bClassification.metadata.isEconomical) {
return aClassification.metadata.isEconomical ? -1 : 1
}
return bClassification.valueScore - aClassification.valueScore
case 'balanced': {
// Balanced scoring considering all factors
const aScore = (aClassification.healthScore + aClassification.privacy + aClassification.valueScore) / 3
const bScore = (bClassification.healthScore + bClassification.privacy + bClassification.valueScore) / 3
return bScore - aScore
}
case 'conservative':
// Prefer confirmed, older UTXOs
if (aClassification.metadata.isConfirmed !== bClassification.metadata.isConfirmed) {
return aClassification.metadata.isConfirmed ? -1 : 1
}
return bClassification.ageScore - aClassification.ageScore
default:
// Default to health score
return bClassification.healthScore - aClassification.healthScore
}
})
}
/**
* Basic UTXO selection (fallback when no classifications)
* @private
* @param {Array} utxos - Available UTXOs
* @param {number} targetAmount - Target amount
* @param {number} feeRate - Fee rate
* @returns {Object} - Selection result
*/
_selectBasic (utxos, targetAmount, feeRate) {
// Sort by value descending (largest first)
const sortedUtxos = utxos.sort((a, b) => {
return this._getUtxoValue(b) - this._getUtxoValue(a)
})
return this._selectFromSorted(sortedUtxos, targetAmount, feeRate)
}
/**
* Select UTXOs from sorted array
* @private
* @param {Array} sortedUtxos - Pre-sorted UTXOs
* @param {number} targetAmount - Target amount
* @param {number} feeRate - Fee rate
* @returns {Object} - Selection result
*/
_selectFromSorted (sortedUtxos, targetAmount, feeRate) {
const selectedUtxos = []
let totalAmount = 0
const inputCost = 148 // P2PKH input size in bytes
for (const utxo of sortedUtxos) {
const utxoValue = this._getUtxoValue(utxo)
selectedUtxos.push(utxo)
totalAmount += utxoValue
// Estimate fee
const estimatedFee = Math.ceil((selectedUtxos.length * inputCost + 34 + 10) * feeRate)
if (totalAmount >= targetAmount + estimatedFee) {
const finalFee = Math.ceil((selectedUtxos.length * inputCost + 34 + 10) * feeRate)
const change = totalAmount - targetAmount - finalFee
return {
utxos: selectedUtxos,
totalValue: totalAmount,
estimatedFee: finalFee,
change: Math.max(0, change),
txSize: selectedUtxos.length * inputCost + 34 + 10,
strategy: 'basic'
}
}
}
// Check if we have enough
const finalFee = Math.ceil((selectedUtxos.length * inputCost + 34 + 10) * feeRate)
if (totalAmount < targetAmount + finalFee) {
throw new Error('Insufficient funds')
}
return {
utxos: selectedUtxos,
totalValue: totalAmount,
estimatedFee: finalFee,
change: totalAmount - targetAmount - finalFee,
txSize: selectedUtxos.length * inputCost + 34 + 10,
strategy: 'basic'
}
}
/**
* Check if health report should be refreshed
* @private
* @returns {boolean} - True if refresh needed
*/
_shouldRefreshHealthReport () {
if (!this.lastHealthReport) return true
const age = Date.now() - this.lastHealthReport.lastScan
const maxAge = this.analyticsConfig.healthRefreshInterval || 300000 // 5 minutes
return age > maxAge
}
/**
* Get base performance metrics (backwards compatibility)
* @private
* @returns {Object} - Base performance metrics
*/
_getBasePerformanceMetrics () {
const cacheHitRate = this.performanceMetrics.totalRequests > 0
? (this.performanceMetrics.cacheHits / this.performanceMetrics.totalRequests) * 100
: 0
return {
cacheHitRate: Math.round(cacheHitRate * 100) / 100,
totalRequests: this.performanceMetrics.totalRequests,
cacheHits: this.performanceMetrics.cacheHits,
averageResponseTime: this.performanceMetrics.averageResponseTime,
lastRefreshTime: this.performanceMetrics.lastRefreshTime,
utxoCount: this.utxoStore.xecUtxos.length
}
}
}
module.exports = Utxos