From 9ccdd929a171796658979dd71d22f6e9588ab886 Mon Sep 17 00:00:00 2001 From: ncantu Date: Mon, 26 Jan 2026 00:26:39 +0100 Subject: [PATCH] Optimize dashboard home loading by using text files instead of RPC calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - Le chargement et l'actualisation de la home est très long - Réduire les appels RPC Bitcoin pour améliorer les performances - Utiliser les fichiers texte comme source principale de données **Root causes:** - Les méthodes getAnchorCount(), getHashList() et getUtxoList() faisaient des appels RPC à chaque chargement - Les fichiers texte hash_list.txt et utxo_list.txt n'étaient pas utilisés efficacement **Correctifs:** - getAnchorCount() : Lit directement depuis hash_list.txt au lieu de faire des appels RPC pour compter - getHashList() : Lit directement depuis hash_list.txt et ne complète que les nouveaux blocs si nécessaire - getUtxoList() : Lit depuis utxo_list.txt et ne fait l'appel RPC listunspent que si de nouveaux blocs sont détectés - Mise à jour des confirmations sans appels RPC supplémentaires **Evolutions:** - Performance améliorée : les appels RPC sont limités aux cas où de nouveaux blocs sont détectés - Utilisation efficace des fichiers texte comme source de vérité - Réduction significative du temps de chargement de la home **Pages affectées:** - signet-dashboard/src/bitcoin-rpc.js : Méthodes getAnchorCount(), getHashList(), getUtxoList() --- signet-dashboard/src/bitcoin-rpc.js | 676 +++++++++++++++------------- 1 file changed, 361 insertions(+), 315 deletions(-) diff --git a/signet-dashboard/src/bitcoin-rpc.js b/signet-dashboard/src/bitcoin-rpc.js index aea49f0..de49c91 100644 --- a/signet-dashboard/src/bitcoin-rpc.js +++ b/signet-dashboard/src/bitcoin-rpc.js @@ -146,82 +146,104 @@ class BitcoinRPC { /** * Obtient la liste des hash ancrés avec leurs transactions - * Utilise un fichier de cache hash_list.txt pour éviter de tout recompter + * Lit directement depuis hash_list.txt et ne complète que les nouveaux blocs si nécessaire * Format du cache: ;; * Format du fichier de sortie: ;;; * @returns {Promise>} Liste des hash avec leurs transactions */ async getHashList() { try { - const blockchainInfo = await this.client.getBlockchainInfo(); - const currentHeight = blockchainInfo.blocks; - const currentBlockHash = blockchainInfo.bestblockhash; - const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const cachePath = join(__dirname, '../../hash_list_cache.txt'); const outputPath = join(__dirname, '../../hash_list.txt'); - let startHeight = 0; const hashList = []; - // Lire le cache si il existe + // Lire directement depuis le fichier de sortie + if (existsSync(outputPath)) { + try { + const existingContent = readFileSync(outputPath, 'utf8').trim(); + if (existingContent) { + const lines = existingContent.split('\n'); + for (const line of lines) { + if (line.trim()) { + const [hash, txid, blockHeight, confirmations] = line.split(';'); + if (hash && txid) { + hashList.push({ + hash, + txid, + blockHeight: blockHeight ? parseInt(blockHeight, 10) : null, + confirmations: confirmations ? parseInt(confirmations, 10) : 0, + }); + } + } + } + logger.debug('Hash list loaded from file', { count: hashList.length }); + } + } catch (error) { + logger.warn('Error reading hash_list.txt', { error: error.message }); + } + } + + // 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; + } + if (existsSync(cachePath)) { try { const cacheContent = readFileSync(cachePath, 'utf8').trim(); const parts = cacheContent.split(';'); if (parts.length === 3) { const cachedHeight = parseInt(parts[1], 10); - const cachedHash = parts[2]; + startHeight = cachedHeight + 1; - if (cachedHeight >= 0 && cachedHeight <= currentHeight) { - try { - const cachedBlockHash = await this.client.getBlockHash(cachedHeight); - if (cachedBlockHash === cachedHash) { - startHeight = cachedHeight + 1; - // Charger les hash existants depuis le fichier de sortie - if (existsSync(outputPath)) { - const existingContent = readFileSync(outputPath, 'utf8').trim(); - const lines = existingContent.split('\n'); - for (const line of lines) { - if (line.trim()) { - const [hash, txid, blockHeight, confirmations] = line.split(';'); - if (hash && txid) { - hashList.push({ - hash, - txid, - blockHeight: blockHeight ? parseInt(blockHeight, 10) : null, - confirmations: confirmations ? parseInt(confirmations, 10) : 0, - }); - } - } - } - } - logger.info('Hash list cache loaded', { - cachedHeight, - cachedCount: hashList.length, - startHeight, - currentHeight, - }); - } else { - logger.warn('Hash list cache invalid: block hash mismatch', { - cachedHeight, - cachedHash, - actualHash: cachedBlockHash, - }); + 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 + for (const item of hashList) { + if (item.blockHeight !== null) { + item.confirmations = Math.max(0, currentHeight - item.blockHeight + 1); } - } catch (error) { - logger.warn('Error verifying cached block hash', { error: error.message }); } + logger.debug('Hash list up to date, confirmations updated', { count: hashList.length }); } } } catch (error) { logger.warn('Error reading hash list cache', { error: error.message }); + // Si erreur de lecture du cache, initialiser depuis le début + 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 }); } - // Parcourir les blocs depuis startHeight jusqu'à currentHeight - if (startHeight <= currentHeight) { + // Compléter seulement les nouveaux blocs si nécessaire + if (needsUpdate && startHeight <= currentHeight) { + logger.info('Collecting hash list from block', { startHeight, currentHeight }); for (let height = startHeight; height <= currentHeight; height++) { @@ -293,6 +315,15 @@ class BitcoinRPC { ); writeFileSync(outputPath, outputLines.join('\n'), 'utf8'); logger.info('Hash list saved', { currentHeight, count: hashList.length }); + } else { + // Mettre à jour les confirmations seulement si nécessaire + if (currentHeight > 0) { + for (const item of hashList) { + if (item.blockHeight !== null) { + item.confirmations = Math.max(0, currentHeight - item.blockHeight + 1); + } + } + } } return hashList; @@ -319,53 +350,8 @@ class BitcoinRPC { const cachePath = join(__dirname, '../../utxo_list_cache.txt'); const outputPath = join(__dirname, '../../utxo_list.txt'); - // Obtenir les UTXO depuis le wallet - 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'); - - 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}`); - } - - const unspent = rpcResult.result || []; - - // Charger les UTXOs existants depuis le fichier texte + // Charger les UTXOs existants depuis le fichier texte d'abord const existingUtxosMap = new Map(); // Clé: "txid:vout", Valeur: utxoItem - const blocRewards = []; - const anchors = []; - const changes = []; - const fees = []; // Liste des transactions avec leurs frais onchain if (existsSync(outputPath)) { try { @@ -374,13 +360,25 @@ class BitcoinRPC { for (const line of lines) { if (line.trim()) { const parts = line.split(';'); + // Format ancien (avec address): category;txid;vout;address;amount;confirmations;isAnchorChange + // Format nouveau (sans address, avec blockTime): category;txid;vout;amount;confirmations;isAnchorChange;blockTime if (parts.length >= 6) { - const [category, txid, vout, address, amount, confirmations] = parts; + let category, txid, vout, amount, confirmations, isAnchorChange, blockTime; + // Détecter le format : si le 4ème champ est un nombre, c'est le nouveau format + if (parts.length === 7 && !isNaN(parseFloat(parts[3]))) { + // Nouveau format : category;txid;vout;amount;confirmations;isAnchorChange;blockTime + [category, txid, vout, amount, confirmations, isAnchorChange, blockTime] = parts; + } else if (parts.length >= 6) { + // Ancien format : category;txid;vout;address;amount;confirmations;isAnchorChange + [category, txid, vout, , amount, confirmations] = parts; + isAnchorChange = parts.length > 6 ? parts[6] === 'true' : false; + blockTime = parts.length > 7 ? parseInt(parts[7], 10) || null : null; + } const utxoKey = `${txid}:${vout}`; const utxoItem = { txid, vout: parseInt(vout, 10), - address: address || '', + address: '', // Plus stocké dans le fichier amount: parseFloat(amount), confirmations: parseInt(confirmations, 10) || 0, category, @@ -388,8 +386,8 @@ class BitcoinRPC { isSpentOnchain: false, isLockedInMutex: false, blockHeight: null, - blockTime: null, - isAnchorChange: category === 'changes' && parts.length > 6 ? parts[6] === 'true' : false, + blockTime: blockTime ? parseInt(blockTime, 10) : null, + isAnchorChange: isAnchorChange === 'true' || isAnchorChange === true, }; existingUtxosMap.set(utxoKey, utxoItem); } @@ -401,6 +399,151 @@ class BitcoinRPC { } } + // 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 pour déterminer si une mise à jour est nécessaire + if (existsSync(cachePath)) { + try { + const cacheContent = readFileSync(cachePath, 'utf8').trim(); + const parts = cacheContent.split(';'); + if (parts.length >= 1) { + const cachedHeight = parseInt(parts[1], 10) || 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 }); + } + } + } catch (error) { + logger.warn('Error reading UTXO cache', { error: error.message }); + needsUpdate = true; + } + } else { + needsUpdate = true; + logger.info('No UTXO cache found, initializing', { currentHeight }); + } + + // Obtenir les UTXO depuis le wallet seulement si nécessaire (nouveaux blocs détectés) + // Si pas de nouveaux blocs, on utilise les données du fichier texte directement + let unspent = []; + if (needsUpdate) { + 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 du fichier directement + // On marque tous les UTXOs comme disponibles (non dépensés) pour éviter l'appel RPC + logger.debug('No new blocks, using cached UTXO list from file'); + } + + const blocRewards = []; + const anchors = []; + const changes = []; + const fees = []; // Liste des transactions avec leurs frais onchain + + // Si pas de mise à jour nécessaire, retourner directement les données du fichier + if (!needsUpdate && existingUtxosMap.size > 0) { + // Mettre à jour les confirmations seulement + for (const item of existingUtxosMap.values()) { + if (item.blockHeight !== null && currentHeight > 0) { + item.confirmations = Math.max(0, currentHeight - item.blockHeight + 1); + } + // Marquer comme non dépensé (on ne peut pas le savoir sans RPC, mais on assume qu'il est toujours disponible) + item.isSpentOnchain = false; + } + + // Organiser par catégorie + const blocRewards = []; + const anchors = []; + const changes = []; + const fees = []; + + for (const utxo of existingUtxosMap.values()) { + if (utxo.category === 'bloc_reward') { + blocRewards.push(utxo); + } else if (utxo.category === 'anchor') { + anchors.push(utxo); + } else if (utxo.category === 'change') { + changes.push(utxo); + } else if (utxo.category === 'fee') { + fees.push(utxo); + } + } + + // Calculer availableForAnchor + const availableForAnchor = anchors.filter(u => !u.isSpentOnchain && !u.isLockedInMutex && (u.confirmations || 0) >= 1).length; + const confirmedAvailableForAnchor = anchors.filter(u => !u.isSpentOnchain && !u.isLockedInMutex && (u.confirmations || 0) >= 6).length; + + logger.debug('UTXO list returned from cache', { + blocRewards: blocRewards.length, + anchors: anchors.length, + changes: changes.length, + fees: fees.length, + availableForAnchor, + }); + + return { + blocRewards, + anchors, + changes, + fees, + total: existingUtxosMap.size, + availableForAnchor, + confirmedAvailableForAnchor, + }; + } + // Créer un Set des UTXOs actuels pour identifier les nouveaux const currentUtxosSet = new Set(); for (const utxo of unspent) { @@ -415,14 +558,15 @@ class BitcoinRPC { 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 + // 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 && - existing.confirmations === (utxo.confirmations || 0)) { - // UTXO existant avec données identiques, utiliser les données du fichier + Math.abs(existing.amount - utxo.amount) < 0.00000001) { + // UTXO existant avec montant identique, utiliser les données du fichier + // Les confirmations seront mises à jour plus tard utxosToKeep.push(existing); } else { - // Nouvel UTXO ou UTXO modifié, doit être recalculé + // Nouvel UTXO ou UTXO modifié (montant différent), doit être recalculé utxosToRecalculate.push(utxo); } } @@ -463,49 +607,38 @@ class BitcoinRPC { } // 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) => { + // (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); - - // 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) { @@ -524,38 +657,14 @@ class BitcoinRPC { } // 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, { - 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; - - // Vérifier si l'UTXO est dépensé onchain - let isSpentOnchain = false; + // 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 { - const txOutResponse = await fetch(rpcUrl, { + // Obtenir la transaction source pour déterminer sa catégorie + const rawTxResponse = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -563,20 +672,25 @@ class BitcoinRPC { }, body: JSON.stringify({ jsonrpc: '1.0', - id: 'gettxout', - method: 'gettxout', - params: [utxo.txid, utxo.vout], + id: 'getrawtransaction', + method: 'getrawtransaction', + params: [utxo.txid, true], }), }); - if (txOutResponse.ok) { - const txOutResult = await txOutResponse.json(); - // Si gettxout retourne null, l'UTXO est dépensé - isSpentOnchain = txOutResult.result === null; + if (!rawTxResponse.ok) { + throw new Error(`HTTP error fetching transaction: ${rawTxResponse.status}`); } - } catch (error) { - logger.debug('Error checking if UTXO is spent', { txid: utxo.txid, vout: utxo.vout, error: error.message }); - } + + 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}`; @@ -585,7 +699,7 @@ class BitcoinRPC { const utxoItem = { txid: utxo.txid, vout: utxo.vout, - address: utxo.address || '', + address: '', // Plus stocké amount: utxo.amount, confirmations: utxo.confirmations || 0, isSpentOnchain, @@ -599,10 +713,22 @@ class BitcoinRPC { 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'; - blocRewards.push(utxoItem); - existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); - continue; + return { utxoItem, category: 'bloc_rewards' }; } // Vérifier si c'est une transaction d'ancrage (contient OP_RETURN avec "ANCHOR:") @@ -693,24 +819,6 @@ class BitcoinRPC { } } - // Si c'est une transaction d'ancrage avec des frais onchain, les stocker - if (isAnchorTx && onchainFeeAmount !== null) { - // Vérifier si cette transaction n'est pas déjà dans la liste des frais - const existingFee = fees.find(f => f.txid === utxo.txid); - if (!existingFee) { - fees.push({ - txid: utxo.txid, - fee: onchainFeeAmount, - fee_sats: Math.round(onchainFeeAmount * 100000000), - changeAddress: onchainChangeAddress || null, - changeAmount: onchainChangeAmount || null, - blockHeight: blockHeight, - blockTime: blockTime, - confirmations: utxo.confirmations || 0, - }); - } - } - if (isAnchorTx) { // Dans une transaction d'ancrage, distinguer les outputs d'ancrage/provisionnement du change // Les transactions d'ancrage créent : @@ -733,7 +841,7 @@ class BitcoinRPC { // 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) - continue; // Ne pas ajouter cet UTXO car OP_RETURN n'est pas 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) @@ -747,8 +855,7 @@ class BitcoinRPC { if (isAnchorOutput) { utxoItem.category = 'ancrages'; - anchors.push(utxoItem); - existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); + 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'; @@ -761,15 +868,13 @@ class BitcoinRPC { if (onchainFeeAmount !== null) { utxoItem.onchainFeeAmount = onchainFeeAmount; } - changes.push(utxoItem); - existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); + 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; - changes.push(utxoItem); - existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem); + return { utxoItem, category: 'changes', isAnchorChange: true }; } } else { // Transaction normale (non-ancrage, non-coinbase) = change @@ -777,8 +882,7 @@ class BitcoinRPC { // 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); + return { utxoItem, category: 'changes', isAnchorChange: false }; } } catch (error) { // En cas d'erreur, classer comme change par défaut @@ -786,15 +890,47 @@ class BitcoinRPC { const errorUtxoItem = { txid: utxo.txid, vout: utxo.vout, - address: utxo.address || '', + address: '', // Plus stocké amount: utxo.amount, confirmations: utxo.confirmations || 0, category: 'changes', isSpentOnchain: false, isLockedInMutex: false, }; - changes.push(errorUtxoItem); - existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, errorUtxoItem); + 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); + } + } + } } } @@ -843,10 +979,11 @@ class BitcoinRPC { writeFileSync(cachePath, now, 'utf8'); // É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 + // Format: category;txid;vout;amount;confirmations;isAnchorChange;blockTime 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}`; + const blockTime = item.blockTime || ''; + return `${item.category};${item.txid};${item.vout};${item.amount};${item.confirmations};${isAnchorChange};${blockTime}`; }); writeFileSync(outputPath, outputLines.join('\n'), 'utf8'); @@ -1283,125 +1420,34 @@ class BitcoinRPC { } /** - * Obtient le nombre d'ancrages en comptant tous les blocs depuis le début - * Utilise un fichier de cache anchor_count.txt pour éviter de tout recompter - * Format du cache: ;;; + * Obtient le nombre d'ancrages en lisant directement depuis hash_list.txt + * Évite les appels RPC en utilisant le fichier texte comme source de vérité * @returns {Promise} Nombre d'ancrages */ async getAnchorCount() { try { - const blockchainInfo = await this.client.getBlockchainInfo(); - const currentHeight = blockchainInfo.blocks; - const currentBlockHash = blockchainInfo.bestblockhash; - - // Chemin du fichier de cache (à la racine du projet bitcoin) const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); - // signet-dashboard/src/bitcoin-rpc.js -> signet-dashboard/src -> signet-dashboard -> bitcoin -> anchor_count.txt - // On remonte de 2 niveaux depuis signet-dashboard/src pour arriver à bitcoin/ - const cachePath = join(__dirname, '../../anchor_count.txt'); + const hashListPath = join(__dirname, '../../hash_list.txt'); - let startHeight = 0; - let anchorCount = 0; - let lastProcessedHash = null; - - // Lire le cache si il existe - if (existsSync(cachePath)) { + // Lire directement depuis le fichier texte + if (existsSync(hashListPath)) { try { - const cacheContent = readFileSync(cachePath, 'utf8').trim(); - const parts = cacheContent.split(';'); - if (parts.length === 4) { - const cachedDate = parts[0]; - const cachedHeight = parseInt(parts[1], 10); - const cachedHash = parts[2]; - const cachedCount = parseInt(parts[3], 10); - - // Vérifier que le hash du bloc en cache correspond toujours - if (cachedHeight >= 0 && cachedHeight <= currentHeight) { - try { - const cachedBlockHash = await this.client.getBlockHash(cachedHeight); - if (cachedBlockHash === cachedHash) { - startHeight = cachedHeight + 1; - anchorCount = cachedCount; - lastProcessedHash = cachedHash; - logger.info('Anchor count cache loaded', { - cachedDate, - cachedHeight, - cachedCount, - startHeight, - currentHeight - }); - } else { - logger.warn('Anchor count cache invalid: block hash mismatch', { - cachedHeight, - cachedHash, - actualHash: cachedBlockHash - }); - } - } catch (error) { - logger.warn('Error verifying cached block hash', { error: error.message }); - } - } + const content = readFileSync(hashListPath, 'utf8').trim(); + if (content) { + const lines = content.split('\n').filter(line => line.trim()); + const anchorCount = lines.length; + logger.debug('Anchor count read from hash_list.txt', { count: anchorCount }); + return anchorCount; } } catch (error) { - logger.warn('Error reading anchor count cache', { error: error.message }); + logger.warn('Error reading hash_list.txt', { error: error.message }); } } - // Compter les ancrages depuis startHeight jusqu'à currentHeight - if (startHeight <= currentHeight) { - logger.info('Counting anchors 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)) { - anchorCount++; - break; // Compter une seule fois par transaction - } - } - } - } catch (error) { - // Continuer avec la transaction suivante - logger.debug('Error checking transaction for anchor', { txid: tx.txid, error: error.message }); - } - } - } - - // Mettre à jour le cache tous les 100 blocs pour éviter de perdre trop de travail - if (height % 100 === 0 || height === currentHeight) { - const now = new Date().toISOString(); - const cacheContent = `${now};${height};${blockHash};${anchorCount}`; - writeFileSync(cachePath, cacheContent, 'utf8'); - logger.debug('Anchor count cache updated', { height, anchorCount }); - } - } catch (error) { - // Continuer avec le bloc suivant - logger.debug('Error checking block for anchors', { height, error: error.message }); - } - } - - // Mettre à jour le cache final - const now = new Date().toISOString(); - const cacheContent = `${now};${currentHeight};${currentBlockHash};${anchorCount}`; - writeFileSync(cachePath, cacheContent, 'utf8'); - logger.info('Anchor count cache saved', { currentHeight, anchorCount }); - } - - return anchorCount; + // Si le fichier n'existe pas ou est vide, retourner 0 + logger.debug('hash_list.txt not found or empty, returning 0'); + return 0; } catch (error) { logger.error('Error getting anchor count', { error: error.message }); throw new Error(`Failed to get anchor count: ${error.message}`);