UTXO Management
Complete examples for managing Unspent Transaction Outputs (UTXOs) efficiently.
Overview
UTXO management is critical for wallet performance and transaction building. This code feature demonstrates practical UTXO tracking, selection strategies, and optimization techniques.
Related SDK Components:
UTXO Tracker
import { Transaction, PrivateKey, PublicKey, Script } from '@bsv/sdk'
interface UTXO {
txid: string
vout: number
satoshis: number
script: Script
confirmations?: number
}
/**
* UTXO Tracker
*
* Track and manage UTXOs for a wallet
*/
class UTXOTracker {
private utxos: Map<string, UTXO> = new Map()
/**
* Add UTXO to tracker
*/
addUTXO(utxo: UTXO): void {
const key = `${utxo.txid}:${utxo.vout}`
this.utxos.set(key, utxo)
}
/**
* Remove spent UTXO
*/
removeUTXO(txid: string, vout: number): void {
const key = `${txid}:${vout}`
this.utxos.delete(key)
}
/**
* Get all UTXOs
*/
getAllUTXOs(): UTXO[] {
return Array.from(this.utxos.values())
}
/**
* Get total balance
*/
getTotalBalance(): number {
return Array.from(this.utxos.values())
.reduce((sum, utxo) => sum + utxo.satoshis, 0)
}
/**
* Get confirmed balance
*/
getConfirmedBalance(minConfirmations: number = 6): number {
return Array.from(this.utxos.values())
.filter(utxo => (utxo.confirmations || 0) >= minConfirmations)
.reduce((sum, utxo) => sum + utxo.satoshis, 0)
}
/**
* Get UTXOs by minimum value
*/
getUTXOsByMinValue(minValue: number): UTXO[] {
return Array.from(this.utxos.values())
.filter(utxo => utxo.satoshis >= minValue)
}
/**
* Process transaction to update UTXO set
*/
processTransaction(
tx: Transaction,
publicKeyHash: Buffer
): void {
// Remove spent UTXOs (inputs)
for (const input of tx.inputs) {
this.removeUTXO(input.sourceTXID!, input.sourceOutputIndex!)
}
// Add new UTXOs (outputs)
tx.outputs.forEach((output, index) => {
if (this.isOwnedByKey(output.lockingScript, publicKeyHash)) {
this.addUTXO({
txid: tx.id('hex'),
vout: index,
satoshis: output.satoshis!,
script: output.lockingScript,
confirmations: 0
})
}
})
}
/**
* Check if output is owned by key
*/
private isOwnedByKey(script: Script, publicKeyHash: Buffer): boolean {
// Simple P2PKH detection
const chunks = script.chunks
if (chunks.length !== 5) return false
if (chunks[0].op !== OP.OP_DUP) return false
if (chunks[1].op !== OP.OP_HASH160) return false
if (chunks[3].op !== OP.OP_EQUALVERIFY) return false
if (chunks[4].op !== OP.OP_CHECKSIG) return false
return chunks[2].buf?.equals(publicKeyHash) || false
}
}UTXO Selection Strategies
/**
* UTXO Selection Strategies
*
* Different algorithms for selecting UTXOs optimally
*/
class UTXOSelector {
/**
* Largest-first selection
* Minimizes number of inputs, but may create large change
*/
selectLargestFirst(
utxos: UTXO[],
targetAmount: number,
feePerInput: number = 150
): UTXO[] {
const sorted = [...utxos].sort((a, b) => b.satoshis - a.satoshis)
const selected: UTXO[] = []
let total = 0
for (const utxo of sorted) {
selected.push(utxo)
total += utxo.satoshis
const fee = feePerInput * selected.length
if (total >= targetAmount + fee) {
break
}
}
const finalFee = feePerInput * selected.length
if (total < targetAmount + finalFee) {
throw new Error('Insufficient funds')
}
return selected
}
/**
* Smallest-first selection
* Cleans up small UTXOs (dust consolidation)
*/
selectSmallestFirst(
utxos: UTXO[],
targetAmount: number,
feePerInput: number = 150
): UTXO[] {
const sorted = [...utxos].sort((a, b) => a.satoshis - b.satoshis)
const selected: UTXO[] = []
let total = 0
for (const utxo of sorted) {
selected.push(utxo)
total += utxo.satoshis
const fee = feePerInput * selected.length
if (total >= targetAmount + fee) {
break
}
}
const finalFee = feePerInput * selected.length
if (total < targetAmount + finalFee) {
throw new Error('Insufficient funds')
}
return selected
}
/**
* Branch-and-bound selection
* Finds optimal UTXO combination with minimal change
*/
selectOptimal(
utxos: UTXO[],
targetAmount: number,
feePerInput: number = 150
): UTXO[] {
// Try to find exact match first
const exactMatch = this.findExactMatch(utxos, targetAmount, feePerInput)
if (exactMatch) return exactMatch
// Try branch-and-bound for near-perfect match
const sorted = [...utxos].sort((a, b) => b.satoshis - a.satoshis)
const bestMatch = this.branchAndBound(
sorted,
targetAmount,
feePerInput,
0,
[],
0
)
if (bestMatch) return bestMatch
// Fallback to largest-first
return this.selectLargestFirst(utxos, targetAmount, feePerInput)
}
/**
* Find exact match (no change needed)
*/
private findExactMatch(
utxos: UTXO[],
targetAmount: number,
feePerInput: number
): UTXO[] | null {
// Try single UTXO
for (const utxo of utxos) {
const fee = feePerInput
if (Math.abs(utxo.satoshis - targetAmount - fee) < 546) {
return [utxo]
}
}
// Try pairs
for (let i = 0; i < utxos.length; i++) {
for (let j = i + 1; j < utxos.length; j++) {
const total = utxos[i].satoshis + utxos[j].satoshis
const fee = feePerInput * 2
if (Math.abs(total - targetAmount - fee) < 546) {
return [utxos[i], utxos[j]]
}
}
}
return null
}
/**
* Branch-and-bound algorithm for optimal selection
*/
private branchAndBound(
utxos: UTXO[],
targetAmount: number,
feePerInput: number,
index: number,
current: UTXO[],
currentTotal: number
): UTXO[] | null {
const fee = feePerInput * current.length
// Success: found near-perfect match
if (currentTotal >= targetAmount + fee &&
currentTotal - targetAmount - fee < 1000) {
return current
}
// Exceeded or out of UTXOs
if (index >= utxos.length || currentTotal > targetAmount + fee + 10000) {
return null
}
// Try including current UTXO
const withCurrent = this.branchAndBound(
utxos,
targetAmount,
feePerInput,
index + 1,
[...current, utxos[index]],
currentTotal + utxos[index].satoshis
)
if (withCurrent) return withCurrent
// Try excluding current UTXO
return this.branchAndBound(
utxos,
targetAmount,
feePerInput,
index + 1,
current,
currentTotal
)
}
/**
* Select UTXOs with change minimization
*/
selectMinimizeChange(
utxos: UTXO[],
targetAmount: number,
feePerInput: number = 150
): UTXO[] {
let bestSelection: UTXO[] = []
let smallestChange = Infinity
// Try all combinations up to 4 UTXOs
const maxCombinations = Math.min(utxos.length, 4)
for (let size = 1; size <= maxCombinations; size++) {
const combinations = this.getCombinations(utxos, size)
for (const combo of combinations) {
const total = combo.reduce((sum, u) => sum + u.satoshis, 0)
const fee = feePerInput * combo.length
const change = total - targetAmount - fee
if (change >= 0 && change < smallestChange) {
smallestChange = change
bestSelection = combo
}
}
}
if (bestSelection.length === 0) {
throw new Error('Insufficient funds')
}
return bestSelection
}
/**
* Get all combinations of given size
*/
private getCombinations(utxos: UTXO[], size: number): UTXO[][] {
if (size === 1) {
return utxos.map(u => [u])
}
const result: UTXO[][] = []
for (let i = 0; i <= utxos.length - size; i++) {
const smaller = this.getCombinations(utxos.slice(i + 1), size - 1)
for (const combo of smaller) {
result.push([utxos[i], ...combo])
}
}
return result
}
}UTXO Consolidation
/**
* UTXO Consolidation
*
* Combine many small UTXOs into fewer larger ones
*/
class UTXOConsolidator {
/**
* Consolidate UTXOs when count exceeds threshold
*/
async consolidate(
privateKey: PrivateKey,
utxos: UTXO[],
threshold: number = 100
): Promise<Transaction | null> {
if (utxos.length < threshold) {
return null
}
const tx = new Transaction()
// Add all UTXOs as inputs
for (const utxo of utxos) {
tx.addInput({
sourceTXID: utxo.txid,
sourceOutputIndex: utxo.vout,
unlockingScriptTemplate: new P2PKH().unlock(privateKey),
sequence: 0xffffffff
})
}
// Calculate total and fee
const totalInput = utxos.reduce((sum, u) => sum + u.satoshis, 0)
const fee = 500 + (utxos.length * 150)
// Single consolidated output
tx.addOutput({
satoshis: totalInput - fee,
lockingScript: new P2PKH().lock(privateKey.toPublicKey().toHash())
})
await tx.sign()
return tx
}
/**
* Consolidate dust UTXOs
*/
async consolidateDust(
privateKey: PrivateKey,
utxos: UTXO[],
dustThreshold: number = 10000
): Promise<Transaction | null> {
const dustUTXOs = utxos.filter(u => u.satoshis < dustThreshold)
if (dustUTXOs.length === 0) {
return null
}
return this.consolidate(privateKey, dustUTXOs, 0)
}
/**
* Smart consolidation - balance UTXO count and sizes
*/
async smartConsolidate(
privateKey: PrivateKey,
utxos: UTXO[],
targetCount: number = 10,
targetSize: number = 100000
): Promise<Transaction[]> {
const transactions: Transaction[] = []
const sorted = [...utxos].sort((a, b) => a.satoshis - b.satoshis)
while (sorted.length > targetCount) {
// Take smallest UTXOs
const batch = sorted.splice(0, Math.min(100, sorted.length - targetCount + 1))
const tx = new Transaction()
for (const utxo of batch) {
tx.addInput({
sourceTXID: utxo.txid,
sourceOutputIndex: utxo.vout,
unlockingScriptTemplate: new P2PKH().unlock(privateKey),
sequence: 0xffffffff
})
}
const totalInput = batch.reduce((sum, u) => sum + u.satoshis, 0)
const fee = 500 + (batch.length * 150)
tx.addOutput({
satoshis: totalInput - fee,
lockingScript: new P2PKH().lock(privateKey.toPublicKey().toHash())
})
await tx.sign()
transactions.push(tx)
// Add consolidated UTXO back to sorted list
sorted.push({
txid: tx.id('hex'),
vout: 0,
satoshis: totalInput - fee,
script: tx.outputs[0].lockingScript
})
sorted.sort((a, b) => a.satoshis - b.satoshis)
}
return transactions
}
}Related Examples
See Also
SDK Components:
Learning Paths:
Last updated
