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