Optimize UTXO list loading using text file cache

**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
This commit is contained in:
ncantu 2026-01-25 23:43:23 +01:00
parent 64cba050a8
commit 300c728ce8

View File

@ -359,12 +359,79 @@ class BitcoinRPC {
} }
const unspent = rpcResult.result || []; const unspent = rpcResult.result || [];
// Charger les UTXOs existants depuis le fichier texte
const existingUtxosMap = new Map(); // Clé: "txid:vout", Valeur: utxoItem
const blocRewards = []; const blocRewards = [];
const anchors = []; const anchors = [];
const changes = []; const changes = [];
const fees = []; // Liste des transactions avec leurs frais onchain 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 // Récupérer les UTXO verrouillés depuis l'API d'ancrage
let lockedUtxos = new Set(); let lockedUtxos = new Set();
@ -395,8 +462,69 @@ class BitcoinRPC {
logger.debug('Error getting locked UTXOs from anchor API', { error: error.message }); logger.debug('Error getting locked UTXOs from anchor API', { error: error.message });
} }
// Catégoriser chaque UTXO // Mettre à jour les informations dynamiques pour les UTXOs existants
for (const utxo of unspent) { // (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 { try {
// Obtenir la transaction source pour déterminer sa catégorie // Obtenir la transaction source pour déterminer sa catégorie
const rawTxResponse = await fetch(rpcUrl, { const rawTxResponse = await fetch(rpcUrl, {
@ -473,6 +601,7 @@ class BitcoinRPC {
if (isCoinbase) { if (isCoinbase) {
utxoItem.category = 'bloc_rewards'; utxoItem.category = 'bloc_rewards';
blocRewards.push(utxoItem); blocRewards.push(utxoItem);
existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem);
continue; continue;
} }
@ -616,48 +745,66 @@ class BitcoinRPC {
} }
} }
if (isAnchorOutput) { if (isAnchorOutput) {
utxoItem.category = 'ancrages'; utxoItem.category = 'ancrages';
anchors.push(utxoItem); anchors.push(utxoItem);
} else if (isChangeOutput) { existingUtxosMap.set(`${utxo.txid}:${utxo.vout}`, utxoItem);
// C'est le change de la transaction d'ancrage } else if (isChangeOutput) {
utxoItem.category = 'changes'; // C'est le change de la transaction d'ancrage
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.)
utxoItem.category = 'changes'; 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); 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) { } catch (error) {
// En cas d'erreur, classer comme change par défaut // En cas d'erreur, classer comme change par défaut
logger.debug('Error categorizing UTXO', { txid: utxo.txid, error: error.message }); logger.debug('Error categorizing UTXO', { txid: utxo.txid, error: error.message });
changes.push({ const errorUtxoItem = {
txid: utxo.txid, txid: utxo.txid,
vout: utxo.vout, vout: utxo.vout,
address: utxo.address || '', address: utxo.address || '',
amount: utxo.amount, amount: utxo.amount,
confirmations: utxo.confirmations || 0, confirmations: utxo.confirmations || 0,
category: 'changes', 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(); const now = new Date().toISOString();
writeFileSync(cachePath, now, 'utf8'); writeFileSync(cachePath, now, 'utf8');
// Écrire le fichier de sortie avec toutes les catégories // Écrire le fichier de sortie avec toutes les catégories (incluant les UTXOs dépensés pour historique)
const outputLines = allUtxos.map((item) => // Format: category;txid;vout;address;amount;confirmations;isAnchorChange
`${item.category};${item.txid};${item.vout};${item.address};${item.amount};${item.confirmations}` 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'); writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
// Analyser la distribution pour comprendre pourquoi il y a si peu de changes // Analyser la distribution pour comprendre pourquoi il y a si peu de changes