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:
parent
c391e7151a
commit
9ccdd929a1
@ -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}`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user