Optimize dashboard home loading by using text files instead of RPC calls

**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()
This commit is contained in:
ncantu 2026-01-26 00:26:39 +01:00
parent c391e7151a
commit 9ccdd929a1

View File

@ -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: <date>;<hauteur du dernier bloc>;<hash du dernier bloc>
* Format du fichier de sortie: <hash>;<txid>;<block_height>;<confirmations>
* @returns {Promise<Array<Object>>} 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: <date>;<hauteur du dernier bloc>;<hash du dernier bloc>;<count total des ancrages>
* 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<number>} 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}`);