OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib / utxo-manager.cjs

158 lines · javascript ref:
/**
 * UTXO Manager - Phase 6.3
 *
 * Manages Unspent Transaction Outputs (UTXOs) for Bitcoin wallet.
 * Tracks UTXOs in Autobase for multi-device synchronization.
 */

const b4a = require('b4a')

/**
 * Create UTXO key for Autobase storage
 * Format: utxo:{chain}:{txid}:{vout}
 */
function createUTXOKey(chain, txid, vout) {
  return `utxo:${chain}:${txid}:${vout}`
}

/**
 * Store UTXO in Autobase
 * @param {Object} utxo - UTXO data from blockchain
 * @param {string} chain - Chain identifier (bitcoin, ethereum)
 * @param {Function} storeUTXOFn - Backend storeUTXO function
 */
async function storeUTXO(utxo, chain, storeUTXOFn) {
  const utxoData = {
    chain,
    txid: utxo.txid,
    vout: utxo.vout,
    address: utxo.address,
    amount: utxo.value, // satoshis
    scriptPubKey: utxo.scriptPubKey || null,
    confirmations: utxo.confirmations || 0,
    blockHeight: utxo.blockHeight || null,
    spendable: true,
    spent: false,
    spentTxid: null,
    timestamp: Date.now()
  }

  await storeUTXOFn(utxoData)
  return utxoData
}

/**
 * Mark UTXO as spent
 * @param {string} txid - Transaction ID
 * @param {number} vout - Output index
 * @param {string} spentTxid - Transaction that spent this UTXO
 * @param {string} chain - Chain identifier
 * @param {Function} updateUTXOFn - Backend updateUTXO function
 */
async function markUTXOSpent(txid, vout, spentTxid, chain, updateUTXOFn) {
  await updateUTXOFn(chain, txid, vout, {
    spent: true,
    spendable: false,
    spentTxid,
    spentAt: Date.now()
  })
}

/**
 * Calculate balance from UTXOs
 * @param {Array} utxos - Array of UTXO objects
 * @returns {Object} Balance breakdown
 */
function calculateBalance(utxos) {
  const confirmed = utxos
    .filter(u => u.confirmations >= 6 && !u.spent && u.spendable)
    .reduce((sum, u) => sum + u.amount, 0)

  const unconfirmed = utxos
    .filter(u => u.confirmations > 0 && u.confirmations < 6 && !u.spent && u.spendable)
    .reduce((sum, u) => sum + u.amount, 0)

  const pending = utxos
    .filter(u => u.confirmations === 0 && !u.spent && u.spendable)
    .reduce((sum, u) => sum + u.amount, 0)

  return {
    confirmed,      // 6+ confirmations
    unconfirmed,    // 1-5 confirmations
    pending,        // 0 confirmations
    total: confirmed + unconfirmed + pending,
    spendable: confirmed + unconfirmed // Don't include pending in spendable
  }
}

/**
 * Get spendable UTXOs (confirmed + unconfirmed, not spent)
 * @param {Array} utxos - Array of UTXO objects
 * @param {boolean} confirmedOnly - Only return confirmed UTXOs (6+ confirmations)
 * @returns {Array} Spendable UTXOs sorted by amount (largest first)
 */
function getSpendableUTXOs(utxos, confirmedOnly = false) {
  const minConfirmations = confirmedOnly ? 6 : 1

  return utxos
    .filter(u =>
      u.confirmations >= minConfirmations &&
      !u.spent &&
      u.spendable
    )
    .sort((a, b) => b.amount - a.amount) // Largest first for coin selection
}

/**
 * Format satoshis to BTC display
 * @param {number} satoshis - Amount in satoshis
 * @returns {string} Formatted BTC amount
 */
function formatBTC(satoshis) {
  const btc = (satoshis / 100000000).toFixed(8)
  return btc.replace(/\.?0+$/, '') || '0' // Remove trailing zeros
}

/**
 * Format balance display
 * @param {Object} balance - Balance object from calculateBalance
 * @param {string} symbol - Currency symbol (BTC, ETH, etc.)
 * @returns {string} Formatted balance string
 */
function formatBalance(balance, symbol = 'BTC') {
  const lines = []

  if (balance.confirmed > 0) {
    lines.push(`Confirmed:   ${formatBTC(balance.confirmed)} ${symbol} (${balance.confirmed.toLocaleString()} sats)`)
  }

  if (balance.unconfirmed > 0) {
    lines.push(`Unconfirmed: ${formatBTC(balance.unconfirmed)} ${symbol} (${balance.unconfirmed.toLocaleString()} sats)`)
  }

  if (balance.pending > 0) {
    lines.push(`Pending:     ${formatBTC(balance.pending)} ${symbol} (${balance.pending.toLocaleString()} sats)`)
  }

  if (lines.length === 0) {
    return `Balance:     0 ${symbol}`
  }

  if (balance.total !== balance.confirmed) {
    lines.push(`─────────────────────────────────────`)
    lines.push(`Total:       ${formatBTC(balance.total)} ${symbol} (${balance.total.toLocaleString()} sats)`)
  }

  return lines.join('\n')
}

module.exports = {
  createUTXOKey,
  storeUTXO,
  markUTXOSpent,
  calculateBalance,
  getSpendableUTXOs,
  formatBTC,
  formatBalance
}