minimal-xec-wallet
Version:
A minimalist eCash (XEC) wallet npm library, for use in web apps. Supports eTokens.
388 lines (330 loc) • 13.6 kB
JavaScript
/*
This library optimizes wallet performance by consolidating UTXOs.
It combines multiple small UTXOs into fewer, larger UTXOs to improve
transaction efficiency and reduce fees.
*/
class ConsolidateUtxos {
constructor (wallet) {
this.wallet = wallet
this.ar = wallet.ar
this.sendXecLib = wallet.sendXecLib
this.utxos = wallet.utxos
// Configuration
this.dustLimit = 200 // XEC dust limit in satoshis (2 XEC)
this.maxInputsPerTx = 200 // Maximum inputs per consolidation transaction
this.minUtxosForConsolidation = 5 // Minimum UTXOs needed to trigger consolidation
this.consolidationThreshold = 100000 // Threshold in satoshis below which UTXOs should be consolidated
this.defaultSatsPerByte = 1.2
}
async start (opts = {}) {
try {
const options = {
dryRun: opts.dryRun || false,
satsPerByte: opts.satsPerByte || this.defaultSatsPerByte,
maxInputs: opts.maxInputs || this.maxInputsPerTx,
consolidationThreshold: opts.consolidationThreshold || this.consolidationThreshold
}
// Wait for wallet to be initialized
await this.wallet.walletInfoPromise
if (!this.wallet.isInitialized) {
await this.wallet.initialize()
}
// Analyze current UTXOs
const analysis = await this.analyzeUtxos(options)
if (!analysis.shouldConsolidate) {
return {
success: true,
message: analysis.reason,
analysis,
transactions: []
}
}
if (options.dryRun) {
return {
success: true,
message: 'Dry run completed - no transactions broadcast',
analysis,
transactions: analysis.consolidationPlans
}
}
// Execute consolidation
const results = await this.executeConsolidation(analysis.consolidationPlans, options)
return {
success: true,
message: `Successfully consolidated ${analysis.totalUtxos} UTXOs into ${analysis.outputUtxos} UTXOs`,
analysis,
transactions: results
}
} catch (err) {
throw new Error(`UTXO consolidation failed: ${err.message}`)
}
}
async analyzeUtxos (options = {}) {
try {
const allUtxosResult = this.utxos.getSpendableXecUtxos()
const allUtxos = allUtxosResult.utxos || allUtxosResult
// CRITICAL: Filter out token UTXOs to prevent token burning
const pureXecUtxos = allUtxos.filter(utxo => !utxo.token)
const tokenUtxos = allUtxos.filter(utxo => utxo.token)
if (pureXecUtxos.length < this.minUtxosForConsolidation) {
return {
shouldConsolidate: false,
reason: `Not enough pure XEC UTXOs for consolidation (${pureXecUtxos.length} < ${this.minUtxosForConsolidation})`,
totalUtxos: pureXecUtxos.length,
totalValue: this._calculateTotalValue(pureXecUtxos),
tokenUtxos: tokenUtxos.length,
tokenUtxosSkipped: tokenUtxos.length > 0
}
}
// Filter UTXOs that should be consolidated (smaller ones first)
const utxosToConsolidate = pureXecUtxos
.filter(utxo => this._getUtxoValue(utxo) <= options.consolidationThreshold)
.sort((a, b) => this._getUtxoValue(a) - this._getUtxoValue(b)) // Sort by value ascending
if (utxosToConsolidate.length < this.minUtxosForConsolidation) {
return {
shouldConsolidate: false,
reason: `Not enough small pure XEC UTXOs to consolidate (${utxosToConsolidate.length} below ${options.consolidationThreshold} satoshis)`,
totalUtxos: pureXecUtxos.length,
totalValue: this._calculateTotalValue(pureXecUtxos),
smallUtxos: utxosToConsolidate.length,
tokenUtxos: tokenUtxos.length,
tokenUtxosSkipped: tokenUtxos.length > 0
}
}
// Calculate optimal consolidation strategy
const consolidationPlans = this.calculateOptimalConsolidation(utxosToConsolidate, options)
// Calculate savings
const currentFeeForSpending = this._estimateCurrentSpendingFee(utxosToConsolidate, options.satsPerByte)
const consolidationFee = consolidationPlans.reduce((total, plan) => total + plan.estimatedFee, 0)
const futureSpendingFee = this._estimateFutureSpendingFee(consolidationPlans.length, options.satsPerByte)
const totalSavings = currentFeeForSpending - consolidationFee - futureSpendingFee
return {
shouldConsolidate: totalSavings > 0,
reason: totalSavings > 0
? `Consolidation will save ${totalSavings} satoshis in future transaction fees`
: `Consolidation would cost ${Math.abs(totalSavings)} satoshis more than current setup`,
totalUtxos: utxosToConsolidate.length,
outputUtxos: consolidationPlans.length,
totalValue: this._calculateTotalValue(utxosToConsolidate),
consolidationFee,
potentialSavings: totalSavings,
tokenUtxos: tokenUtxos.length,
tokenUtxosSkipped: tokenUtxos.length > 0,
consolidationPlans
}
} catch (err) {
throw new Error(`UTXO analysis failed: ${err.message}`)
}
}
calculateOptimalConsolidation (utxos, options = {}) {
try {
const maxInputs = options.maxInputs || this.maxInputsPerTx
const satsPerByte = options.satsPerByte || this.defaultSatsPerByte
const plans = []
// Split UTXOs into batches that can be processed in single transactions
for (let i = 0; i < utxos.length; i += maxInputs) {
const batch = utxos.slice(i, i + maxInputs)
const totalValue = this._calculateTotalValue(batch)
// Calculate estimated fee for this consolidation transaction
const estimatedFee = this._calculateConsolidationFee(batch.length, 1, satsPerByte)
const outputValue = totalValue - estimatedFee
if (outputValue <= this.dustLimit) {
// Skip batches that would result in dust
continue
}
plans.push({
inputUtxos: batch,
inputCount: batch.length,
totalInputValue: totalValue,
estimatedFee,
outputValue,
outputCount: 1, // Consolidate into single output
savings: this._calculateBatchSavings(batch, satsPerByte)
})
}
return plans
} catch (err) {
throw new Error(`Consolidation calculation failed: ${err.message}`)
}
}
async executeConsolidation (consolidationPlans, options = {}) {
try {
const results = []
for (const plan of consolidationPlans) {
try {
// CRITICAL SAFETY CHECK: Ensure no token UTXOs are being consolidated
const tokenUtxosInPlan = plan.inputUtxos.filter(utxo => utxo.token)
if (tokenUtxosInPlan.length > 0) {
throw new Error(`SAFETY ABORT: Plan contains ${tokenUtxosInPlan.length} token UTXOs. Consolidation would burn tokens!`)
}
// Create consolidation transaction - send all value to same address
const outputs = [{
address: this.wallet.walletInfo.xecAddress,
amountSat: plan.outputValue
}]
const txid = await this.sendXecLib.sendXec(
outputs,
this.wallet.walletInfo,
plan.inputUtxos
)
results.push({
txid,
inputCount: plan.inputCount,
inputValue: plan.totalInputValue,
outputValue: plan.outputValue,
fee: plan.estimatedFee,
success: true
})
// Brief delay between transactions to avoid overwhelming the network
if (consolidationPlans.indexOf(plan) < consolidationPlans.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
} catch (err) {
results.push({
inputCount: plan.inputCount,
inputValue: plan.totalInputValue,
error: err.message,
success: false
})
}
}
// Refresh UTXO cache after consolidation
await this.utxos.refreshCache(this.wallet.walletInfo.xecAddress)
return results
} catch (err) {
throw new Error(`Consolidation execution failed: ${err.message}`)
}
}
async createConsolidationTx (utxosToConsolidate, options = {}) {
try {
const satsPerByte = options.satsPerByte || this.defaultSatsPerByte
const totalValue = this._calculateTotalValue(utxosToConsolidate)
const estimatedFee = this._calculateConsolidationFee(utxosToConsolidate.length, 1, satsPerByte)
const outputValue = totalValue - estimatedFee
if (outputValue <= this.dustLimit) {
throw new Error('Consolidation would result in dust output')
}
// Create single output to same address
const outputs = [{
address: this.wallet.walletInfo.xecAddress,
amountSat: outputValue
}]
// Create transaction hex
const txHex = await this.sendXecLib.createTransaction(
outputs,
this.wallet.walletInfo,
utxosToConsolidate,
satsPerByte
)
return {
txHex,
inputCount: utxosToConsolidate.length,
totalInputValue: totalValue,
outputValue,
estimatedFee
}
} catch (err) {
throw new Error(`Consolidation transaction creation failed: ${err.message}`)
}
}
// Helper methods
_calculateTotalValue (utxos) {
return utxos.reduce((total, utxo) => total + 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
}
_calculateConsolidationFee (numInputs, numOutputs, satsPerByte) {
// Estimate transaction size: inputs (~148 bytes) + outputs (~34 bytes) + overhead (~10 bytes)
const estimatedSize = (numInputs * 148) + (numOutputs * 34) + 10
return Math.ceil(estimatedSize * satsPerByte)
}
_estimateCurrentSpendingFee (utxos, satsPerByte) {
// Estimate what it would cost to spend all these UTXOs in future transactions
// Assume average transaction uses 2 outputs
return this._calculateConsolidationFee(utxos.length, 2, satsPerByte)
}
_estimateFutureSpendingFee (numConsolidatedOutputs, satsPerByte) {
// Estimate cost to spend the consolidated UTXOs in the future
return this._calculateConsolidationFee(numConsolidatedOutputs, 2, satsPerByte)
}
_calculateBatchSavings (batch, satsPerByte) {
const currentCost = this._estimateCurrentSpendingFee(batch, satsPerByte)
const consolidationCost = this._calculateConsolidationFee(batch.length, 1, satsPerByte)
const futureCost = this._estimateFutureSpendingFee(1, satsPerByte)
return currentCost - consolidationCost - futureCost
}
// Analysis methods for wallet optimization
getUtxoDistribution () {
try {
const allUtxosResult = this.utxos.getSpendableXecUtxos()
const allUtxos = allUtxosResult.utxos || allUtxosResult
// Separate pure XEC from token UTXOs
const pureXecUtxos = allUtxos.filter(utxo => !utxo.token)
const tokenUtxos = allUtxos.filter(utxo => utxo.token)
const distribution = {
dust: 0, // < 1000 sats
small: 0, // 1000 - 10000 sats
medium: 0, // 10000 - 100000 sats
large: 0, // > 100000 sats
total: pureXecUtxos.length,
tokenUtxos: tokenUtxos.length,
tokenUtxosSkipped: tokenUtxos.length > 0
}
// Only analyze pure XEC UTXOs for consolidation
for (const utxo of pureXecUtxos) {
const value = this._getUtxoValue(utxo)
if (value < 1000) {
distribution.dust++
} else if (value < 10000) {
distribution.small++
} else if (value < 100000) {
distribution.medium++
} else {
distribution.large++
}
}
return distribution
} catch (err) {
throw new Error(`UTXO distribution analysis failed: ${err.message}`)
}
}
estimateOptimizationSavings () {
try {
const allUtxosResult = this.utxos.getSpendableXecUtxos()
const allUtxos = allUtxosResult.utxos || allUtxosResult
// Only analyze pure XEC UTXOs (tokens are preserved separately)
const pureXecUtxos = allUtxos.filter(utxo => !utxo.token)
const tokenUtxos = allUtxos.filter(utxo => utxo.token)
if (pureXecUtxos.length < 2) {
return {
savings: 0,
reason: 'No optimization needed for pure XEC UTXOs',
currentUtxos: pureXecUtxos.length,
tokenUtxos: tokenUtxos.length
}
}
const currentFee = this._estimateCurrentSpendingFee(pureXecUtxos, this.defaultSatsPerByte)
const optimalUtxoCount = Math.max(1, Math.ceil(pureXecUtxos.length / 50)) // Optimal: ~50 UTXOs max
const optimizedFee = this._estimateFutureSpendingFee(optimalUtxoCount, this.defaultSatsPerByte)
return {
savings: currentFee - optimizedFee,
currentUtxos: pureXecUtxos.length,
optimalUtxos: optimalUtxoCount,
currentEstimatedFee: currentFee,
optimizedEstimatedFee: optimizedFee,
tokenUtxos: tokenUtxos.length,
tokenUtxosPreserved: tokenUtxos.length
}
} catch (err) {
throw new Error(`Optimization savings estimation failed: ${err.message}`)
}
}
}
module.exports = ConsolidateUtxos