From 300c728ce8f44cf8fb15bf1b3ff88d8a394bd4a1 Mon Sep 17 00:00:00 2001 From: ncantu Date: Sun, 25 Jan 2026 23:43:23 +0100 Subject: [PATCH] Optimize UTXO list loading using text file cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - Le chargement des UTXOs est très long car il recalcule tout à chaque fois - Chaque UTXO nécessite plusieurs appels RPC (getrawtransaction, gettxout, getTransaction) - Avec des milliers d'UTXOs, cela prend beaucoup de temps **Root causes:** - Le code ne lit jamais le fichier utxo_list.txt pour éviter de recalculer - Tous les UTXOs sont recalculés à chaque appel, même s'ils n'ont pas changé - Le fichier de cache utxo_list_cache.txt n'est utilisé que pour stocker une date, pas pour éviter les recalculs **Correctifs:** - Chargement des UTXOs existants depuis utxo_list.txt au démarrage - Comparaison avec les UTXOs actuels de listunspent pour identifier les nouveaux/modifiés - Recalcul uniquement des UTXOs nouveaux ou modifiés - Mise à jour des informations dynamiques (isSpentOnchain, isLockedInMutex, confirmations) en parallèle - Conservation des UTXOs dépensés dans le fichier pour l'historique (mais exclus des listes actives) **Evolutions:** - Utilisation du fichier texte utxo_list.txt comme cache (comme getHashList() utilise hash_list.txt) - Format du fichier étendu : category;txid;vout;address;amount;confirmations;isAnchorChange - Vérifications en parallèle pour les UTXOs existants (Promise.all) - Logs indiquant le nombre d'UTXOs chargés depuis le fichier vs recalculés - Les UTXOs dépensés sont conservés dans le fichier mais exclus des listes actives **Pages affectées:** - signet-dashboard/src/bitcoin-rpc.js: Méthode getUtxoList() optimisée pour utiliser le fichier texte comme cache --- signet-dashboard/src/bitcoin-rpc.js | 223 +++++++++++++++++++++++----- 1 file changed, 186 insertions(+), 37 deletions(-) diff --git a/signet-dashboard/src/bitcoin-rpc.js b/signet-dashboard/src/bitcoin-rpc.js index 2fa9b88..aea49f0 100644 --- a/signet-dashboard/src/bitcoin-rpc.js +++ b/signet-dashboard/src/bitcoin-rpc.js @@ -359,12 +359,79 @@ class BitcoinRPC { } const unspent = rpcResult.result || []; + + // Charger les UTXOs existants depuis le fichier texte + const existingUtxosMap = new Map(); // Clé: "txid:vout", Valeur: utxoItem const blocRewards = []; const anchors = []; const changes = []; const fees = []; // Liste des transactions avec leurs frais onchain - logger.info('Categorizing UTXOs', { total: unspent.length }); + if (existsSync(outputPath)) { + try { + const existingContent = readFileSync(outputPath, 'utf8').trim(); + const lines = existingContent.split('\n'); + for (const line of lines) { + if (line.trim()) { + const parts = line.split(';'); + if (parts.length >= 6) { + const [category, txid, vout, address, amount, confirmations] = parts; + const utxoKey = `${txid}:${vout}`; + const utxoItem = { + txid, + vout: parseInt(vout, 10), + address: address || '', + amount: parseFloat(amount), + confirmations: parseInt(confirmations, 10) || 0, + category, + // Ces champs seront mis à jour si nécessaire + isSpentOnchain: false, + isLockedInMutex: false, + blockHeight: null, + blockTime: null, + isAnchorChange: category === 'changes' && parts.length > 6 ? parts[6] === 'true' : false, + }; + existingUtxosMap.set(utxoKey, utxoItem); + } + } + } + logger.info('Loaded existing UTXOs from file', { count: existingUtxosMap.size }); + } catch (error) { + logger.warn('Error reading existing UTXO file', { error: error.message }); + } + } + + // 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 les données de base sont identiques + if (existing && + Math.abs(existing.amount - utxo.amount) < 0.00000001 && + existing.confirmations === (utxo.confirmations || 0)) { + // UTXO existant avec données identiques, utiliser les données du fichier + utxosToKeep.push(existing); + } else { + // Nouvel UTXO ou UTXO modifié, 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(); @@ -395,8 +462,69 @@ class BitcoinRPC { logger.debug('Error getting locked UTXOs from anchor API', { error: error.message }); } - // Catégoriser chaque UTXO - for (const utxo of unspent) { + // Mettre à jour les informations dynamiques pour les UTXOs existants + // (isSpentOnchain, isLockedInMutex, confirmations) + // Faire les vérifications en parallèle pour accélérer + const updatePromises = utxosToKeep.map(async (existingUtxo) => { + // 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ù + + // Vérifier si l'UTXO est verrouillé + const utxoKey = `${existingUtxo.txid}:${existingUtxo.vout}`; + existingUtxo.isLockedInMutex = lockedUtxos.has(utxoKey); + + // Vérifier si dépensé (gettxout retourne null) + 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: [existingUtxo.txid, existingUtxo.vout], + }), + }); + + if (txOutResponse.ok) { + const txOutResult = await txOutResponse.json(); + existingUtxo.isSpentOnchain = txOutResult.result === null; + } + } catch (error) { + logger.debug('Error checking if existing UTXO is spent', { txid: existingUtxo.txid, vout: existingUtxo.vout, error: error.message }); + } + } else { + // UTXO n'est plus dans listunspent, il a été dépensé + existingUtxo.isSpentOnchain = true; + } + }); + + await Promise.all(updatePromises); + + // 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 + for (const utxo of utxosToRecalculate) { try { // Obtenir la transaction source pour déterminer sa catégorie const rawTxResponse = await fetch(rpcUrl, { @@ -473,6 +601,7 @@ class BitcoinRPC { if (isCoinbase) { utxoItem.category = 'bloc_rewards'; blocRewards.push(utxoItem); + existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); continue; } @@ -616,48 +745,66 @@ class BitcoinRPC { } } - if (isAnchorOutput) { - utxoItem.category = 'ancrages'; - anchors.push(utxoItem); - } 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; - } - changes.push(utxoItem); - } 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; - changes.push(utxoItem); - } - } 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.) + if (isAnchorOutput) { + utxoItem.category = 'ancrages'; + anchors.push(utxoItem); + existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); + } else if (isChangeOutput) { + // C'est le change de la transaction d'ancrage utxoItem.category = 'changes'; - utxoItem.isAnchorChange = false; // Change d'une transaction normale + 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; + } changes.push(utxoItem); + existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); + } 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; + changes.push(utxoItem); + existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); } + } 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 + changes.push(utxoItem); + existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); + } } catch (error) { // En cas d'erreur, classer comme change par défaut logger.debug('Error categorizing UTXO', { txid: utxo.txid, error: error.message }); - changes.push({ + const errorUtxoItem = { txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, category: 'changes', - }); + isSpentOnchain: false, + isLockedInMutex: false, + }; + changes.push(errorUtxoItem); + existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, errorUtxoItem); + } + } + + // Vérifier les UTXOs dépensés (ceux qui étaient dans le fichier mais plus dans listunspent) + // Ces UTXOs sont marqués comme dépensés mais conservés dans le fichier pour l'historique + 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) } } @@ -695,10 +842,12 @@ class BitcoinRPC { const now = new Date().toISOString(); writeFileSync(cachePath, now, 'utf8'); - // Écrire le fichier de sortie avec toutes les catégories - const outputLines = allUtxos.map((item) => - `${item.category};${item.txid};${item.vout};${item.address};${item.amount};${item.confirmations}` - ); + // Écrire le fichier de sortie avec toutes les catégories (incluant les UTXOs dépensés pour historique) + // Format: category;txid;vout;address;amount;confirmations;isAnchorChange + const outputLines = Array.from(existingUtxosMap.values()).map((item) => { + const isAnchorChange = item.isAnchorChange ? 'true' : 'false'; + return `${item.category};${item.txid};${item.vout};${item.address};${item.amount};${item.confirmations};${isAnchorChange}`; + }); writeFileSync(outputPath, outputLines.join('\n'), 'utf8'); // Analyser la distribution pour comprendre pourquoi il y a si peu de changes