Optimize and Consolidate UTXO

Overview

Over time, wallets that receive frequent small payments accumulate a large number of low-value UTXOs. Transactions that consume many inputs are larger in size and therefore cost more in fees. UTXO optimization addresses this by consolidating multiple small UTXOs into fewer, larger outputs during periods of low network fees reducing the cost and complexity of future transactions.

UTXO Consolidation Before 50 sats 100 sats 75 sats 200 sats 150 sats Consolidation TransactionFactory After 500 sats - fees

Why Optimize UTXOs?

Unoptimized UTXO sets lead to several problems that degrade wallet performance and increase costs:

  • Many small UTXOs: Transactions with numerous inputs are larger in size, resulting in higher fees.
  • Dust UTXOs: Outputs below or near the dust threshold (330 satoshis) may be uneconomical or impossible to spend.
  • Fragmented balance: A scattered UTXO set complicates transaction construction and makes accurate fee estimation more difficult.
  • Chain limit: OP_NET enforces a maximum of 25 unconfirmed transaction descendants, which is reached faster when consolidation requires multiple chained operations.

When to Optimize

Use the following indicators to determine when UTXO optimization is needed:

Scenario Action
Many dust UTXOs Consolidate (low priority)
Many small UTXOs (>10) Consolidate when fees low
Single large UTXO Split before batch operations
High UTXO count (>20) Consolidate to reduce future fees
Low fee environment Good time to consolidate
Need parallel transactions Split into multiple UTXOs

UTXO Analysis

Before optimizing, analyze your wallet's UTXO distribution to understand the current state and identify opportunities for consolidation. Examining the number of UTXOs, their individual values, and the proportion of dust outputs gives a clear picture of how fragmented the wallet is and how much fee savings consolidation could provide.

Create the Distribution

typescript
Analyze UTXO Distribution
import { networks } from '@btc-vision/bitcoin';
import { Wallet } from '@btc-vision/transaction';
import { JSONRpcProvider } from 'opnet';

interface UTXOAnalysis {
    total: bigint;
    count: number;
    dust: number;
    small: number;
    medium: number;
    large: number;
    dustValue: bigint;
}

async function analyzeUTXOs(
    provider: JSONRpcProvider,
    address: string,
): Promise<UTXOAnalysis> {
    const utxos = await provider.utxoManager.getUTXOs({
        address,
        optimize: false,
        filterSpentUTXOs: true,
    });

    let total = 0n;
    let dust = 0;
    let small = 0;
    let medium = 0;
    let large = 0;
    let dustValue = 0n;

    for (const utxo of utxos) {
        total += utxo.value;

        if (utxo.value < 546n) {
            dust++;
            dustValue += utxo.value;
        } else if (utxo.value < 10000n) {
            small++;
        } else if (utxo.value < 100000n) {
            medium++;
        } else {
            large++;
        }
    }

    return {
        total,
        count: utxos.length,
        dust,
        small,
        medium,
        large,
        dustValue,
    };
}

// Usage
const analysis = await analyzeUTXOs(provider, wallet.p2tr);
console.log('UTXO Analysis:');
console.log(`  Total: ${analysis.total} sats across ${analysis.count} UTXOs`);
console.log(`  Dust (<546 sats): ${analysis.dust}`);
console.log(`  Small (546-10k): ${analysis.small}`);
console.log(`  Medium (10k-100k): ${analysis.medium}`);
console.log(`  Large (>100k): ${analysis.large}`);

        

Analyze it

typescript
Should consolidate?
function shouldConsolidate(analysis: UTXOAnalysis): boolean {
    // Consolidate if many small UTXOs
    if (analysis.small + analysis.dust > 10) {
        return true;
    }

    // Consolidate if total count is high
    if (analysis.count > 20) {
        return true;
    }

    // Consolidate if significant dust value
    if (analysis.dustValue > 10000n) {
        return true;
    }

    return false;
}
        

UTXO Consolidation

Basic Consolidation

The simplest consolidation approach is to merge multiple UTXOs into a single output by sending the full balance back to your own address using TransactionFactory.createBTCTransfer(). This consumes all selected inputs and produces one clean output, minus the transaction fee.

Reminder

Perform consolidation during periods of low network fees to minimize the cost.

typescript
Basic consolidation
import { networks, Network } from '@btc-vision/bitcoin';
import {
    IFundingTransactionParameters,
    TransactionFactory,
    Wallet,
} from '@btc-vision/transaction';
import { JSONRpcProvider } from 'opnet';

const factory = new TransactionFactory();

async function consolidateUTXOs(
    wallet: Wallet,
    provider: JSONRpcProvider,
    network: Network,
    maxUTXOs: number = 100,
    feeRate: number = 5,
): Promise<string | null> {
    // Get all UTXOs
    const utxos = await provider.utxoManager.getUTXOs({
        address: wallet.p2tr,
        optimize: false,
        mergePendingUTXOs: false,
        filterSpentUTXOs: true,
    });

    if (utxos.length <= 1) {
        console.log('Nothing to consolidate');
        return null;
    }

    // Limit number of UTXOs to consolidate
    const selectedUTXOs = utxos.slice(0, maxUTXOs);
    const totalValue = selectedUTXOs.reduce((sum, u) => sum + u.value, 0n);

    console.log(`Consolidating ${selectedUTXOs.length} UTXOs with total ${totalValue} sats`);

    // Build consolidation transaction
    const params: IFundingTransactionParameters = {
        amount: totalValue - 1000n,  // Reserve for fees
        feeRate: feeRate,
        from: wallet.p2tr,
        to: wallet.p2tr,  // Send back to self
        utxos: selectedUTXOs,
        signer: wallet.keypair,
        network: network,
        priorityFee: 0n,
        gasSatFee: 0n,
    };

    const result = await factory.createBTCTransfer(params);

    console.log(`Transaction size: ${result.tx.length / 2} bytes`);
    console.log(`Estimated fees: ${result.estimatedFees} sats`);

    // Broadcast
    const broadcast = await provider.sendRawTransaction(result.tx, false);

    if (!broadcast || broadcast.error) {
        throw new Error(`Broadcast failed: ${broadcast?.error}`);
    }

    // Track UTXO changes
    provider.utxoManager.spentUTXO(wallet.p2tr, result.inputUtxos, result.nextUTXOs);

    console.log(`Consolidated ${selectedUTXOs.length} UTXOs into 1`);

    return broadcast.result;
}

// Usage
const txId = await consolidateUTXOs(wallet, provider, network, 50, 5);
console.log('Consolidation TX:', txId);
        

Selective Consolidation

Rather than merging all UTXOs, you can selectively consolidate only those below a certain satoshi threshold. This targets the problematic small outputs while leaving larger UTXOs untouched, reducing fees compared to a full consolidation. Filter your UTXO set by value before passing them to TransactionFactory.createBTCTransfer().

typescript
Selective consolidation
async function consolidateSmallUTXOs(
    wallet: Wallet,
    provider: JSONRpcProvider,
    network: Network,
    threshold: bigint = 10000n,
    feeRate: number = 5,
): Promise<string | null> {
    const utxos = await provider.utxoManager.getUTXOs({
        address: wallet.p2tr,
        optimize: false,
        filterSpentUTXOs: true,
    });

    // Filter small UTXOs
    const smallUtxos = utxos.filter((u) => u.value < threshold);

    if (smallUtxos.length < 2) {
        console.log('Not enough small UTXOs to consolidate');
        return null;
    }

    const totalValue = smallUtxos.reduce((sum, u) => sum + u.value, 0n);

    // Check if consolidation is profitable
    // Rough estimate: 58 vB per input, need to cover fees
    const estimatedFee = BigInt(smallUtxos.length * 58 * feeRate);
    if (totalValue <= estimatedFee * 2n) {
        console.log('Consolidation not profitable - fee exceeds value');
        return null;
    }

    const params: IFundingTransactionParameters = {
        amount: totalValue - estimatedFee,
        feeRate: feeRate,
        from: wallet.p2tr,
        to: wallet.p2tr,
        utxos: smallUtxos,
        signer: wallet.keypair,
        network: network,
        priorityFee: 0n,
        gasSatFee: 0n,
    };

    const result = await factory.createBTCTransfer(params);
    const broadcast = await provider.sendRawTransaction(result.tx, false);

    if (!broadcast || broadcast.error) {
        throw new Error(`Broadcast failed: ${broadcast?.error}`);
    }

    provider.utxoManager.spentUTXO(wallet.p2tr, result.inputUtxos, result.nextUTXOs);

    console.log(`Consolidated ${smallUtxos.length} small UTXOs`);

    return broadcast.result;
}
        

Consolidation with Message

You can attach an OP_RETURN note to your consolidation transaction to tag it with a memo or identifier for record-keeping purposes. Pass the desired message through the note field when building the transaction.

typescript
Consolidation with message
async function consolidateWithMessage(
    wallet: Wallet,
    provider: JSONRpcProvider,
    network: Network,
    message: string,
    feeRate: number = 10,
): Promise<string | null> {
    const utxos = await provider.utxoManager.getUTXOs({
        address: wallet.p2tr,
        optimize: false,
        filterSpentUTXOs: true,
    });

    if (utxos.length <= 1) {
        return null;
    }

    const totalValue = utxos.reduce((sum, u) => sum + u.value, 0n);

    const params: IFundingTransactionParameters = {
        amount: totalValue - 2000n,
        feeRate: feeRate,
        from: wallet.p2tr,
        to: wallet.p2tr,
        utxos: utxos,
        signer: wallet.keypair,
        network: network,
        priorityFee: 0n,
        gasSatFee: 0n,
        note: message,  // Add OP_RETURN message
    };

    const result = await factory.createBTCTransfer(params);
    const broadcast = await provider.sendRawTransaction(result.tx, false);

    if (!broadcast || broadcast.error) {
        throw new Error(`Broadcast failed: ${broadcast?.error}`);
    }

    return broadcast.result;
}

// Usage
const txId = await consolidateWithMessage(
    wallet,
    provider,
    network,
    'UTXO consolidation',
    10,
);
        

UTXO Splitting

Split Large UTXO

To split a large UTXO into multiple smaller outputs, use the splitInputsInto property when building the transaction. This is useful when you need independent UTXOs for parallel transaction sending or want to pre-partition funds across multiple operations without creating separate transfers.

typescript
Split large UTXO
import { BitcoinUtils } from 'opnet';

async function splitUTXO(
    wallet: Wallet,
    provider: JSONRpcProvider,
    network: Network,
    splitCount: number = 5,
    feeRate: number = 5,
): Promise<string> {
    // Get UTXOs sorted by value (largest first when optimized)
    const utxos = await provider.utxoManager.getUTXOs({
        address: wallet.p2tr,
        optimize: true,
        filterSpentUTXOs: true,
    });

    if (utxos.length === 0) {
        throw new Error('No UTXOs available');
    }

    // Use the largest UTXO
    const largestUtxo = utxos[0];

    // Ensure enough value for split outputs + fees
    const minRequired = BigInt(splitCount) * 546n + 10000n;
    if (largestUtxo.value < minRequired) {
        throw new Error(`UTXO too small to split into ${splitCount} outputs`);
    }

    // Calculate amount to split (leave room for fees)
    const amountToSplit = largestUtxo.value - 5000n;

    const params: IFundingTransactionParameters = {
        amount: amountToSplit,
        feeRate: feeRate,
        from: wallet.p2tr,
        to: wallet.p2tr,
        utxos: [largestUtxo],
        signer: wallet.keypair,
        network: network,
        priorityFee: 0n,
        gasSatFee: 0n,
        splitInputsInto: splitCount,  // Split into this many outputs
    };

    const result = await factory.createBTCTransfer(params);
    const broadcast = await provider.sendRawTransaction(result.tx, false);

    if (!broadcast || broadcast.error) {
        throw new Error(`Broadcast failed: ${broadcast?.error}`);
    }

    provider.utxoManager.spentUTXO(wallet.p2tr, result.inputUtxos, result.nextUTXOs);

    console.log(`Split 1 UTXO into ${splitCount} outputs`);

    return broadcast.result;
}

// Usage - split into 10 UTXOs for parallel operations
const txId = await splitUTXO(wallet, provider, network, 10, 5);
console.log('Split TX:', txId);
        

Split for Batch Operations

When you need to send multiple transactions in parallel, each one requires its own input UTXO. Split a large UTXO into the required number of outputs ahead of time using splitInputsInto, so that each parallel transaction can draw from an independent input without conflicting.

typescript
Split large UTXO
async function prepareForBatchOperations(
    wallet: Wallet,
    provider: JSONRpcProvider,
    network: Network,
    operationsNeeded: number,
    feeRate: number = 5,
): Promise<void> {
    const utxos = await provider.utxoManager.getUTXOs({
        address: wallet.p2tr,
        optimize: true,
        filterSpentUTXOs: true,
    });

    if (utxos.length >= operationsNeeded) {
        console.log(`Already have ${utxos.length} UTXOs, sufficient for ${operationsNeeded} operations`);
        return;
    }

    const needed = operationsNeeded - utxos.length + 1;
    console.log(`Need to create ${needed} more UTXOs`);

    // Get total available value
    const totalValue = utxos.reduce((sum, u) => sum + u.value, 0n);
    const amountToSplit = totalValue - 10000n;

    const params: IFundingTransactionParameters = {
        amount: amountToSplit,
        feeRate: feeRate,
        from: wallet.p2tr,
        to: wallet.p2tr,
        utxos: utxos,
        signer: wallet.keypair,
        network: network,
        priorityFee: 0n,
        gasSatFee: 0n,
        splitInputsInto: needed,
    };

    const result = await factory.createBTCTransfer(params);
    const broadcast = await provider.sendRawTransaction(result.tx, false);

    if (!broadcast || broadcast.error) {
        throw new Error(`Broadcast failed: ${broadcast?.error}`);
    }

    console.log(`Created ${needed} UTXOs for batch operations`);
}

// Usage - prepare for 20 parallel transactions
await prepareForBatchOperations(wallet, provider, network, 20);
        

Complete Optimizer Service Example

typescript
A full-featured UTXO optimization service
import { networks, Network } from '@btc-vision/bitcoin';
import {
    IFundingTransactionParameters,
    TransactionFactory,
    Wallet,
} from '@btc-vision/transaction';
import { JSONRpcProvider } from 'opnet';

interface UTXOAnalysis {
    total: bigint;
    count: number;
    dust: number;
    small: number;
    medium: number;
    large: number;
    dustValue: bigint;
}

class UTXOOptimizer {
    private readonly factory = new TransactionFactory();

    constructor(
        private readonly provider: JSONRpcProvider,
        private readonly wallet: Wallet,
        private readonly network: Network,
    ) {}

    async analyze(): Promise<UTXOAnalysis> {
        const utxos = await this.provider.utxoManager.getUTXOs({
            address: this.wallet.p2tr,
            optimize: false,
            filterSpentUTXOs: true,
        });

        let total = 0n;
        let dust = 0;
        let small = 0;
        let medium = 0;
        let large = 0;
        let dustValue = 0n;

        for (const utxo of utxos) {
            total += utxo.value;

            if (utxo.value < 546n) {
                dust++;
                dustValue += utxo.value;
            } else if (utxo.value < 10000n) {
                small++;
            } else if (utxo.value < 100000n) {
                medium++;
            } else {
                large++;
            }
        }

        return { total, count: utxos.length, dust, small, medium, large, dustValue };
    }

    async getRecommendation(): Promise<string> {
        const analysis = await this.analyze();

        if (analysis.count === 0) {
            return 'No UTXOs found';
        }

        if (analysis.count === 1) {
            if (analysis.total > 1_000_000n) {
                return 'Consider splitting for parallel transactions';
            }
            return 'Single UTXO - optimal';
        }

        if (analysis.dust > 0) {
            return `Consolidate ${analysis.dust} dust UTXOs to reclaim ${analysis.dustValue} sats`;
        }

        if (analysis.small > 10) {
            return `Consolidate ${analysis.small} small UTXOs to reduce future fees`;
        }

        if (analysis.count > 20) {
            return `Consolidate to reduce UTXO count from ${analysis.count}`;
        }

        return 'UTXO set is healthy';
    }

    async consolidate(
        maxUTXOs: number = 100,
        feeRate: number = 5,
    ): Promise<string | null> {
        const utxos = await this.provider.utxoManager.getUTXOs({
            address: this.wallet.p2tr,
            optimize: false,
            filterSpentUTXOs: true,
        });

        if (utxos.length <= 1) {
            console.log('Nothing to consolidate');
            return null;
        }

        const selectedUTXOs = utxos.slice(0, maxUTXOs);
        const totalValue = selectedUTXOs.reduce((sum, u) => sum + u.value, 0n);

        const params: IFundingTransactionParameters = {
            amount: totalValue - 1000n,
            feeRate: feeRate,
            from: this.wallet.p2tr,
            to: this.wallet.p2tr,
            utxos: selectedUTXOs,
            signer: this.wallet.keypair,
            network: this.network,
            priorityFee: 0n,
            gasSatFee: 0n,
        };

        const result = await this.factory.createBTCTransfer(params);
        const broadcast = await this.provider.sendRawTransaction(result.tx, false);

        if (!broadcast || broadcast.error) {
            throw new Error(`Broadcast failed: ${broadcast?.error}`);
        }

        this.provider.utxoManager.spentUTXO(
            this.wallet.p2tr,
            result.inputUtxos,
            result.nextUTXOs,
        );

        return broadcast.result;
    }

    async split(
        splitCount: number,
        feeRate: number = 5,
    ): Promise<string> {
        const utxos = await this.provider.utxoManager.getUTXOs({
            address: this.wallet.p2tr,
            optimize: true,
            filterSpentUTXOs: true,
        });

        if (utxos.length === 0) {
            throw new Error('No UTXOs available');
        }

        const totalValue = utxos.reduce((sum, u) => sum + u.value, 0n);

        const params: IFundingTransactionParameters = {
            amount: totalValue - 10000n,
            feeRate: feeRate,
            from: this.wallet.p2tr,
            to: this.wallet.p2tr,
            utxos: utxos,
            signer: this.wallet.keypair,
            network: this.network,
            priorityFee: 0n,
            gasSatFee: 0n,
            splitInputsInto: splitCount,
        };

        const result = await this.factory.createBTCTransfer(params);
        const broadcast = await this.provider.sendRawTransaction(result.tx, false);

        if (!broadcast || broadcast.error) {
            throw new Error(`Broadcast failed: ${broadcast?.error}`);
        }

        return broadcast.result;
    }

    async autoOptimize(feeRate: number = 5): Promise<string | null> {
        const analysis = await this.analyze();

        // Too many UTXOs - consolidate
        if (analysis.count > 20 || analysis.small + analysis.dust > 10) {
            console.log('Auto-optimizing: consolidating UTXOs');
            return this.consolidate(100, feeRate);
        }

        console.log('No optimization needed');
        return null;
    }

    async prepareForBatch(
        operationCount: number,
        feeRate: number = 5,
    ): Promise<string | null> {
        const utxos = await this.provider.utxoManager.getUTXOs({
            address: this.wallet.p2tr,
            filterSpentUTXOs: true,
        });

        if (utxos.length >= operationCount) {
            console.log('Sufficient UTXOs for batch operations');
            return null;
        }

        const needed = operationCount - utxos.length + 1;
        console.log(`Creating ${needed} more UTXOs for batch operations`);

        return this.split(needed, feeRate);
    }
}

// Usage
const network = networks.regtest;
const provider = new JSONRpcProvider({ url: 'https://regtest.opnet.org', network });
const mnemonic = new Mnemonic(
    'your twenty four word seed phrase goes here ...',
    '',
    network,
    MLDSASecurityLevel.LEVEL2,
);
const wallet = mnemonic.deriveUnisat(AddressTypes.P2TR, 0);

const optimizer = new UTXOOptimizer(provider, wallet, network);

// Analyze current state
const analysis = await optimizer.analyze();
console.log('Current UTXO state:', analysis);

// Get recommendation
const recommendation = await optimizer.getRecommendation();
console.log('Recommendation:', recommendation);

// Auto-optimize if needed
const txId = await optimizer.autoOptimize(5);
if (txId) {
    console.log('Optimized in TX:', txId);
}

// Prepare for 10 parallel transactions
await optimizer.prepareForBatch(10);