**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
1745 lines
70 KiB
JavaScript
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();
|