ncantu 0db7a76044 Fix: double déclaration const now, scripts .mjs, /api/utxo/count accepte ancrages
**Motivations:**
- Corriger erreur syntaxe double déclaration const now dans bitcoin-rpc.js
- Scripts batch en .mjs (ES modules) sans dépendance dotenv
- /api/utxo/count doit accepter catégorie ancrages (pluriel) du fichier

**Root causes:**
- const now déclaré deux fois dans même portée (lignes 294 et 299)
- Scripts utilisent dotenv non installé globalement
- /api/utxo/count cherchait seulement 'anchor' mais fichier utilise 'ancrages'

**Correctifs:**
- Supprimer deuxième déclaration const now (ligne 299)
- Scripts .mjs : parser .env manuellement sans dotenv
- /api/utxo/count : accepter 'anchor' OU 'ancrages'

**Evolutions:**
- Aucune

**Pages affectées:**
- signet-dashboard/src/bitcoin-rpc.js
- signet-dashboard/src/server.js
- scripts/complete-utxo-list-blocktime.mjs
- scripts/diagnose-bloc-rewards.mjs
2026-01-26 02:06:10 +01:00

1745 lines
70 KiB
JavaScript

/**
* Client Bitcoin RPC pour le dashboard
*
* Gère la connexion et les appels RPC vers le nœud Bitcoin Signet
*/
import Client from 'bitcoin-core';
import { logger } from './logger.js';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Buffer } from 'buffer';
class BitcoinRPC {
constructor() {
this.client = new Client({
host: process.env.BITCOIN_RPC_HOST || 'localhost',
port: parseInt(process.env.BITCOIN_RPC_PORT || '38332'),
username: process.env.BITCOIN_RPC_USER || 'bitcoin',
password: process.env.BITCOIN_RPC_PASSWORD || 'bitcoin',
timeout: parseInt(process.env.BITCOIN_RPC_TIMEOUT || '30000'),
});
}
/**
* Obtient les informations sur la blockchain
* @returns {Promise<Object>} Informations sur la blockchain
*/
async getBlockchainInfo() {
try {
return await this.client.getBlockchainInfo();
} catch (error) {
logger.error('Error getting blockchain info', { error: error.message });
throw new Error(`Failed to get blockchain info: ${error.message}`);
}
}
/**
* Obtient le dernier bloc miné
* @returns {Promise<Object>} Informations sur le dernier bloc
*/
async getLatestBlock() {
try {
const blockchainInfo = await this.client.getBlockchainInfo();
const bestBlockHash = blockchainInfo.bestblockhash;
const block = await this.client.getBlock(bestBlockHash, 2); // Verbose level 2
return {
hash: block.hash,
height: block.height,
time: block.time,
mediantime: block.mediantime,
tx_count: block.tx ? block.tx.length : 0,
size: block.size,
weight: block.weight,
};
} catch (error) {
logger.error('Error getting latest block', { error: error.message });
throw new Error(`Failed to get latest block: ${error.message}`);
}
}
/**
* Obtient le solde du wallet (mature et immature)
* @returns {Promise<Object>} Solde du wallet
*/
async getWalletBalance() {
try {
// Utiliser command() pour appeler directement getbalances() qui retourne mature, immature, et unconfirmed
// Si getbalances() n'est pas disponible, utiliser getBalance() avec différents minconf
let balances;
try {
// Utiliser command() pour appeler getbalances() directement (méthode RPC de Bitcoin Core)
balances = await this.client.command('getbalances');
// getbalances() retourne { "mine": { "trusted": ..., "untrusted_pending": ..., "immature": ... } }
const mine = balances.mine || {};
return {
mature: Math.round((mine.trusted || 0)),
immature: Math.round((mine.immature || 0)),
unconfirmed: mine.untrusted_pending || 0,
total: (mine.trusted || 0) + (mine.immature || 0) + (mine.untrusted_pending || 0),
};
} catch (error) {
// Fallback si getbalances() n'est pas disponible
logger.debug('getbalances() not available, using getBalance()', { error: error.message });
// getBalance() retourne le solde mature (confirmé avec au moins 1 confirmation)
const balance = await this.client.getBalance();
// getBalance avec minconf=0 retourne le solde total (mature + immature)
const totalBalance = await this.client.getBalance('*', 0);
// Calculer le solde immature
const immatureBalance = Math.max(0, totalBalance - balance);
// Obtenir les transactions non confirmées depuis listUnspent
let unconfirmedBalance = 0;
try {
const unspent = await this.client.listUnspent(0); // 0 = inclure les non confirmés
for (const utxo of unspent) {
if (utxo.confirmations === 0) {
unconfirmedBalance += utxo.amount;
}
}
} catch (error) {
// Si listUnspent échoue, unconfirmedBalance reste à 0
logger.debug('Could not get unconfirmed balance', { error: error.message });
}
return {
mature: Math.round(balance),
immature: Math.round(immatureBalance),
unconfirmed: unconfirmedBalance,
total: totalBalance + unconfirmedBalance,
};
}
} catch (error) {
logger.error('Error getting wallet balance', { error: error.message });
throw new Error(`Failed to get wallet balance: ${error.message}`);
}
}
/**
* Obtient le nombre de pairs connectés
* @returns {Promise<Object>} Informations sur les pairs
*/
async getNetworkPeers() {
try {
const networkInfo = await this.client.getNetworkInfo();
const peerInfo = await this.client.getPeerInfo();
return {
connections: networkInfo.connections,
peers: peerInfo.map(peer => ({
addr: peer.addr,
services: peer.services,
version: peer.version,
subver: peer.subver,
})),
};
} catch (error) {
logger.error('Error getting network peers', { error: error.message });
throw new Error(`Failed to get network peers: ${error.message}`);
}
}
/**
* Obtient la liste des hash ancrés avec leurs transactions
* 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>;<date>
* @returns {Promise<Array<Object>>} Liste des hash avec leurs transactions
*/
async getHashList() {
try {
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');
const hashList = [];
// 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 parts = line.split(';');
const [hash, txid, blockHeight, confirmations, date] = parts;
if (hash && txid) {
hashList.push({
hash,
txid,
blockHeight: blockHeight ? parseInt(blockHeight, 10) : null,
confirmations: confirmations ? parseInt(confirmations, 10) : 0,
date: date || new Date().toISOString(), // Date actuelle si manquante
});
}
}
}
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);
startHeight = cachedHeight + 1;
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);
}
}
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 });
}
// 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++) {
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)) {
// Extraire le hash depuis le script
const hashStart = scriptHex.indexOf(anchorPrefix) + anchorPrefix.length;
const hashHex = scriptHex.substring(hashStart, hashStart + 64);
if (/^[0-9a-fA-F]{64}$/.test(hashHex)) {
const confirmations = currentHeight - height + 1;
hashList.push({
hash: hashHex.toLowerCase(),
txid: tx.txid,
blockHeight: height,
confirmations,
date: new Date().toISOString(),
});
}
break; // Un seul hash par transaction
}
}
}
} catch (error) {
logger.debug('Error checking transaction for hash', { txid: tx.txid, error: error.message });
}
}
}
// Mettre à jour le cache tous les 100 blocs
if (height % 100 === 0 || height === currentHeight) {
const now = new Date().toISOString();
const cacheContent = `${now};${height};${blockHash}`;
writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie avec date
const outputLines = hashList.map((item) =>
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
);
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
logger.debug('Hash list cache updated', { height, count: hashList.length });
}
} catch (error) {
logger.debug('Error checking block for hashes', { height, error: error.message });
}
}
// Mettre à jour le cache final
const now = new Date().toISOString();
const cacheContent = `${now};${currentHeight};${currentBlockHash}`;
writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie final avec date
const outputLines = hashList.map((item) =>
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
);
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;
} catch (error) {
logger.error('Error getting hash list', { error: error.message });
throw new Error(`Failed to get hash list: ${error.message}`);
}
}
/**
* Obtient la liste des UTXO avec leurs montants, catégorisés en 3 types :
* - bloc_rewards : UTXO provenant de transactions coinbase (minage)
* - ancrages : UTXO provenant de transactions d'ancrage
* - changes : UTXO provenant d'autres transactions (monnaie de retour)
* Utilise un fichier de cache utxo_list_cache.txt pour éviter de tout recompter
* Format du cache: <date>
* Format du fichier de sortie: <category>;<txid>;<vout>;<address>;<amount>;<confirmations>
* @returns {Promise<Object>} Objet avec 3 listes : blocRewards, anchors, changes
*/
async getUtxoList() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const cachePath = join(__dirname, '../../utxo_list_cache.txt');
const outputPath = join(__dirname, '../../utxo_list.txt');
// Charger les UTXOs existants depuis le fichier texte d'abord
const existingUtxosMap = new Map(); // Clé: "txid:vout", Valeur: utxoItem
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(';');
// 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) {
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: '', // Plus stocké dans le fichier
amount: parseFloat(amount),
confirmations: parseInt(confirmations, 10) || 0,
category,
// Ces champs seront mis à jour si nécessaire
isSpentOnchain: false,
isLockedInMutex: false,
blockHeight: null,
blockTime: blockTime ? parseInt(blockTime, 10) : null,
isAnchorChange: isAnchorChange === 'true' || isAnchorChange === true,
};
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 });
}
}
// 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(';');
// Format attendu : <date>;<hauteur> (2 parties)
// Format ancien : <date> (1 partie) - nécessite une mise à jour
if (parts.length === 2) {
// Nouveau format avec hauteur
const cachedHeight = parseInt(parts[1], 10);
if (!isNaN(cachedHeight) && cachedHeight >= 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 });
}
} else {
// Hauteur invalide, forcer la mise à jour
logger.warn('Invalid height in UTXO cache, forcing update');
needsUpdate = true;
}
} else if (parts.length === 1) {
// Ancien format sans hauteur, forcer la mise à jour pour réécrire avec le bon format
logger.info('Old UTXO cache format detected (without height), forcing update to rewrite cache');
needsUpdate = true;
} else {
// Format inattendu, forcer la mise à jour
logger.warn('Unexpected UTXO cache format, forcing update', { partsCount: parts.length });
needsUpdate = true;
}
} 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);
}
}
// Charger les frais depuis fees_list.txt si disponible
const feesListPath = join(__dirname, '../../fees_list.txt');
if (existsSync(feesListPath)) {
try {
const feesContent = readFileSync(feesListPath, 'utf8').trim();
if (feesContent) {
const feesLines = feesContent.split('\n');
for (const line of feesLines) {
if (line.trim()) {
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
const parts = line.split(';');
if (parts.length >= 3) {
const feeObj = {
txid: parts[0],
fee: parseFloat(parts[1]) || 0,
fee_sats: parseInt(parts[2], 10) || 0,
blockHeight: parts[3] ? parseInt(parts[3], 10) : null,
blockTime: parts[4] ? parseInt(parts[4], 10) : null,
confirmations: parts[5] ? parseInt(parts[5], 10) : 0,
changeAddress: parts[6] || null,
changeAmount: parts[7] ? parseFloat(parts[7]) : null,
};
fees.push(feeObj);
}
}
}
}
} catch (error) {
logger.warn('Error reading fees_list.txt', { error: error.message });
}
}
// 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) {
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 le montant est identique
// Les confirmations peuvent changer (augmenter) mais le montant reste constant
if (existing &&
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é (montant différent), 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();
try {
const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010';
const anchorApiKey = process.env.ANCHOR_API_KEY || '';
const headers = {
'Content-Type': 'application/json',
};
if (anchorApiKey) {
headers['x-api-key'] = anchorApiKey;
}
const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, {
method: 'GET',
headers,
});
if (lockedResponse.ok) {
const lockedData = await lockedResponse.json();
for (const locked of lockedData.locked || []) {
lockedUtxos.add(`${locked.txid}:${locked.vout}`);
}
logger.debug('Locked UTXOs retrieved', { count: lockedUtxos.size });
}
} catch (error) {
logger.debug('Error getting locked UTXOs from anchor API', { error: error.message });
}
// Mettre à jour les informations dynamiques pour les UTXOs existants
// (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);
} else {
// UTXO n'est plus dans listunspent, il a été dépensé
existingUtxo.isSpentOnchain = true;
}
}
// 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
// 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 {
// 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;
// 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}`;
const isLockedInMutex = lockedUtxos.has(utxoKey);
const utxoItem = {
txid: utxo.txid,
vout: utxo.vout,
address: '', // Plus stocké
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
isSpentOnchain,
isLockedInMutex,
};
// Vérifier si c'est une transaction coinbase (récompense de minage)
// Une transaction coinbase doit avoir exactement un input avec le champ coinbase défini
const isCoinbase = rawTx.vin && rawTx.vin.length === 1 &&
rawTx.vin[0].coinbase !== undefined &&
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';
return { utxoItem, category: 'bloc_rewards' };
}
// Vérifier si c'est une transaction d'ancrage (contient OP_RETURN avec "ANCHOR:")
let isAnchorTx = false;
let blockHeight = null;
// Obtenir la hauteur du bloc et le blocktime si la transaction est confirmée
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', { txid: utxo.txid, error: error.message });
}
}
utxoItem.blockHeight = blockHeight;
utxoItem.blockTime = blockTime;
// Extraire les métadonnées onchain (change et frais) depuis l'OP_RETURN
let onchainChangeAddress = null;
let onchainChangeAmount = null;
let onchainFeeAmount = null;
if (rawTx.vout) {
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)) {
isAnchorTx = true;
// Extraire les métadonnées depuis l'OP_RETURN
// Format: "ANCHOR:" + hash (32 bytes) + "|CHANGE:<address>:<amount_sats>|FEE:<amount_sats>"
try {
// Le script hex contient les données encodées
// "ANCHOR:" en hex = "414e43484f523a"
// Le hash suit (64 caractères hex = 32 bytes)
// Puis "|" en hex = "7c"
// Puis les métadonnées en UTF-8
const anchorPrefixHex = Buffer.from('ANCHOR:', 'utf8').toString('hex');
const hashLengthHex = 64; // 64 caractères hex pour 32 bytes
const separatorHex = Buffer.from('|', 'utf8').toString('hex');
// Trouver la position de "ANCHOR:" dans le script hex
const anchorPos = scriptHex.indexOf(anchorPrefixHex);
if (anchorPos !== -1) {
// Position après "ANCHOR:" + hash (64 caractères hex)
const afterHashPos = anchorPos + anchorPrefixHex.length + hashLengthHex;
// Chercher le séparateur "|" après le hash
const separatorPos = scriptHex.indexOf(separatorHex, afterHashPos);
if (separatorPos !== -1) {
// Extraire les métadonnées après le séparateur
const metadataHex = scriptHex.substring(separatorPos + separatorHex.length);
// Convertir de hex à UTF-8
const metadataBuffer = Buffer.from(metadataHex, 'hex');
const metadataString = metadataBuffer.toString('utf8');
// Parser les métadonnées: "CHANGE:<address>:<amount_sats>|FEE:<amount_sats>"
const parts = metadataString.split('|');
for (const part of parts) {
if (part.startsWith('CHANGE:')) {
const changeData = part.substring(7); // Enlever "CHANGE:"
const changeParts = changeData.split(':');
if (changeParts.length === 2 && changeParts[0] !== 'none') {
onchainChangeAddress = changeParts[0];
onchainChangeAmount = parseInt(changeParts[1], 10) / 100000000; // Convertir sats en BTC
}
} else if (part.startsWith('FEE:')) {
const feeData = part.substring(4); // Enlever "FEE:"
onchainFeeAmount = parseInt(feeData, 10) / 100000000; // Convertir sats en BTC
}
}
}
}
} catch (error) {
logger.debug('Error parsing OP_RETURN metadata', { txid: utxo.txid, error: error.message });
}
break;
}
}
}
}
if (isAnchorTx) {
// Dans une transaction d'ancrage, distinguer les outputs d'ancrage/provisionnement du change
// Les transactions d'ancrage créent :
// - 1 output OP_RETURN (non dépensable, généralement le premier output)
// - 1 output d'ancrage de 2500 sats (0.000025 BTC)
// - 7 outputs de provisionnement de 2500 sats chacun
// - 1 output de change (seulement si change > 0.00001 BTC = 1000 sats)
const utxoAmountSats = Math.round(utxo.amount * 100000000);
const anchorAmountSats = 2500; // Montant standard des outputs d'ancrage/provisionnement
const minChangeSats = 1000; // Change minimum créé (0.00001 BTC)
// Identifier précisément le type d'output en analysant la transaction
let isAnchorOutput = false;
let isChangeOutput = false;
if (rawTx.vout && rawTx.vout[utxo.vout]) {
const output = rawTx.vout[utxo.vout];
// 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)
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)
if (Math.abs(utxoAmountSats - anchorAmountSats) <= 1) {
isAnchorOutput = true;
} else if (utxoAmountSats >= minChangeSats) {
// Le change dans une transaction d'ancrage est > 1000 sats et différent de 2500 sats
isChangeOutput = true;
}
}
if (isAnchorOutput) {
utxoItem.category = 'ancrages';
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';
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;
}
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;
return { utxoItem, category: 'changes', isAnchorChange: true };
}
} 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
return { utxoItem, category: 'changes', isAnchorChange: false };
}
} catch (error) {
// En cas d'erreur, classer comme change par défaut
logger.debug('Error categorizing UTXO', { txid: utxo.txid, error: error.message });
const errorUtxoItem = {
txid: utxo.txid,
vout: utxo.vout,
address: '', // Plus stocké
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
category: 'changes',
isSpentOnchain: false,
isLockedInMutex: false,
};
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);
}
}
}
}
}
// 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)
}
}
// Trier chaque catégorie par montant décroissant
blocRewards.sort((a, b) => b.amount - a.amount);
anchors.sort((a, b) => b.amount - a.amount);
// Pour les changes, trier d'abord par type (ancrage en premier), puis par montant décroissant
changes.sort((a, b) => {
// Les changes d'ancrage en premier
if (a.isAnchorChange && !b.isAnchorChange) return -1;
if (!a.isAnchorChange && b.isAnchorChange) return 1;
// Puis par montant décroissant
return b.amount - a.amount;
});
// Calculer le nombre d'UTXO disponibles pour l'ancrage (> 2000 sats, confirmés et non dépensés)
const allUtxos = [...blocRewards, ...anchors, ...changes];
const minAnchorAmount = 2000 / 100000000; // 2000 sats en BTC
const availableForAnchor = allUtxos.filter(utxo =>
utxo.amount >= minAnchorAmount &&
(utxo.confirmations || 0) > 0 && // Only confirmed UTXOs
!utxo.isSpentOnchain &&
!utxo.isLockedInMutex
).length;
// Compter les UTXOs confirmés disponibles pour l'ancrage
const confirmedAvailableForAnchor = allUtxos.filter(utxo =>
utxo.amount >= minAnchorAmount &&
(utxo.confirmations || 0) > 0 && // Only confirmed UTXOs
!utxo.isSpentOnchain &&
!utxo.isLockedInMutex
).length;
// Mettre à jour le cache avec le format: <date>;<hauteur>
const now = new Date().toISOString();
const cacheContent = `${now};${currentHeight}`;
writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie avec toutes les catégories (incluant les UTXOs dépensés pour historique)
// Format: category;txid;vout;amount;confirmations;isAnchorChange;blockTime
const outputLines = Array.from(existingUtxosMap.values()).map((item) => {
const isAnchorChange = item.isAnchorChange ? 'true' : 'false';
const blockTime = item.blockTime || '';
return `${item.category};${item.txid};${item.vout};${item.amount};${item.confirmations};${isAnchorChange};${blockTime}`;
});
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
// Analyser la distribution pour comprendre pourquoi il y a si peu de changes
const anchorTxChanges = changes.filter(utxo => {
// Vérifier si cet UTXO provient d'une transaction d'ancrage
// (on ne peut pas le vérifier directement ici, mais on peut analyser les montants)
const utxoAmountSats = Math.round(utxo.amount * 100000000);
return utxoAmountSats > 2500; // Change d'une transaction d'ancrage serait > 2500 sats
});
const normalTxChanges = changes.length - anchorTxChanges.length;
// Trier les frais par blockHeight décroissant (plus récent en premier)
fees.sort((a, b) => {
if (a.blockHeight === null && b.blockHeight === null) return 0;
if (a.blockHeight === null) return 1;
if (b.blockHeight === null) return -1;
return b.blockHeight - a.blockHeight;
});
// Charger les frais depuis fees_list.txt si disponible
const feesListPath = join(__dirname, '../../fees_list.txt');
if (existsSync(feesListPath)) {
try {
const feesContent = readFileSync(feesListPath, 'utf8').trim();
if (feesContent) {
const feesLines = feesContent.split('\n');
for (const line of feesLines) {
if (line.trim()) {
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
const parts = line.split(';');
if (parts.length >= 3) {
const feeObj = {
txid: parts[0],
fee: parseFloat(parts[1]) || 0,
fee_sats: parseInt(parts[2], 10) || 0,
blockHeight: parts[3] ? parseInt(parts[3], 10) : null,
blockTime: parts[4] ? parseInt(parts[4], 10) : null,
confirmations: parts[5] ? parseInt(parts[5], 10) : 0,
changeAddress: parts[6] || null,
changeAmount: parts[7] ? parseFloat(parts[7]) : null,
};
// Vérifier si pas déjà dans fees (éviter doublons)
if (!fees.find(f => f.txid === feeObj.txid)) {
fees.push(feeObj);
}
}
}
}
}
} catch (error) {
logger.warn('Error reading fees_list.txt', { error: error.message });
}
}
logger.info('UTXO list saved', {
blocRewards: blocRewards.length,
anchors: anchors.length,
changes: changes.length,
changesFromAnchorTx: anchorTxChanges.length,
changesFromNormalTx: normalTxChanges,
fees: fees.length,
total: allUtxos.length,
availableForAnchor,
});
return {
blocRewards,
anchors,
changes,
fees,
total: allUtxos.length,
availableForAnchor,
confirmedAvailableForAnchor,
};
} catch (error) {
logger.error('Error getting UTXO list', { error: error.message });
throw new Error(`Failed to get UTXO list: ${error.message}`);
}
}
/**
* Obtient les informations sur les UTXOs de moins de 2500 sats disponibles pour consolidation
* @returns {Promise<Object>} Nombre et montant total des petits UTXOs
*/
async getSmallUtxosInfo() {
try {
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');
// Récupérer les UTXOs confirmés
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
}),
});
if (!rpcResponse.ok) {
const errorText = await rpcResponse.text();
logger.error('HTTP error in listunspent for small UTXOs info', {
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 for small UTXOs info', { error: rpcResult.error });
throw new Error(`RPC error: ${rpcResult.error.message}`);
}
const unspent = rpcResult.result || [];
// Récupérer les UTXOs verrouillés depuis l'API d'ancrage
let lockedUtxos = new Set();
try {
const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010';
const anchorApiKey = process.env.ANCHOR_API_KEY || '';
const headers = {
'Content-Type': 'application/json',
};
if (anchorApiKey) {
headers['x-api-key'] = anchorApiKey;
}
const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, {
method: 'GET',
headers,
});
if (lockedResponse.ok) {
const lockedData = await lockedResponse.json();
for (const locked of lockedData.locked || []) {
lockedUtxos.add(`${locked.txid}:${locked.vout}`);
}
}
} catch (error) {
logger.debug('Error getting locked UTXOs for small UTXOs info', { error: error.message });
}
// Filtrer les UTXOs de moins de 2500 sats (0.000025 BTC), confirmés et non verrouillés
const maxAmount = 0.000025; // 2500 sats
const smallUtxos = unspent.filter(utxo => {
const utxoKey = `${utxo.txid}:${utxo.vout}`;
return utxo.amount < maxAmount &&
(utxo.confirmations || 0) > 0 &&
!lockedUtxos.has(utxoKey);
});
// Vérifier si l'UTXO est dépensé onchain et calculer le montant total
let count = 0;
let totalAmount = 0;
for (const utxo of smallUtxos) {
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: [utxo.txid, utxo.vout],
}),
});
if (txOutResponse.ok) {
const txOutResult = await txOutResponse.json();
// Si gettxout retourne null, l'UTXO est dépensé
if (txOutResult.result !== null) {
count++;
totalAmount += utxo.amount;
}
}
} catch (error) {
logger.debug('Error checking if UTXO is spent for small UTXOs info', { txid: utxo.txid, vout: utxo.vout, error: error.message });
}
}
const totalSats = Math.round(totalAmount * 100000000);
return {
count,
totalAmount,
totalSats,
};
} catch (error) {
logger.error('Error getting small UTXOs info', { error: error.message });
throw error;
}
}
/**
* Consolide les UTXOs de moins de 2500 sats en un gros UTXO
* @returns {Promise<Object>} Transaction créée avec txid
*/
async consolidateSmallUtxos() {
try {
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');
// Récupérer les UTXOs confirmés
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
}),
});
if (!rpcResponse.ok) {
const errorText = await rpcResponse.text();
logger.error('HTTP error in listunspent for consolidation', {
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 for consolidation', { error: rpcResult.error });
throw new Error(`RPC error: ${rpcResult.error.message}`);
}
const unspent = rpcResult.result || [];
// Récupérer les UTXOs verrouillés depuis l'API d'ancrage
let lockedUtxos = new Set();
try {
const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010';
const anchorApiKey = process.env.ANCHOR_API_KEY || '';
const headers = {
'Content-Type': 'application/json',
};
if (anchorApiKey) {
headers['x-api-key'] = anchorApiKey;
}
const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, {
method: 'GET',
headers,
});
if (lockedResponse.ok) {
const lockedData = await lockedResponse.json();
for (const locked of lockedData.locked || []) {
lockedUtxos.add(`${locked.txid}:${locked.vout}`);
}
}
} catch (error) {
logger.debug('Error getting locked UTXOs for consolidation', { error: error.message });
}
// Filtrer les UTXOs de moins de 2500 sats (0.000025 BTC), confirmés, non dépensés et non verrouillés
const maxAmount = 0.000025; // 2500 sats
const smallUtxos = unspent.filter(utxo => {
const utxoKey = `${utxo.txid}:${utxo.vout}`;
return utxo.amount < maxAmount &&
(utxo.confirmations || 0) > 0 &&
!lockedUtxos.has(utxoKey);
});
// Vérifier si l'UTXO est dépensé onchain
const availableSmallUtxos = [];
for (const utxo of smallUtxos) {
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: [utxo.txid, utxo.vout],
}),
});
if (txOutResponse.ok) {
const txOutResult = await txOutResponse.json();
// Si gettxout retourne null, l'UTXO est dépensé
if (txOutResult.result !== null) {
availableSmallUtxos.push(utxo);
}
}
} catch (error) {
logger.debug('Error checking if UTXO is spent for consolidation', { txid: utxo.txid, vout: utxo.vout, error: error.message });
}
}
if (availableSmallUtxos.length === 0) {
throw new Error('No small UTXOs available for consolidation');
}
logger.info('Consolidating small UTXOs', {
count: availableSmallUtxos.length,
totalAmount: availableSmallUtxos.reduce((sum, utxo) => sum + utxo.amount, 0),
});
// Calculer le montant total
const totalAmount = availableSmallUtxos.reduce((sum, utxo) => sum + utxo.amount, 0);
// Estimation des frais : base + frais par input
const estimatedFeePerInput = 0.000001; // Frais par input (conservateur)
const estimatedFeeBase = 0.00001; // Frais de base
const estimatedFee = estimatedFeeBase + (availableSmallUtxos.length * estimatedFeePerInput);
// Arrondir à 8 décimales
const roundTo8Decimals = (amount) => {
return Math.round(amount * 100000000) / 100000000;
};
const change = roundTo8Decimals(totalAmount - estimatedFee);
if (change <= 0) {
throw new Error('Consolidation would result in negative or zero change after fees');
}
// Obtenir une adresse de destination pour le gros UTXO consolidé
const address = await this.getNewAddress();
// Créer les inputs
const inputs = availableSmallUtxos.map(utxo => ({
txid: utxo.txid,
vout: utxo.vout,
}));
// Créer les outputs
const outputs = {
[address]: change,
};
// Créer la transaction
const createTxResponse = await fetch(rpcUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`,
},
body: JSON.stringify({
jsonrpc: '1.0',
id: 'createrawtransaction',
method: 'createrawtransaction',
params: [inputs, outputs],
}),
});
if (!createTxResponse.ok) {
const errorText = await createTxResponse.text();
throw new Error(`HTTP error creating transaction: ${createTxResponse.status} ${errorText}`);
}
const createTxResult = await createTxResponse.json();
if (createTxResult.error) {
throw new Error(`RPC error creating transaction: ${createTxResult.error.message}`);
}
const rawTx = createTxResult.result;
// Signer la transaction
const signTxResponse = await fetch(rpcUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`,
},
body: JSON.stringify({
jsonrpc: '1.0',
id: 'signrawtransactionwithwallet',
method: 'signrawtransactionwithwallet',
params: [rawTx],
}),
});
if (!signTxResponse.ok) {
const errorText = await signTxResponse.text();
throw new Error(`HTTP error signing transaction: ${signTxResponse.status} ${errorText}`);
}
const signTxResult = await signTxResponse.json();
if (signTxResult.error) {
throw new Error(`RPC error signing transaction: ${signTxResult.error.message}`);
}
if (!signTxResult.result.complete) {
throw new Error('Transaction signing failed');
}
// Envoyer la transaction au mempool
const sendTxResponse = await fetch(rpcUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`,
},
body: JSON.stringify({
jsonrpc: '1.0',
id: 'sendrawtransaction',
method: 'sendrawtransaction',
params: [signTxResult.result.hex, 0], // maxfeerate = 0 (accepter n'importe quel taux)
}),
});
if (!sendTxResponse.ok) {
const errorText = await sendTxResponse.text();
throw new Error(`HTTP error sending transaction: ${sendTxResponse.status} ${errorText}`);
}
const sendTxResult = await sendTxResponse.json();
if (sendTxResult.error) {
throw new Error(`RPC error sending transaction: ${sendTxResult.error.message}`);
}
const txid = sendTxResult.result;
logger.info('Consolidation transaction sent to mempool', {
txid,
inputCount: availableSmallUtxos.length,
totalInputAmount: totalAmount,
changeAmount: change,
estimatedFee,
});
return {
txid,
inputCount: availableSmallUtxos.length,
totalInputAmount: totalAmount,
changeAmount: change,
estimatedFee,
};
} catch (error) {
logger.error('Error consolidating small UTXOs', { error: error.message });
throw error;
}
}
/**
* Met à jour les frais depuis les transactions d'ancrage
* Récupère les frais depuis OP_RETURN des transactions d'ancrage et les stocke dans fees_list.txt
* @param {number} sinceBlockHeight - Hauteur de bloc à partir de laquelle récupérer (optionnel, depuis dernier frais du fichier)
* @returns {Promise<Object>} Résultat avec nombre de frais récupérés
*/
async updateFeesFromAnchors(sinceBlockHeight = null) {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const feesListPath = join(__dirname, '../../fees_list.txt');
const utxoListPath = join(__dirname, '../../utxo_list.txt');
// Lire les frais existants
const existingFees = new Map();
if (existsSync(feesListPath)) {
try {
const content = readFileSync(feesListPath, 'utf8').trim();
if (content) {
const lines = content.split('\n');
for (const line of lines) {
if (line.trim()) {
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
const parts = line.split(';');
if (parts.length >= 2) {
const txid = parts[0];
existingFees.set(txid, line);
}
}
}
}
} catch (error) {
logger.warn('Error reading fees_list.txt', { error: error.message });
}
}
// Déterminer depuis quelle hauteur récupérer
let startHeight = sinceBlockHeight;
if (!startHeight) {
// Trouver la hauteur maximale des frais existants
let maxHeight = 0;
for (const line of existingFees.values()) {
const parts = line.split(';');
if (parts.length >= 4 && parts[3]) {
const height = parseInt(parts[3], 10);
if (!isNaN(height) && height > maxHeight) {
maxHeight = height;
}
}
}
startHeight = maxHeight;
}
// Lire les ancrages depuis utxo_list.txt pour obtenir les txids
const anchorTxids = new Set();
if (existsSync(utxoListPath)) {
try {
const content = readFileSync(utxoListPath, 'utf8').trim();
if (content) {
const lines = content.split('\n');
for (const line of lines) {
if (line.trim()) {
const parts = line.split(';');
if (parts.length >= 2 && parts[0] === 'ancrages') {
const txid = parts[1];
anchorTxids.add(txid);
}
}
}
}
} catch (error) {
logger.warn('Error reading utxo_list.txt for anchors', { error: error.message });
}
}
// Récupérer les frais depuis les transactions d'ancrage
const newFees = [];
let processed = 0;
const totalAnchors = anchorTxids.size;
for (const txid of anchorTxids) {
// Ignorer si déjà dans les frais existants
if (existingFees.has(txid)) {
continue;
}
try {
const rawTx = await this.client.getRawTransaction(txid, true);
if (!rawTx || !rawTx.vout) continue;
let onchainFeeAmount = null;
let blockHeight = null;
let blockTime = null;
let changeAddress = null;
let changeAmount = null;
// Extraire les métadonnées depuis OP_RETURN
for (const output of rawTx.vout) {
if (output.scriptPubKey && output.scriptPubKey.hex) {
const scriptHex = output.scriptPubKey.hex;
const anchorPrefixHex = Buffer.from('ANCHOR:', 'utf8').toString('hex');
if (scriptHex.includes(anchorPrefixHex)) {
try {
const hashLengthHex = 64;
const separatorHex = Buffer.from('|', 'utf8').toString('hex');
const anchorPos = scriptHex.indexOf(anchorPrefixHex);
if (anchorPos !== -1) {
const afterHashPos = anchorPos + anchorPrefixHex.length + hashLengthHex;
const separatorPos = scriptHex.indexOf(separatorHex, afterHashPos);
if (separatorPos !== -1) {
const metadataHex = scriptHex.substring(separatorPos + separatorHex.length);
const metadataBuffer = Buffer.from(metadataHex, 'hex');
const metadataString = metadataBuffer.toString('utf8');
const parts = metadataString.split('|');
for (const part of parts) {
if (part.startsWith('CHANGE:')) {
const changeData = part.substring(7);
const changeParts = changeData.split(':');
if (changeParts.length === 2 && changeParts[0] !== 'none') {
changeAddress = changeParts[0];
changeAmount = parseInt(changeParts[1], 10) / 100000000;
}
} else if (part.startsWith('FEE:')) {
const feeData = part.substring(4);
onchainFeeAmount = parseInt(feeData, 10) / 100000000;
}
}
}
}
} catch (error) {
logger.debug('Error parsing OP_RETURN metadata for fees', { txid, error: error.message });
}
break;
}
}
}
// Récupérer blockHeight et blockTime si disponible
if (rawTx.confirmations > 0) {
try {
const txInfo = await this.client.getTransaction(txid);
blockHeight = txInfo.blockheight || null;
blockTime = txInfo.blocktime || null;
} catch (error) {
logger.debug('Error getting transaction block info for fees', { txid, error: error.message });
}
}
// Ajouter seulement si frais trouvés
if (onchainFeeAmount !== null && onchainFeeAmount > 0) {
const feeSats = Math.round(onchainFeeAmount * 100000000);
const confirmations = rawTx.confirmations || 0;
newFees.push({
txid,
fee: onchainFeeAmount,
fee_sats: feeSats,
blockHeight: blockHeight || '',
blockTime: blockTime || '',
confirmations,
changeAddress: changeAddress || '',
changeAmount: changeAmount || '',
});
}
processed++;
if (processed % 10 === 0) {
logger.debug('Processing fees from anchors', { processed, total: totalAnchors });
}
} catch (error) {
logger.debug('Error processing anchor transaction for fees', { txid, error: error.message });
}
}
// Ajouter les nouveaux frais au fichier
if (newFees.length > 0) {
const feeLines = newFees.map((fee) =>
`${fee.txid};${fee.fee};${fee.fee_sats};${fee.blockHeight};${fee.blockTime};${fee.confirmations};${fee.changeAddress};${fee.changeAmount}`
);
const existingLines = Array.from(existingFees.values());
const allLines = [...existingLines, ...feeLines];
writeFileSync(feesListPath, allLines.join('\n'), 'utf8');
logger.info('Fees list updated', { newFees: newFees.length, total: allLines.length });
}
return {
success: true,
newFees: newFees.length,
totalFees: existingFees.size + newFees.length,
processed,
};
} catch (error) {
logger.error('Error updating fees from anchors', { error: error.message });
throw error;
}
}
/**
* 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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const hashListPath = join(__dirname, '../../hash_list.txt');
// Lire directement depuis le fichier texte
if (existsSync(hashListPath)) {
try {
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 hash_list.txt', { error: error.message });
}
}
// 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}`);
}
}
}
// Export singleton
export const bitcoinRPC = new BitcoinRPC();