/** * Client Bitcoin RPC pour le dashboard * * Gère la connexion et les appels RPC vers le nœud Bitcoin Signet */ import Client from 'bitcoin-core'; import { logger } from './logger.js'; import { Buffer } from 'buffer'; import { getDatabase } from './database.js'; class BitcoinRPC { constructor() { this.client = new Client({ host: process.env.BITCOIN_RPC_HOST || 'localhost', port: parseInt(process.env.BITCOIN_RPC_PORT || '38332'), username: process.env.BITCOIN_RPC_USER || 'bitcoin', password: process.env.BITCOIN_RPC_PASSWORD || 'bitcoin', timeout: parseInt(process.env.BITCOIN_RPC_TIMEOUT || '30000'), }); } /** * Obtient les informations sur la blockchain * @returns {Promise} Informations sur la blockchain */ async getBlockchainInfo() { try { return await this.client.getBlockchainInfo(); } catch (error) { logger.error('Error getting blockchain info', { error: error.message }); throw new Error(`Failed to get blockchain info: ${error.message}`); } } /** * Obtient le dernier bloc miné * @returns {Promise} Informations sur le dernier bloc */ async getLatestBlock() { try { const blockchainInfo = await this.client.getBlockchainInfo(); const bestBlockHash = blockchainInfo.bestblockhash; const block = await this.client.getBlock(bestBlockHash, 2); // Verbose level 2 return { hash: block.hash, height: block.height, time: block.time, mediantime: block.mediantime, tx_count: block.tx ? block.tx.length : 0, size: block.size, weight: block.weight, }; } catch (error) { logger.error('Error getting latest block', { error: error.message }); throw new Error(`Failed to get latest block: ${error.message}`); } } /** * Obtient le solde du wallet (mature et immature) * @returns {Promise} Solde du wallet */ async getWalletBalance() { try { // Utiliser command() pour appeler directement getbalances() qui retourne mature, immature, et unconfirmed // Si getbalances() n'est pas disponible, utiliser getBalance() avec différents minconf let balances; try { // Utiliser command() pour appeler getbalances() directement (méthode RPC de Bitcoin Core) balances = await this.client.command('getbalances'); // getbalances() retourne { "mine": { "trusted": ..., "untrusted_pending": ..., "immature": ... } } const mine = balances.mine || {}; return { mature: Math.round((mine.trusted || 0)), immature: Math.round((mine.immature || 0)), unconfirmed: mine.untrusted_pending || 0, total: (mine.trusted || 0) + (mine.immature || 0) + (mine.untrusted_pending || 0), }; } catch (error) { // Fallback si getbalances() n'est pas disponible logger.debug('getbalances() not available, using getBalance()', { error: error.message }); // getBalance() retourne le solde mature (confirmé avec au moins 1 confirmation) const balance = await this.client.getBalance(); // getBalance avec minconf=0 retourne le solde total (mature + immature) const totalBalance = await this.client.getBalance('*', 0); // Calculer le solde immature const immatureBalance = Math.max(0, totalBalance - balance); // Obtenir les transactions non confirmées depuis listUnspent let unconfirmedBalance = 0; try { const unspent = await this.client.listUnspent(0); // 0 = inclure les non confirmés for (const utxo of unspent) { if (utxo.confirmations === 0) { unconfirmedBalance += utxo.amount; } } } catch (error) { // Si listUnspent échoue, unconfirmedBalance reste à 0 logger.debug('Could not get unconfirmed balance', { error: error.message }); } return { mature: Math.round(balance), immature: Math.round(immatureBalance), unconfirmed: unconfirmedBalance, total: totalBalance + unconfirmedBalance, }; } } catch (error) { logger.error('Error getting wallet balance', { error: error.message }); throw new Error(`Failed to get wallet balance: ${error.message}`); } } /** * Obtient le nombre de pairs connectés * @returns {Promise} Informations sur les pairs */ async getNetworkPeers() { try { const networkInfo = await this.client.getNetworkInfo(); const peerInfo = await this.client.getPeerInfo(); return { connections: networkInfo.connections, peers: peerInfo.map(peer => ({ addr: peer.addr, services: peer.services, version: peer.version, subver: peer.subver, })), }; } catch (error) { logger.error('Error getting network peers', { error: error.message }); throw new Error(`Failed to get network peers: ${error.message}`); } } /** * Obtient la liste des hash ancrés avec leurs transactions * Lit depuis la base de données et ne complète que les nouveaux blocs si nécessaire * @returns {Promise>} Liste des hash avec leurs transactions */ async getHashList() { try { const db = getDatabase(); const hashList = []; // Lire depuis la base de données const anchors = db.prepare('SELECT hash, txid, block_height, confirmations, date FROM anchors ORDER BY block_height ASC, id ASC').all(); for (const anchor of anchors) { hashList.push({ hash: anchor.hash, txid: anchor.txid, blockHeight: anchor.block_height, confirmations: anchor.confirmations || 0, date: anchor.date || new Date().toISOString(), }); } logger.debug('Hash list loaded from database', { count: hashList.length }); // Vérifier s'il y a de nouveaux blocs à compléter (un seul appel RPC minimal) let needsUpdate = false; let startHeight = 0; let currentHeight = 0; let currentBlockHash = ''; // Un seul appel RPC pour obtenir la hauteur actuelle try { const blockchainInfo = await this.client.getBlockchainInfo(); currentHeight = blockchainInfo.blocks; currentBlockHash = blockchainInfo.bestblockhash; } catch (error) { logger.warn('Error getting blockchain info', { error: error.message }); // Si on ne peut pas obtenir la hauteur, retourner la liste telle quelle return hashList; } // Vérifier le cache dans la base de données const cacheRow = db.prepare('SELECT value FROM cache WHERE key = ?').get('hash_list_cache'); if (cacheRow) { try { const parts = cacheRow.value.split(';'); if (parts.length >= 2) { const cachedHeight = parseInt(parts[1], 10); startHeight = cachedHeight + 1; if (startHeight <= currentHeight) { needsUpdate = true; logger.info('New blocks detected, updating hash list', { startHeight, currentHeight, newBlocks: currentHeight - startHeight + 1, }); } else { // Mettre à jour les confirmations seulement dans la base de données const updateConfirmations = db.prepare(` UPDATE anchors SET confirmations = ?, updated_at = CURRENT_TIMESTAMP WHERE block_height IS NOT NULL `); updateConfirmations.run(Math.max(0, currentHeight - (cachedHeight || 0) + 1)); logger.debug('Hash list up to date, confirmations updated in database', { count: hashList.length }); } } else { startHeight = 0; needsUpdate = true; } } catch (error) { logger.warn('Error reading hash list cache from database', { error: error.message }); startHeight = 0; needsUpdate = true; } } else { // Pas de cache, initialiser depuis le début startHeight = 0; needsUpdate = true; logger.info('No cache found, initializing hash list', { currentHeight }); } // Compléter seulement les nouveaux blocs si nécessaire if (needsUpdate && startHeight <= currentHeight) { const insertAnchor = db.prepare(` INSERT OR IGNORE INTO anchors (hash, txid, block_height, confirmations, date, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); logger.info('Collecting hash list from block', { startHeight, currentHeight }); for (let height = startHeight; height <= currentHeight; height++) { try { const blockHash = await this.client.getBlockHash(height); const block = await this.client.getBlock(blockHash, 2); if (block.tx) { for (const tx of block.tx) { try { const rawTx = await this.client.getRawTransaction(tx.txid, true); // Vérifier si la transaction contient un OP_RETURN avec "ANCHOR:" for (const output of rawTx.vout || []) { if (output.scriptPubKey && output.scriptPubKey.hex) { const scriptHex = output.scriptPubKey.hex; const anchorPrefix = Buffer.from('ANCHOR:', 'utf8').toString('hex'); if (scriptHex.includes(anchorPrefix)) { // Extraire le hash depuis le script const hashStart = scriptHex.indexOf(anchorPrefix) + anchorPrefix.length; const hashHex = scriptHex.substring(hashStart, hashStart + 64); if (/^[0-9a-fA-F]{64}$/.test(hashHex)) { const confirmations = currentHeight - height + 1; const hash = hashHex.toLowerCase(); const date = new Date().toISOString(); // Insérer dans la base de données insertAnchor.run(hash, tx.txid, height, confirmations, date); // Ajouter à la liste pour le retour hashList.push({ hash, txid: tx.txid, blockHeight: height, confirmations, date, }); } break; // Un seul hash par transaction } } } } catch (error) { logger.debug('Error checking transaction for hash', { txid: tx.txid, error: error.message }); } } } // Mettre à jour le cache tous les 100 blocs if (height % 100 === 0 || height === currentHeight) { const now = new Date().toISOString(); const cacheContent = `${now};${height};${blockHash}`; const updateCache = db.prepare('INSERT OR REPLACE INTO cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'); updateCache.run('hash_list_cache', cacheContent); logger.debug('Hash list cache updated in database', { height, count: hashList.length }); } } catch (error) { logger.debug('Error checking block for hashes', { height, error: error.message }); } } // Mettre à jour le cache final const now = new Date().toISOString(); const cacheContent = `${now};${currentHeight};${currentBlockHash}`; const updateCache = db.prepare('INSERT OR REPLACE INTO cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'); updateCache.run('hash_list_cache', cacheContent); // Recharger depuis la base de données pour retourner les données à jour const updatedAnchors = db.prepare('SELECT hash, txid, block_height, confirmations, date FROM anchors ORDER BY block_height ASC, id ASC').all(); hashList.length = 0; for (const anchor of updatedAnchors) { hashList.push({ hash: anchor.hash, txid: anchor.txid, blockHeight: anchor.block_height, confirmations: anchor.confirmations || 0, date: anchor.date || new Date().toISOString(), }); } logger.info('Hash list saved to database', { currentHeight, count: hashList.length }); } else { // Mettre à jour les confirmations seulement si nécessaire if (currentHeight > 0) { const updateConfirmations = db.prepare(` UPDATE anchors SET confirmations = ?, updated_at = CURRENT_TIMESTAMP WHERE block_height IS NOT NULL `); // Calculer les confirmations pour chaque ancrage const anchorsToUpdate = db.prepare('SELECT id, block_height FROM anchors WHERE block_height IS NOT NULL').all(); const updateStmt = db.prepare('UPDATE anchors SET confirmations = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); for (const anchor of anchorsToUpdate) { const confirmations = Math.max(0, currentHeight - anchor.block_height + 1); updateStmt.run(confirmations, anchor.id); } // Recharger depuis la base de données const updatedAnchors = db.prepare('SELECT hash, txid, block_height, confirmations, date FROM anchors ORDER BY block_height ASC, id ASC').all(); hashList.length = 0; for (const anchor of updatedAnchors) { hashList.push({ hash: anchor.hash, txid: anchor.txid, blockHeight: anchor.block_height, confirmations: anchor.confirmations || 0, date: anchor.date || new Date().toISOString(), }); } } } return hashList; } catch (error) { logger.error('Error getting hash list', { error: error.message }); throw new Error(`Failed to get hash list: ${error.message}`); } } /** * Obtient la liste des UTXO avec leurs montants, catégorisés en 3 types : * - bloc_rewards : UTXO provenant de transactions coinbase (minage) * - ancrages : UTXO provenant de transactions d'ancrage * - changes : UTXO provenant d'autres transactions (monnaie de retour) * Utilise la base de données SQLite pour stocker et récupérer les UTXOs * @returns {Promise} Objet avec 3 listes : blocRewards, anchors, changes */ async getUtxoList() { try { const db = getDatabase(); // Vérifier s'il y a de nouveaux blocs à traiter (un seul appel RPC minimal) let needsUpdate = false; let currentHeight = 0; try { const blockchainInfo = await this.client.getBlockchainInfo(); currentHeight = blockchainInfo.blocks; } catch (error) { logger.warn('Error getting blockchain info', { error: error.message }); } // Vérifier le cache dans la base de données pour déterminer si une mise à jour est nécessaire const cacheRow = db.prepare('SELECT value FROM cache WHERE key = ?').get('utxo_list_cache'); if (cacheRow) { try { const parts = cacheRow.value.split(';'); // Format attendu : ; (2 parties) if (parts.length >= 2) { const cachedHeight = parseInt(parts[1], 10); if (!isNaN(cachedHeight) && cachedHeight >= 0) { if (cachedHeight < currentHeight) { needsUpdate = true; logger.info('New blocks detected, updating UTXO list', { cachedHeight, currentHeight, newBlocks: currentHeight - cachedHeight, }); } else { logger.debug('UTXO list up to date, no RPC call needed', { currentHeight }); } } else { logger.warn('Invalid height in UTXO cache, forcing update'); needsUpdate = true; } } else { logger.warn('Unexpected UTXO cache format, forcing update', { partsCount: parts.length }); needsUpdate = true; } } catch (error) { logger.warn('Error reading UTXO cache from database', { error: error.message }); needsUpdate = true; } } else { needsUpdate = true; logger.info('No UTXO cache found, initializing', { currentHeight }); } // Optimisation mémoire : charger les UTXOs depuis la DB seulement si nécessaire pour la mise à jour const existingUtxosMap = new Map(); // Clé: "txid:vout", Valeur: utxoItem // Obtenir les UTXO depuis le wallet seulement si nécessaire (nouveaux blocs détectés) let unspent = []; if (needsUpdate) { // Charger les UTXOs existants depuis la base de données pour la mise à jour // Optimisation : ne charger que les colonnes nécessaires pour réduire la consommation mémoire const utxosFromDb = db.prepare(` SELECT txid, vout, address, amount, confirmations, category, is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change FROM utxos `).all(); for (const utxo of utxosFromDb) { const utxoKey = `${utxo.txid}:${utxo.vout}`; const utxoItem = { txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, category: utxo.category, isSpentOnchain: utxo.is_spent_onchain === 1, isLockedInMutex: utxo.is_locked_in_mutex === 1, blockHeight: null, blockTime: utxo.block_time, isAnchorChange: utxo.is_anchor_change === 1, }; existingUtxosMap.set(utxoKey, utxoItem); } logger.info('Loaded existing UTXOs from database for update', { count: existingUtxosMap.size }); const walletName = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; const host = process.env.BITCOIN_RPC_HOST || '127.0.0.1'; const port = process.env.BITCOIN_RPC_PORT || '38332'; const username = process.env.BITCOIN_RPC_USER || 'bitcoin'; const password = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin'; const rpcUrl = `http://${host}:${port}/wallet/${walletName}`; const auth = Buffer.from(`${username}:${password}`).toString('base64'); const rpcResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'listunspent', method: 'listunspent', params: [1], // Minimum 1 confirmation to avoid too-long-mempool-chain errors }), }); if (!rpcResponse.ok) { const errorText = await rpcResponse.text(); logger.error('HTTP error in listunspent', { status: rpcResponse.status, statusText: rpcResponse.statusText, response: errorText, }); throw new Error(`HTTP error fetching UTXOs: ${rpcResponse.status} ${rpcResponse.statusText}`); } const rpcResult = await rpcResponse.json(); if (rpcResult.error) { logger.error('RPC error in listunspent', { error: rpcResult.error }); throw new Error(`RPC error: ${rpcResult.error.message}`); } unspent = rpcResult.result || []; logger.debug('UTXO list updated from RPC', { count: unspent.length }); } else { // Pas de nouveaux blocs, utiliser les données de la base de données directement logger.debug('No new blocks, using cached UTXO list from database'); } const blocRewards = []; const anchors = []; const changes = []; const fees = []; // Liste des transactions avec leurs frais onchain // Si pas de mise à jour nécessaire, charger directement depuis la DB sans Map intermédiaire if (!needsUpdate) { // Optimisation mémoire : charger directement depuis la DB et organiser par catégorie // sans créer de Map intermédiaire qui consomme de la mémoire const blocRewards = db.prepare(` SELECT txid, vout, address, amount, confirmations, category, is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change FROM utxos WHERE category = 'bloc_rewards' ORDER BY amount DESC `).all().map(utxo => ({ txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, category: utxo.category, isSpentOnchain: utxo.is_spent_onchain === 1, isLockedInMutex: utxo.is_locked_in_mutex === 1, blockHeight: null, blockTime: utxo.block_time, isAnchorChange: utxo.is_anchor_change === 1, })); const anchors = db.prepare(` SELECT txid, vout, address, amount, confirmations, category, is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change FROM utxos WHERE category = 'ancrages' OR category = 'anchor' ORDER BY amount DESC `).all().map(utxo => ({ txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, category: utxo.category, isSpentOnchain: utxo.is_spent_onchain === 1, isLockedInMutex: utxo.is_locked_in_mutex === 1, blockHeight: null, blockTime: utxo.block_time, isAnchorChange: utxo.is_anchor_change === 1, })); const changes = db.prepare(` SELECT txid, vout, address, amount, confirmations, category, is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change FROM utxos WHERE category = 'changes' OR category = 'change' ORDER BY is_anchor_change DESC, amount DESC `).all().map(utxo => ({ txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, category: utxo.category, isSpentOnchain: utxo.is_spent_onchain === 1, isLockedInMutex: utxo.is_locked_in_mutex === 1, blockHeight: null, blockTime: utxo.block_time, isAnchorChange: utxo.is_anchor_change === 1, })); // Charger les frais depuis la base de données const fees = []; try { // Mettre à jour les confirmations dans la DB si nécessaire if (currentHeight > 0) { // SQLite : utiliser CASE pour calculer les confirmations (MAX(0, x) = CASE WHEN x > 0 THEN x ELSE 0 END) const updateFees = db.prepare(` UPDATE fees SET confirmations = CASE WHEN block_height IS NOT NULL AND block_height <= ? THEN CASE WHEN (? - block_height + 1) > 0 THEN (? - block_height + 1) ELSE 0 END ELSE confirmations END, updated_at = CURRENT_TIMESTAMP WHERE block_height IS NOT NULL AND block_height <= ? `); updateFees.run(currentHeight, currentHeight, currentHeight, currentHeight); } // Optimisation : ne charger que les colonnes nécessaires const feesFromDb = db.prepare(` SELECT txid, fee, fee_sats, block_height, block_time, confirmations, change_address, change_amount FROM fees ORDER BY block_height DESC `).all(); for (const fee of feesFromDb) { fees.push({ txid: fee.txid, fee: fee.fee, fee_sats: fee.fee_sats, blockHeight: fee.block_height, blockTime: fee.block_time, confirmations: fee.confirmations || 0, changeAddress: fee.change_address, changeAmount: fee.change_amount, }); } } catch (error) { logger.warn('Error reading fees from database', { error: error.message }); } // Calculer availableForAnchor const minAnchorAmount = 2000 / 100000000; const availableForAnchor = anchors.filter(u => u.amount >= minAnchorAmount && (u.confirmations || 0) > 0 && !u.isSpentOnchain && !u.isLockedInMutex ).length; const confirmedAvailableForAnchor = anchors.filter(u => u.amount >= minAnchorAmount && (u.confirmations || 0) >= 6 && !u.isSpentOnchain && !u.isLockedInMutex ).length; const total = blocRewards.length + anchors.length + changes.length + fees.length; logger.debug('UTXO list returned from database (no update needed)', { blocRewards: blocRewards.length, anchors: anchors.length, changes: changes.length, fees: fees.length, total, availableForAnchor, }); return { blocRewards, anchors, changes, fees, total, availableForAnchor, confirmedAvailableForAnchor, }; } // Créer un Set des UTXOs actuels pour identifier les nouveaux const currentUtxosSet = new Set(); for (const utxo of unspent) { currentUtxosSet.add(`${utxo.txid}:${utxo.vout}`); } // Identifier les UTXOs à recalculer (nouveaux ou modifiés) const utxosToRecalculate = []; const utxosToKeep = []; for (const utxo of unspent) { const utxoKey = `${utxo.txid}:${utxo.vout}`; const existing = existingUtxosMap.get(utxoKey); // Vérifier si l'UTXO existe et si le montant est identique // Les confirmations peuvent changer (augmenter) mais le montant reste constant if (existing && Math.abs(existing.amount - utxo.amount) < 0.00000001) { // UTXO existant avec montant identique, utiliser les données de la base de données // Les confirmations seront mises à jour plus tard utxosToKeep.push(existing); } else { // Nouvel UTXO ou UTXO modifié (montant différent), doit être recalculé utxosToRecalculate.push(utxo); } } logger.info('UTXO processing', { total: unspent.length, fromFile: utxosToKeep.length, toRecalculate: utxosToRecalculate.length, }); // Récupérer les UTXO verrouillés depuis l'API d'ancrage let lockedUtxos = new Set(); try { const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010'; const anchorApiKey = process.env.ANCHOR_API_KEY || ''; const headers = { 'Content-Type': 'application/json', }; if (anchorApiKey) { headers['x-api-key'] = anchorApiKey; } const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, { method: 'GET', headers, }); if (lockedResponse.ok) { const lockedData = await lockedResponse.json(); for (const locked of lockedData.locked || []) { lockedUtxos.add(`${locked.txid}:${locked.vout}`); } logger.debug('Locked UTXOs retrieved', { count: lockedUtxos.size }); } } catch (error) { logger.debug('Error getting locked UTXOs from anchor API', { error: error.message }); } // Mettre à jour les informations dynamiques pour les UTXOs existants // (isSpentOnchain, isLockedInMutex, confirmations, blockTime si manquant) // Si un UTXO est dans listunspent, il n'est pas dépensé (pas besoin d'appel RPC gettxout) // Récupérer blockTime pour les UTXOs confirmés qui n'en ont pas encore const updateBlockTimePromises = utxosToKeep .filter(utxo => (utxo.confirmations || 0) > 0 && !utxo.blockTime) .map(async (existingUtxo) => { try { const txInfo = await this.client.getTransaction(existingUtxo.txid); existingUtxo.blockHeight = txInfo.blockheight || null; existingUtxo.blockTime = txInfo.blocktime || null; } catch (error) { logger.debug('Error getting transaction block info for existing UTXO', { txid: existingUtxo.txid, error: error.message }); } }); await Promise.all(updateBlockTimePromises); for (const existingUtxo of utxosToKeep) { // Mettre à jour les confirmations depuis listunspent const currentUtxo = unspent.find(u => u.txid === existingUtxo.txid && u.vout === existingUtxo.vout); if (currentUtxo) { existingUtxo.confirmations = currentUtxo.confirmations || 0; existingUtxo.amount = currentUtxo.amount; // Mettre à jour le montant au cas où existingUtxo.isSpentOnchain = false; // Si dans listunspent, il n'est pas dépensé // Vérifier si l'UTXO est verrouillé const utxoKey = `${existingUtxo.txid}:${existingUtxo.vout}`; existingUtxo.isLockedInMutex = lockedUtxos.has(utxoKey); } else { // UTXO n'est plus dans listunspent, il a été dépensé existingUtxo.isSpentOnchain = true; } } // Ajouter les UTXOs existants aux listes appropriées (seulement s'ils ne sont pas dépensés) for (const existingUtxo of utxosToKeep) { // Ne pas ajouter les UTXOs dépensés aux listes actives if (existingUtxo.isSpentOnchain) { continue; } if (existingUtxo.category === 'bloc_rewards') { blocRewards.push(existingUtxo); } else if (existingUtxo.category === 'ancrages') { anchors.push(existingUtxo); } else if (existingUtxo.category === 'changes') { changes.push(existingUtxo); } } // Catégoriser uniquement les nouveaux UTXOs ou ceux modifiés // Traiter en parallèle par batch pour accélérer sans surcharger le serveur RPC const BATCH_SIZE = 10; // Traiter 10 UTXOs en parallèle à la fois for (let i = 0; i < utxosToRecalculate.length; i += BATCH_SIZE) { const batch = utxosToRecalculate.slice(i, i + BATCH_SIZE); const batchPromises = batch.map(async (utxo) => { try { // Obtenir la transaction source pour déterminer sa catégorie const rawTxResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'getrawtransaction', method: 'getrawtransaction', params: [utxo.txid, true], }), }); if (!rawTxResponse.ok) { throw new Error(`HTTP error fetching transaction: ${rawTxResponse.status}`); } const rawTxResult = await rawTxResponse.json(); if (rawTxResult.error) { throw new Error(`RPC error: ${rawTxResult.error.message}`); } const rawTx = rawTxResult.result; // Si l'UTXO est dans listunspent, il n'est pas dépensé (pas besoin de gettxout) const isSpentOnchain = false; // Vérifier si l'UTXO est verrouillé dans le mutex de l'API d'ancrage const utxoKey = `${utxo.txid}:${utxo.vout}`; const isLockedInMutex = lockedUtxos.has(utxoKey); const utxoItem = { txid: utxo.txid, vout: utxo.vout, address: '', // Plus stocké amount: utxo.amount, confirmations: utxo.confirmations || 0, isSpentOnchain, isLockedInMutex, }; // Vérifier si c'est une transaction coinbase (récompense de minage) // Une transaction coinbase doit avoir exactement un input avec le champ coinbase défini const isCoinbase = rawTx.vin && rawTx.vin.length === 1 && rawTx.vin[0].coinbase !== undefined && rawTx.vin[0].coinbase !== null; if (isCoinbase) { // Obtenir la hauteur du bloc et le blocktime si la transaction est confirmée let blockHeight = null; let blockTime = null; if (utxo.confirmations > 0) { try { const txInfo = await this.client.getTransaction(utxo.txid); blockHeight = txInfo.blockheight || null; blockTime = txInfo.blocktime || null; } catch (error) { logger.debug('Error getting transaction block info for coinbase', { txid: utxo.txid, error: error.message }); } } utxoItem.blockHeight = blockHeight; utxoItem.blockTime = blockTime; utxoItem.category = 'bloc_rewards'; return { utxoItem, category: 'bloc_rewards' }; } // Vérifier si c'est une transaction d'ancrage (contient OP_RETURN avec "ANCHOR:") let isAnchorTx = false; let blockHeight = null; // Obtenir la hauteur du bloc et le blocktime si la transaction est confirmée let blockTime = null; if (utxo.confirmations > 0) { try { const txInfo = await this.client.getTransaction(utxo.txid); blockHeight = txInfo.blockheight || null; blockTime = txInfo.blocktime || null; } catch (error) { logger.debug('Error getting transaction block info', { txid: utxo.txid, error: error.message }); } } utxoItem.blockHeight = blockHeight; utxoItem.blockTime = blockTime; // Extraire les métadonnées onchain (change et frais) depuis l'OP_RETURN let onchainChangeAddress = null; let onchainChangeAmount = null; let onchainFeeAmount = null; if (rawTx.vout) { for (const output of rawTx.vout) { if (output.scriptPubKey && output.scriptPubKey.hex) { const scriptHex = output.scriptPubKey.hex; const anchorPrefix = Buffer.from('ANCHOR:', 'utf8').toString('hex'); if (scriptHex.includes(anchorPrefix)) { isAnchorTx = true; // Extraire les métadonnées depuis l'OP_RETURN // Format: "ANCHOR:" + hash (32 bytes) + "|CHANGE:
:|FEE:" try { // Le script hex contient les données encodées // "ANCHOR:" en hex = "414e43484f523a" // Le hash suit (64 caractères hex = 32 bytes) // Puis "|" en hex = "7c" // Puis les métadonnées en UTF-8 const anchorPrefixHex = Buffer.from('ANCHOR:', 'utf8').toString('hex'); const hashLengthHex = 64; // 64 caractères hex pour 32 bytes const separatorHex = Buffer.from('|', 'utf8').toString('hex'); // Trouver la position de "ANCHOR:" dans le script hex const anchorPos = scriptHex.indexOf(anchorPrefixHex); if (anchorPos !== -1) { // Position après "ANCHOR:" + hash (64 caractères hex) const afterHashPos = anchorPos + anchorPrefixHex.length + hashLengthHex; // Chercher le séparateur "|" après le hash const separatorPos = scriptHex.indexOf(separatorHex, afterHashPos); if (separatorPos !== -1) { // Extraire les métadonnées après le séparateur const metadataHex = scriptHex.substring(separatorPos + separatorHex.length); // Convertir de hex à UTF-8 const metadataBuffer = Buffer.from(metadataHex, 'hex'); const metadataString = metadataBuffer.toString('utf8'); // Parser les métadonnées: "CHANGE:
:|FEE:" const parts = metadataString.split('|'); for (const part of parts) { if (part.startsWith('CHANGE:')) { const changeData = part.substring(7); // Enlever "CHANGE:" const changeParts = changeData.split(':'); if (changeParts.length === 2 && changeParts[0] !== 'none') { onchainChangeAddress = changeParts[0]; onchainChangeAmount = parseInt(changeParts[1], 10) / 100000000; // Convertir sats en BTC } } else if (part.startsWith('FEE:')) { const feeData = part.substring(4); // Enlever "FEE:" onchainFeeAmount = parseInt(feeData, 10) / 100000000; // Convertir sats en BTC } } } } } catch (error) { logger.debug('Error parsing OP_RETURN metadata', { txid: utxo.txid, error: error.message }); } break; } } } } if (isAnchorTx) { // Dans une transaction d'ancrage, distinguer les outputs d'ancrage/provisionnement du change // Les transactions d'ancrage créent : // - 1 output OP_RETURN (non dépensable, généralement le premier output) // - 1 output d'ancrage de 2500 sats (0.000025 BTC) // - 7 outputs de provisionnement de 2500 sats chacun // - 1 output de change (seulement si change > 0.00001 BTC = 1000 sats) const utxoAmountSats = Math.round(utxo.amount * 100000000); const anchorAmountSats = 2500; // Montant standard des outputs d'ancrage/provisionnement const minChangeSats = 1000; // Change minimum créé (0.00001 BTC) // Identifier précisément le type d'output en analysant la transaction let isAnchorOutput = false; let isChangeOutput = false; if (rawTx.vout && rawTx.vout[utxo.vout]) { const output = rawTx.vout[utxo.vout]; // Vérifier si c'est un output OP_RETURN (non dépensable) if (output.scriptPubKey && output.scriptPubKey.type === 'nulldata') { // C'est l'OP_RETURN, on l'ignore (non dépensable) return null; // Ne pas ajouter cet UTXO car OP_RETURN n'est pas dépensable } // Si le montant correspond à un output d'ancrage/provisionnement (2500 sats) if (Math.abs(utxoAmountSats - anchorAmountSats) <= 1) { isAnchorOutput = true; } else if (utxoAmountSats >= minChangeSats) { // Le change dans une transaction d'ancrage est > 1000 sats et différent de 2500 sats isChangeOutput = true; } } if (isAnchorOutput) { utxoItem.category = 'ancrages'; return { utxoItem, category: 'ancrages', fee: isAnchorTx && onchainFeeAmount !== null ? { txid: utxo.txid, fee: onchainFeeAmount, fee_sats: Math.round(onchainFeeAmount * 100000000), changeAddress: onchainChangeAddress || null, changeAmount: onchainChangeAmount || null, blockHeight, blockTime, confirmations: utxo.confirmations || 0 } : null }; } else if (isChangeOutput) { // C'est le change de la transaction d'ancrage utxoItem.category = 'changes'; utxoItem.isAnchorChange = true; // Marquer comme change d'une transaction d'ancrage // Ajouter les métadonnées onchain si disponibles if (onchainChangeAddress && onchainChangeAmount !== null) { utxoItem.onchainChangeAddress = onchainChangeAddress; utxoItem.onchainChangeAmount = onchainChangeAmount; } if (onchainFeeAmount !== null) { utxoItem.onchainFeeAmount = onchainFeeAmount; } return { utxoItem, category: 'changes', isAnchorChange: true, fee: isAnchorTx && onchainFeeAmount !== null ? { txid: utxo.txid, fee: onchainFeeAmount, fee_sats: Math.round(onchainFeeAmount * 100000000), changeAddress: onchainChangeAddress || null, changeAmount: onchainChangeAmount || null, blockHeight, blockTime, confirmations: utxo.confirmations || 0 } : null }; } else { // Montant très petit (< 1000 sats), probablement du dust // Classer comme change quand même (peu probable dans une transaction d'ancrage) utxoItem.category = 'changes'; utxoItem.isAnchorChange = true; return { utxoItem, category: 'changes', isAnchorChange: true }; } } else { // Transaction normale (non-ancrage, non-coinbase) = change // Cela inclut toutes les transactions qui ne sont pas des coinbase et qui ne contiennent pas d'OP_RETURN avec "ANCHOR:" // Ces UTXO proviennent de transactions normales (paiements, etc.) utxoItem.category = 'changes'; utxoItem.isAnchorChange = false; // Change d'une transaction normale return { utxoItem, category: 'changes', isAnchorChange: false }; } } catch (error) { // En cas d'erreur, classer comme change par défaut logger.debug('Error categorizing UTXO', { txid: utxo.txid, error: error.message }); const errorUtxoItem = { txid: utxo.txid, vout: utxo.vout, address: '', // Plus stocké amount: utxo.amount, confirmations: utxo.confirmations || 0, category: 'changes', isSpentOnchain: false, isLockedInMutex: false, }; return { utxoItem: errorUtxoItem, category: 'changes', isAnchorChange: false }; } }); const batchResults = await Promise.all(batchPromises); // Traiter les résultats du batch for (const result of batchResults) { if (!result) continue; // OP_RETURN ignoré const { utxoItem, category, isAnchorChange, fee } = result; const utxoKey = `${utxoItem.txid}:${utxoItem.vout}`; existingUtxosMap.set(utxoKey, utxoItem); if (category === 'bloc_rewards') { blocRewards.push(utxoItem); } else if (category === 'ancrages') { anchors.push(utxoItem); if (fee) { const existingFee = fees.find(f => f.txid === fee.txid); if (!existingFee) { fees.push(fee); } } } else if (category === 'changes') { utxoItem.isAnchorChange = isAnchorChange || false; changes.push(utxoItem); if (fee) { const existingFee = fees.find(f => f.txid === fee.txid); if (!existingFee) { fees.push(fee); } } } } } // Vérifier les UTXOs dépensés (ceux qui étaient dans la base de données mais plus dans listunspent) // Mettre à jour TOUS les UTXOs de la base de données, pas seulement ceux en mémoire // Optimisation : utiliser une requête SQL directe au lieu de parcourir tous les UTXOs en mémoire const updateSpentUtxos = db.prepare(` UPDATE utxos SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP WHERE is_spent_onchain = 0 AND (txid || ':' || vout) NOT IN ( SELECT txid || ':' || vout FROM ( SELECT txid, vout FROM utxos WHERE is_spent_onchain = 0 LIMIT 1 ) ) `); // Créer une liste des UTXOs disponibles pour la requête SQL // Utiliser une table temporaire pour éviter de charger tous les UTXOs en mémoire db.exec(` CREATE TEMP TABLE IF NOT EXISTS temp_available_utxos ( txid TEXT, vout INTEGER, PRIMARY KEY (txid, vout) ) `); // Insérer les UTXOs disponibles par batch pour réduire la mémoire const SYNC_BATCH_SIZE = 1000; const insertAvailableUtxo = db.prepare(` INSERT OR IGNORE INTO temp_available_utxos (txid, vout) VALUES (?, ?) `); const insertBatch = db.transaction((utxos) => { for (const utxo of utxos) { insertAvailableUtxo.run(utxo.txid, utxo.vout); } }); // Insérer par batch pour réduire la consommation mémoire for (let i = 0; i < unspent.length; i += SYNC_BATCH_SIZE) { const batch = unspent.slice(i, i + SYNC_BATCH_SIZE); insertBatch(batch); } // Mettre à jour les UTXOs dépensés en une seule requête SQL const updateSpentStmt = db.prepare(` UPDATE utxos SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP WHERE is_spent_onchain = 0 AND (txid || ':' || vout) NOT IN ( SELECT txid || ':' || vout FROM temp_available_utxos ) `); const updateResult = updateSpentStmt.run(); // Nettoyer la table temporaire db.exec('DROP TABLE IF EXISTS temp_available_utxos'); logger.info('UTXOs dépensés mis à jour dans la base de données', { updated: updateResult.changes, }); // Mettre à jour aussi les UTXOs en mémoire pour cohérence for (const [utxoKey, existingUtxo] of existingUtxosMap.entries()) { if (!currentUtxosSet.has(utxoKey)) { // UTXO n'est plus dans listunspent, il a été dépensé existingUtxo.isSpentOnchain = true; // Ne pas l'ajouter aux listes actives (déjà ajouté dans utxosToKeep si présent) } } // Trier chaque catégorie par montant décroissant blocRewards.sort((a, b) => b.amount - a.amount); anchors.sort((a, b) => b.amount - a.amount); // Pour les changes, trier d'abord par type (ancrage en premier), puis par montant décroissant changes.sort((a, b) => { // Les changes d'ancrage en premier if (a.isAnchorChange && !b.isAnchorChange) return -1; if (!a.isAnchorChange && b.isAnchorChange) return 1; // Puis par montant décroissant return b.amount - a.amount; }); // Calculer le nombre d'UTXO disponibles pour l'ancrage (> 2000 sats, confirmés et non dépensés) const allUtxos = [...blocRewards, ...anchors, ...changes]; const minAnchorAmount = 2000 / 100000000; // 2000 sats en BTC const availableForAnchor = allUtxos.filter(utxo => utxo.amount >= minAnchorAmount && (utxo.confirmations || 0) > 0 && // Only confirmed UTXOs !utxo.isSpentOnchain && !utxo.isLockedInMutex ).length; // Compter les UTXOs confirmés disponibles pour l'ancrage const confirmedAvailableForAnchor = allUtxos.filter(utxo => utxo.amount >= minAnchorAmount && (utxo.confirmations || 0) > 0 && // Only confirmed UTXOs !utxo.isSpentOnchain && !utxo.isLockedInMutex ).length; // Mettre à jour le cache dans la base de données const now = new Date().toISOString(); const cacheContent = `${now};${currentHeight}`; const updateCache = db.prepare('INSERT OR REPLACE INTO cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'); updateCache.run('utxo_list_cache', cacheContent); // Mettre à jour la base de données avec tous les UTXOs const insertOrUpdateUtxo = db.prepare(` INSERT INTO utxos (category, txid, vout, amount, confirmations, is_anchor_change, block_time, is_spent_onchain, is_locked_in_mutex, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(txid, vout) DO UPDATE SET category = excluded.category, amount = excluded.amount, confirmations = excluded.confirmations, is_anchor_change = excluded.is_anchor_change, block_time = excluded.block_time, is_spent_onchain = excluded.is_spent_onchain, is_locked_in_mutex = excluded.is_locked_in_mutex, updated_at = CURRENT_TIMESTAMP `); const insertManyUtxos = db.transaction((utxos) => { for (const item of utxos) { insertOrUpdateUtxo.run( item.category, item.txid, item.vout, item.amount, item.confirmations, item.isAnchorChange ? 1 : 0, item.blockTime || null, item.isSpentOnchain ? 1 : 0, item.isLockedInMutex ? 1 : 0 ); } }); insertManyUtxos(Array.from(existingUtxosMap.values())); // Analyser la distribution pour comprendre pourquoi il y a si peu de changes const anchorTxChanges = changes.filter(utxo => { // Vérifier si cet UTXO provient d'une transaction d'ancrage // (on ne peut pas le vérifier directement ici, mais on peut analyser les montants) const utxoAmountSats = Math.round(utxo.amount * 100000000); return utxoAmountSats > 2500; // Change d'une transaction d'ancrage serait > 2500 sats }); const normalTxChanges = changes.length - anchorTxChanges.length; // Trier les frais par blockHeight décroissant (plus récent en premier) fees.sort((a, b) => { if (a.blockHeight === null && b.blockHeight === null) return 0; if (a.blockHeight === null) return 1; if (b.blockHeight === null) return -1; return b.blockHeight - a.blockHeight; }); // Les frais sont déjà chargés depuis la base de données dans la section précédente (ligne ~537-560) // Pas besoin de recharger ici logger.info('UTXO list saved', { blocRewards: blocRewards.length, anchors: anchors.length, changes: changes.length, changesFromAnchorTx: anchorTxChanges.length, changesFromNormalTx: normalTxChanges, fees: fees.length, total: allUtxos.length, availableForAnchor, }); return { blocRewards, anchors, changes, fees, total: allUtxos.length, availableForAnchor, confirmedAvailableForAnchor, }; } catch (error) { logger.error('Error getting UTXO list', { error: error.message }); throw new Error(`Failed to get UTXO list: ${error.message}`); } } /** * Obtient les informations sur les UTXOs de moins de 2500 sats disponibles pour consolidation * @returns {Promise} Nombre et montant total des petits UTXOs */ async getSmallUtxosInfo() { try { const walletName = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; const host = process.env.BITCOIN_RPC_HOST || 'localhost'; const port = process.env.BITCOIN_RPC_PORT || '38332'; const username = process.env.BITCOIN_RPC_USER || 'bitcoin'; const password = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin'; const rpcUrl = `http://${host}:${port}/wallet/${walletName}`; const auth = Buffer.from(`${username}:${password}`).toString('base64'); // Récupérer les UTXOs confirmés const rpcResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'listunspent', method: 'listunspent', params: [1], // Minimum 1 confirmation }), }); if (!rpcResponse.ok) { const errorText = await rpcResponse.text(); logger.error('HTTP error in listunspent for small UTXOs info', { status: rpcResponse.status, statusText: rpcResponse.statusText, response: errorText, }); throw new Error(`HTTP error fetching UTXOs: ${rpcResponse.status} ${rpcResponse.statusText}`); } const rpcResult = await rpcResponse.json(); if (rpcResult.error) { logger.error('RPC error in listunspent for small UTXOs info', { error: rpcResult.error }); throw new Error(`RPC error: ${rpcResult.error.message}`); } const unspent = rpcResult.result || []; // Récupérer les UTXOs verrouillés depuis l'API d'ancrage let lockedUtxos = new Set(); try { const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010'; const anchorApiKey = process.env.ANCHOR_API_KEY || ''; const headers = { 'Content-Type': 'application/json', }; if (anchorApiKey) { headers['x-api-key'] = anchorApiKey; } const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, { method: 'GET', headers, }); if (lockedResponse.ok) { const lockedData = await lockedResponse.json(); for (const locked of lockedData.locked || []) { lockedUtxos.add(`${locked.txid}:${locked.vout}`); } } } catch (error) { logger.debug('Error getting locked UTXOs for small UTXOs info', { error: error.message }); } // Filtrer les UTXOs de moins de 2500 sats (0.000025 BTC), confirmés et non verrouillés const maxAmount = 0.000025; // 2500 sats const smallUtxos = unspent.filter(utxo => { const utxoKey = `${utxo.txid}:${utxo.vout}`; return utxo.amount < maxAmount && (utxo.confirmations || 0) > 0 && !lockedUtxos.has(utxoKey); }); // Vérifier si l'UTXO est dépensé onchain et calculer le montant total let count = 0; let totalAmount = 0; for (const utxo of smallUtxos) { try { const txOutResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'gettxout', method: 'gettxout', params: [utxo.txid, utxo.vout], }), }); if (txOutResponse.ok) { const txOutResult = await txOutResponse.json(); // Si gettxout retourne null, l'UTXO est dépensé if (txOutResult.result !== null) { count++; totalAmount += utxo.amount; } } } catch (error) { logger.debug('Error checking if UTXO is spent for small UTXOs info', { txid: utxo.txid, vout: utxo.vout, error: error.message }); } } const totalSats = Math.round(totalAmount * 100000000); return { count, totalAmount, totalSats, }; } catch (error) { logger.error('Error getting small UTXOs info', { error: error.message }); throw error; } } /** * Consolide les UTXOs de moins de 2500 sats en un gros UTXO * @returns {Promise} Transaction créée avec txid */ async consolidateSmallUtxos() { try { const walletName = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; const host = process.env.BITCOIN_RPC_HOST || 'localhost'; const port = process.env.BITCOIN_RPC_PORT || '38332'; const username = process.env.BITCOIN_RPC_USER || 'bitcoin'; const password = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin'; const rpcUrl = `http://${host}:${port}/wallet/${walletName}`; const auth = Buffer.from(`${username}:${password}`).toString('base64'); // Récupérer les UTXOs confirmés const rpcResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'listunspent', method: 'listunspent', params: [1], // Minimum 1 confirmation }), }); if (!rpcResponse.ok) { const errorText = await rpcResponse.text(); logger.error('HTTP error in listunspent for consolidation', { status: rpcResponse.status, statusText: rpcResponse.statusText, response: errorText, }); throw new Error(`HTTP error fetching UTXOs: ${rpcResponse.status} ${rpcResponse.statusText}`); } const rpcResult = await rpcResponse.json(); if (rpcResult.error) { logger.error('RPC error in listunspent for consolidation', { error: rpcResult.error }); throw new Error(`RPC error: ${rpcResult.error.message}`); } const unspent = rpcResult.result || []; // Récupérer les UTXOs verrouillés depuis l'API d'ancrage let lockedUtxos = new Set(); try { const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010'; const anchorApiKey = process.env.ANCHOR_API_KEY || ''; const headers = { 'Content-Type': 'application/json', }; if (anchorApiKey) { headers['x-api-key'] = anchorApiKey; } const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, { method: 'GET', headers, }); if (lockedResponse.ok) { const lockedData = await lockedResponse.json(); for (const locked of lockedData.locked || []) { lockedUtxos.add(`${locked.txid}:${locked.vout}`); } } } catch (error) { logger.debug('Error getting locked UTXOs for consolidation', { error: error.message }); } // Filtrer les UTXOs de moins de 2500 sats (0.000025 BTC), confirmés, non dépensés et non verrouillés const maxAmount = 0.000025; // 2500 sats const smallUtxos = unspent.filter(utxo => { const utxoKey = `${utxo.txid}:${utxo.vout}`; return utxo.amount < maxAmount && (utxo.confirmations || 0) > 0 && !lockedUtxos.has(utxoKey); }); // Vérifier si l'UTXO est dépensé onchain const availableSmallUtxos = []; for (const utxo of smallUtxos) { try { const txOutResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'gettxout', method: 'gettxout', params: [utxo.txid, utxo.vout], }), }); if (txOutResponse.ok) { const txOutResult = await txOutResponse.json(); // Si gettxout retourne null, l'UTXO est dépensé if (txOutResult.result !== null) { availableSmallUtxos.push(utxo); } } } catch (error) { logger.debug('Error checking if UTXO is spent for consolidation', { txid: utxo.txid, vout: utxo.vout, error: error.message }); } } if (availableSmallUtxos.length === 0) { throw new Error('No small UTXOs available for consolidation'); } logger.info('Consolidating small UTXOs', { count: availableSmallUtxos.length, totalAmount: availableSmallUtxos.reduce((sum, utxo) => sum + utxo.amount, 0), }); // Calculer le montant total const totalAmount = availableSmallUtxos.reduce((sum, utxo) => sum + utxo.amount, 0); // Estimation des frais : base + frais par input const estimatedFeePerInput = 0.000001; // Frais par input (conservateur) const estimatedFeeBase = 0.00001; // Frais de base const estimatedFee = estimatedFeeBase + (availableSmallUtxos.length * estimatedFeePerInput); // Arrondir à 8 décimales const roundTo8Decimals = (amount) => { return Math.round(amount * 100000000) / 100000000; }; const change = roundTo8Decimals(totalAmount - estimatedFee); if (change <= 0) { throw new Error('Consolidation would result in negative or zero change after fees'); } // Obtenir une adresse de destination pour le gros UTXO consolidé const address = await this.getNewAddress(); // Créer les inputs const inputs = availableSmallUtxos.map(utxo => ({ txid: utxo.txid, vout: utxo.vout, })); // Créer les outputs const outputs = { [address]: change, }; // Créer la transaction const createTxResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'createrawtransaction', method: 'createrawtransaction', params: [inputs, outputs], }), }); if (!createTxResponse.ok) { const errorText = await createTxResponse.text(); throw new Error(`HTTP error creating transaction: ${createTxResponse.status} ${errorText}`); } const createTxResult = await createTxResponse.json(); if (createTxResult.error) { throw new Error(`RPC error creating transaction: ${createTxResult.error.message}`); } const rawTx = createTxResult.result; // Signer la transaction const signTxResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'signrawtransactionwithwallet', method: 'signrawtransactionwithwallet', params: [rawTx], }), }); if (!signTxResponse.ok) { const errorText = await signTxResponse.text(); throw new Error(`HTTP error signing transaction: ${signTxResponse.status} ${errorText}`); } const signTxResult = await signTxResponse.json(); if (signTxResult.error) { throw new Error(`RPC error signing transaction: ${signTxResult.error.message}`); } if (!signTxResult.result.complete) { throw new Error('Transaction signing failed'); } // Envoyer la transaction au mempool const sendTxResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}`, }, body: JSON.stringify({ jsonrpc: '1.0', id: 'sendrawtransaction', method: 'sendrawtransaction', params: [signTxResult.result.hex, 0], // maxfeerate = 0 (accepter n'importe quel taux) }), }); if (!sendTxResponse.ok) { const errorText = await sendTxResponse.text(); throw new Error(`HTTP error sending transaction: ${sendTxResponse.status} ${errorText}`); } const sendTxResult = await sendTxResponse.json(); if (sendTxResult.error) { throw new Error(`RPC error sending transaction: ${sendTxResult.error.message}`); } const txid = sendTxResult.result; logger.info('Consolidation transaction sent to mempool', { txid, inputCount: availableSmallUtxos.length, totalInputAmount: totalAmount, changeAmount: change, estimatedFee, }); return { txid, inputCount: availableSmallUtxos.length, totalInputAmount: totalAmount, changeAmount: change, estimatedFee, }; } catch (error) { logger.error('Error consolidating small UTXOs', { error: error.message }); throw error; } } /** * Met à jour les frais depuis les transactions d'ancrage * Récupère les frais depuis OP_RETURN des transactions d'ancrage et les stocke dans la base de données * @param {number} sinceBlockHeight - Hauteur de bloc à partir de laquelle récupérer (optionnel, depuis dernier frais de la base) * @returns {Promise} Résultat avec nombre de frais récupérés */ async updateFeesFromAnchors(sinceBlockHeight = null) { try { const db = getDatabase(); // Lire les frais existants depuis la base de données const existingFees = new Map(); // Optimisation : ne charger que txid pour vérifier l'existence const feesFromDb = db.prepare('SELECT txid FROM fees').all(); for (const fee of feesFromDb) { existingFees.set(fee.txid, true); } // Déterminer depuis quelle hauteur récupérer let startHeight = sinceBlockHeight; if (!startHeight) { // Trouver la hauteur maximale des frais existants const maxHeightRow = db.prepare('SELECT MAX(block_height) as max_height FROM fees WHERE block_height IS NOT NULL').get(); startHeight = maxHeightRow?.max_height || 0; } // Lire les ancrages depuis la base de données pour obtenir les txids const anchorTxids = new Set(); const anchorsFromDb = db.prepare('SELECT DISTINCT txid FROM anchors').all(); for (const anchor of anchorsFromDb) { anchorTxids.add(anchor.txid); } // Récupérer les frais depuis les transactions d'ancrage const newFees = []; let processed = 0; const totalAnchors = anchorTxids.size; for (const txid of anchorTxids) { // Ignorer si déjà dans les frais existants if (existingFees.has(txid)) { continue; } try { const rawTx = await this.client.getRawTransaction(txid, true); if (!rawTx || !rawTx.vout) continue; let onchainFeeAmount = null; let blockHeight = null; let blockTime = null; let changeAddress = null; let changeAmount = null; // Extraire les métadonnées depuis OP_RETURN for (const output of rawTx.vout) { if (output.scriptPubKey && output.scriptPubKey.hex) { const scriptHex = output.scriptPubKey.hex; const anchorPrefixHex = Buffer.from('ANCHOR:', 'utf8').toString('hex'); if (scriptHex.includes(anchorPrefixHex)) { try { const hashLengthHex = 64; const separatorHex = Buffer.from('|', 'utf8').toString('hex'); const anchorPos = scriptHex.indexOf(anchorPrefixHex); if (anchorPos !== -1) { const afterHashPos = anchorPos + anchorPrefixHex.length + hashLengthHex; const separatorPos = scriptHex.indexOf(separatorHex, afterHashPos); if (separatorPos !== -1) { const metadataHex = scriptHex.substring(separatorPos + separatorHex.length); const metadataBuffer = Buffer.from(metadataHex, 'hex'); const metadataString = metadataBuffer.toString('utf8'); const parts = metadataString.split('|'); for (const part of parts) { if (part.startsWith('CHANGE:')) { const changeData = part.substring(7); const changeParts = changeData.split(':'); if (changeParts.length === 2 && changeParts[0] !== 'none') { changeAddress = changeParts[0]; changeAmount = parseInt(changeParts[1], 10) / 100000000; } } else if (part.startsWith('FEE:')) { const feeData = part.substring(4); onchainFeeAmount = parseInt(feeData, 10) / 100000000; } } } } } catch (error) { logger.debug('Error parsing OP_RETURN metadata for fees', { txid, error: error.message }); } break; } } } // Récupérer blockHeight et blockTime si disponible if (rawTx.confirmations > 0) { try { const txInfo = await this.client.getTransaction(txid); blockHeight = txInfo.blockheight || null; blockTime = txInfo.blocktime || null; } catch (error) { logger.debug('Error getting transaction block info for fees', { txid, error: error.message }); } } // Ajouter seulement si frais trouvés if (onchainFeeAmount !== null && onchainFeeAmount > 0) { const feeSats = Math.round(onchainFeeAmount * 100000000); const confirmations = rawTx.confirmations || 0; newFees.push({ txid, fee: onchainFeeAmount, fee_sats: feeSats, blockHeight: blockHeight || '', blockTime: blockTime || '', confirmations, changeAddress: changeAddress || '', changeAmount: changeAmount || '', }); } processed++; if (processed % 10 === 0) { logger.debug('Processing fees from anchors', { processed, total: totalAnchors }); } } catch (error) { logger.debug('Error processing anchor transaction for fees', { txid, error: error.message }); } } // Ajouter les nouveaux frais à la base de données if (newFees.length > 0) { const insertFee = db.prepare(` INSERT OR REPLACE INTO fees (txid, fee, fee_sats, block_height, block_time, confirmations, change_address, change_amount, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); const insertManyFees = db.transaction((fees) => { for (const fee of fees) { insertFee.run( fee.txid, fee.fee, fee.fee_sats, fee.blockHeight || null, fee.blockTime || null, fee.confirmations, fee.changeAddress || null, fee.changeAmount || null ); } }); insertManyFees(newFees); const totalFees = db.prepare('SELECT COUNT(*) as count FROM fees').get(); logger.info('Fees list updated in database', { newFees: newFees.length, total: totalFees.count }); } return { success: true, newFees: newFees.length, totalFees: existingFees.size + newFees.length, processed, }; } catch (error) { logger.error('Error updating fees from anchors', { error: error.message }); throw error; } } /** * Obtient le nombre d'ancrages depuis la base de données * Vérifie et met à jour la base de données si nécessaire avant de compter * @returns {Promise} Nombre d'ancrages */ async getAnchorCount() { try { const db = getDatabase(); // Vérifier rapidement s'il y a de nouveaux blocs à traiter let needsUpdate = false; try { const blockchainInfo = await this.client.getBlockchainInfo(); const currentHeight = blockchainInfo.blocks; // Vérifier le cache dans la base de données const cacheRow = db.prepare('SELECT value FROM cache WHERE key = ?').get('hash_list_cache'); if (cacheRow) { const parts = cacheRow.value.split(';'); if (parts.length >= 2) { const cachedHeight = parseInt(parts[1], 10); if (!isNaN(cachedHeight) && cachedHeight < currentHeight) { needsUpdate = true; logger.debug('New blocks detected, updating hash list before counting', { cachedHeight, currentHeight, newBlocks: currentHeight - cachedHeight, }); } } else { needsUpdate = true; } } else { needsUpdate = true; } } catch (error) { logger.warn('Error checking for new blocks, using existing data', { error: error.message }); } // Si des nouveaux blocs sont détectés, mettre à jour la base de données if (needsUpdate) { try { await this.getHashList(); logger.debug('Hash list updated before counting anchors'); } catch (error) { logger.warn('Error updating hash list before counting, using existing data', { error: error.message }); } } // Compter depuis la base de données const countRow = db.prepare('SELECT COUNT(*) as count FROM anchors').get(); const anchorCount = countRow?.count || 0; logger.debug('Anchor count read from database', { count: anchorCount }); return anchorCount; } catch (error) { logger.error('Error getting anchor count', { error: error.message }); throw new Error(`Failed to get anchor count: ${error.message}`); } } } // Export singleton export const bitcoinRPC = new BitcoinRPC();