ncantu 1d4b0d8f33 Pagination serveur, correction UTXO déjà dépensé et synchronisation automatique
**Motivations:**
- Réduire la consommation mémoire en paginant côté serveur au lieu de charger toutes les données
- Corriger les erreurs "Input not found or already spent" dans l'API d'ancrage
- Maintenir la synchronisation entre la base de données et l'état réel de Bitcoin
- Améliorer l'expérience utilisateur avec un suivi de progression pour la collecte de signatures

**Root causes:**
- Pagination effectuée côté client : le serveur retournait tous les UTXOs/hashes (68k+ UTXOs, 32k+ hashes) puis le frontend paginait en JavaScript
- Désynchronisation entre la DB et Bitcoin : UTXOs dépensés non mis à jour dans la base de données
- Détection d'erreur incomplète : ne couvrait pas tous les cas ("already spent", "input not found")
- Pas de vérification de disponibilité de l'UTXO juste avant utilisation dans une transaction

**Correctifs:**
- Implémentation de la pagination côté serveur pour `/api/utxo/list` et `/api/hash/list` avec paramètres `page` et `limit`
- Amélioration de la détection d'erreur pour inclure "already spent" et "input not found"
- Ajout d'une vérification de disponibilité de l'UTXO avant utilisation avec mécanisme de retry (max 3 tentatives)
- Mise à jour automatique de tous les UTXOs dépensés dans la base de données lors de chaque synchronisation
- Script de synchronisation périodique avec cron job toutes les heures
- Optimisation mémoire : utilisation de tables temporaires SQL au lieu de charger tous les UTXOs en mémoire

**Evolutions:**
- Pagination serveur avec métadonnées (total, totalPages, page, limit) pour les endpoints `/api/utxo/list` et `/api/hash/list`
- Adaptation du frontend pour utiliser la pagination serveur (compatibilité maintenue avec chargement jusqu'à 1000 éléments)
- Ajout de `onProgress` callback dans `runCollectLoop` pour notifier la progression de la collecte de signatures
- Nouvelle fonction `collectProgress` pour calculer la progression (satisfied vs required) pour les notifications/UI
- Refactoring de `hasEnoughSignatures` avec extraction de `pairsPerMemberFromSigs` pour réutilisabilité

**Pages affectées:**
- `api-anchorage/src/bitcoin-rpc.js` : Vérification disponibilité UTXO, amélioration détection erreur, paramètre retryCount
- `api-anchorage/src/routes/anchor.js` : Passage des nouveaux paramètres à createAnchorTransaction
- `signet-dashboard/src/server.js` : Pagination pour `/api/hash/list` et `/api/utxo/list`
- `signet-dashboard/src/bitcoin-rpc.js` : Mise à jour automatique de tous les UTXOs dépensés avec optimisation mémoire
- `signet-dashboard/public/hash-list.html` : Adaptation pour charger avec pagination serveur
- `signet-dashboard/public/utxo-list.html` : Adaptation pour utiliser la pagination serveur par catégorie
- `userwallet/src/utils/collectSignatures.ts` : Ajout interface CollectLoopOpts avec onProgress callback
- `userwallet/src/utils/loginValidation.ts` : Ajout fonction collectProgress, refactoring avec pairsPerMemberFromSigs
- `data/sync-utxos-spent-status.mjs` : Script de synchronisation périodique des UTXOs dépensés
- `data/sync-utxos-cron.sh` : Script wrapper pour cron job
- `features/pagination-serveur-base-donnees.md` : Documentation de la pagination serveur
- `features/synchronisation-automatique-utxos-depenses.md` : Documentation de la synchronisation automatique
- `fixKnowledge/api-anchorage-utxo-already-spent-error.md` : Documentation de la correction de l'erreur UTXO déjà dépensé
2026-01-27 22:21:38 +01:00

1843 lines
73 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 { Buffer } from 'buffer';
import { getDatabase } from './database.js';
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 depuis la base de données et ne complète que les nouveaux blocs si nécessaire
* @returns {Promise<Array<Object>>} Liste des hash avec leurs transactions
*/
async getHashList() {
try {
const db = getDatabase();
const hashList = [];
// Lire depuis la base de données
const anchors = db.prepare('SELECT hash, txid, block_height, confirmations, date FROM anchors ORDER BY block_height ASC, id ASC').all();
for (const anchor of anchors) {
hashList.push({
hash: anchor.hash,
txid: anchor.txid,
blockHeight: anchor.block_height,
confirmations: anchor.confirmations || 0,
date: anchor.date || new Date().toISOString(),
});
}
logger.debug('Hash list loaded from database', { count: hashList.length });
// 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;
}
// Vérifier le cache dans la base de données
const cacheRow = db.prepare('SELECT value FROM cache WHERE key = ?').get('hash_list_cache');
if (cacheRow) {
try {
const parts = cacheRow.value.split(';');
if (parts.length >= 2) {
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 dans la base de données
const updateConfirmations = db.prepare(`
UPDATE anchors
SET confirmations = ?, updated_at = CURRENT_TIMESTAMP
WHERE block_height IS NOT NULL
`);
updateConfirmations.run(Math.max(0, currentHeight - (cachedHeight || 0) + 1));
logger.debug('Hash list up to date, confirmations updated in database', { count: hashList.length });
}
} else {
startHeight = 0;
needsUpdate = true;
}
} catch (error) {
logger.warn('Error reading hash list cache from database', { error: error.message });
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) {
const insertAnchor = db.prepare(`
INSERT OR IGNORE INTO anchors (hash, txid, block_height, confirmations, date, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
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;
const hash = hashHex.toLowerCase();
const date = new Date().toISOString();
// Insérer dans la base de données
insertAnchor.run(hash, tx.txid, height, confirmations, date);
// Ajouter à la liste pour le retour
hashList.push({
hash,
txid: tx.txid,
blockHeight: height,
confirmations,
date,
});
}
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}`;
const updateCache = db.prepare('INSERT OR REPLACE INTO cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)');
updateCache.run('hash_list_cache', cacheContent);
logger.debug('Hash list cache updated in database', { 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}`;
const updateCache = db.prepare('INSERT OR REPLACE INTO cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)');
updateCache.run('hash_list_cache', cacheContent);
// Recharger depuis la base de données pour retourner les données à jour
const updatedAnchors = db.prepare('SELECT hash, txid, block_height, confirmations, date FROM anchors ORDER BY block_height ASC, id ASC').all();
hashList.length = 0;
for (const anchor of updatedAnchors) {
hashList.push({
hash: anchor.hash,
txid: anchor.txid,
blockHeight: anchor.block_height,
confirmations: anchor.confirmations || 0,
date: anchor.date || new Date().toISOString(),
});
}
logger.info('Hash list saved to database', { currentHeight, count: hashList.length });
} else {
// Mettre à jour les confirmations seulement si nécessaire
if (currentHeight > 0) {
const updateConfirmations = db.prepare(`
UPDATE anchors
SET confirmations = ?, updated_at = CURRENT_TIMESTAMP
WHERE block_height IS NOT NULL
`);
// Calculer les confirmations pour chaque ancrage
const anchorsToUpdate = db.prepare('SELECT id, block_height FROM anchors WHERE block_height IS NOT NULL').all();
const updateStmt = db.prepare('UPDATE anchors SET confirmations = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
for (const anchor of anchorsToUpdate) {
const confirmations = Math.max(0, currentHeight - anchor.block_height + 1);
updateStmt.run(confirmations, anchor.id);
}
// Recharger depuis la base de données
const updatedAnchors = db.prepare('SELECT hash, txid, block_height, confirmations, date FROM anchors ORDER BY block_height ASC, id ASC').all();
hashList.length = 0;
for (const anchor of updatedAnchors) {
hashList.push({
hash: anchor.hash,
txid: anchor.txid,
blockHeight: anchor.block_height,
confirmations: anchor.confirmations || 0,
date: anchor.date || new Date().toISOString(),
});
}
}
}
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 la base de données SQLite pour stocker et récupérer les UTXOs
* @returns {Promise<Object>} Objet avec 3 listes : blocRewards, anchors, changes
*/
async getUtxoList() {
try {
const db = getDatabase();
// 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 dans la base de données pour déterminer si une mise à jour est nécessaire
const cacheRow = db.prepare('SELECT value FROM cache WHERE key = ?').get('utxo_list_cache');
if (cacheRow) {
try {
const parts = cacheRow.value.split(';');
// Format attendu : <date>;<hauteur> (2 parties)
if (parts.length >= 2) {
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 {
logger.warn('Invalid height in UTXO cache, forcing update');
needsUpdate = true;
}
} else {
logger.warn('Unexpected UTXO cache format, forcing update', { partsCount: parts.length });
needsUpdate = true;
}
} catch (error) {
logger.warn('Error reading UTXO cache from database', { error: error.message });
needsUpdate = true;
}
} else {
needsUpdate = true;
logger.info('No UTXO cache found, initializing', { currentHeight });
}
// Optimisation mémoire : charger les UTXOs depuis la DB seulement si nécessaire pour la mise à jour
const existingUtxosMap = new Map(); // Clé: "txid:vout", Valeur: utxoItem
// Obtenir les UTXO depuis le wallet seulement si nécessaire (nouveaux blocs détectés)
let unspent = [];
if (needsUpdate) {
// Charger les UTXOs existants depuis la base de données pour la mise à jour
// Optimisation : ne charger que les colonnes nécessaires pour réduire la consommation mémoire
const utxosFromDb = db.prepare(`
SELECT txid, vout, address, amount, confirmations, category,
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
FROM utxos
`).all();
for (const utxo of utxosFromDb) {
const utxoKey = `${utxo.txid}:${utxo.vout}`;
const utxoItem = {
txid: utxo.txid,
vout: utxo.vout,
address: utxo.address || '',
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
category: utxo.category,
isSpentOnchain: utxo.is_spent_onchain === 1,
isLockedInMutex: utxo.is_locked_in_mutex === 1,
blockHeight: null,
blockTime: utxo.block_time,
isAnchorChange: utxo.is_anchor_change === 1,
};
existingUtxosMap.set(utxoKey, utxoItem);
}
logger.info('Loaded existing UTXOs from database for update', { count: existingUtxosMap.size });
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 de la base de données directement
logger.debug('No new blocks, using cached UTXO list from database');
}
const blocRewards = [];
const anchors = [];
const changes = [];
const fees = []; // Liste des transactions avec leurs frais onchain
// Si pas de mise à jour nécessaire, charger directement depuis la DB sans Map intermédiaire
if (!needsUpdate) {
// Optimisation mémoire : charger directement depuis la DB et organiser par catégorie
// sans créer de Map intermédiaire qui consomme de la mémoire
const blocRewards = db.prepare(`
SELECT txid, vout, address, amount, confirmations, category,
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
FROM utxos
WHERE category = 'bloc_rewards'
ORDER BY amount DESC
`).all().map(utxo => ({
txid: utxo.txid,
vout: utxo.vout,
address: utxo.address || '',
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
category: utxo.category,
isSpentOnchain: utxo.is_spent_onchain === 1,
isLockedInMutex: utxo.is_locked_in_mutex === 1,
blockHeight: null,
blockTime: utxo.block_time,
isAnchorChange: utxo.is_anchor_change === 1,
}));
const anchors = db.prepare(`
SELECT txid, vout, address, amount, confirmations, category,
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
FROM utxos
WHERE category = 'ancrages' OR category = 'anchor'
ORDER BY amount DESC
`).all().map(utxo => ({
txid: utxo.txid,
vout: utxo.vout,
address: utxo.address || '',
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
category: utxo.category,
isSpentOnchain: utxo.is_spent_onchain === 1,
isLockedInMutex: utxo.is_locked_in_mutex === 1,
blockHeight: null,
blockTime: utxo.block_time,
isAnchorChange: utxo.is_anchor_change === 1,
}));
const changes = db.prepare(`
SELECT txid, vout, address, amount, confirmations, category,
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
FROM utxos
WHERE category = 'changes' OR category = 'change'
ORDER BY is_anchor_change DESC, amount DESC
`).all().map(utxo => ({
txid: utxo.txid,
vout: utxo.vout,
address: utxo.address || '',
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
category: utxo.category,
isSpentOnchain: utxo.is_spent_onchain === 1,
isLockedInMutex: utxo.is_locked_in_mutex === 1,
blockHeight: null,
blockTime: utxo.block_time,
isAnchorChange: utxo.is_anchor_change === 1,
}));
// Charger les frais depuis la base de données
const fees = [];
try {
// Mettre à jour les confirmations dans la DB si nécessaire
if (currentHeight > 0) {
// SQLite : utiliser CASE pour calculer les confirmations (MAX(0, x) = CASE WHEN x > 0 THEN x ELSE 0 END)
const updateFees = db.prepare(`
UPDATE fees
SET confirmations = CASE
WHEN block_height IS NOT NULL AND block_height <= ?
THEN CASE
WHEN (? - block_height + 1) > 0
THEN (? - block_height + 1)
ELSE 0
END
ELSE confirmations
END,
updated_at = CURRENT_TIMESTAMP
WHERE block_height IS NOT NULL AND block_height <= ?
`);
updateFees.run(currentHeight, currentHeight, currentHeight, currentHeight);
}
// Optimisation : ne charger que les colonnes nécessaires
const feesFromDb = db.prepare(`
SELECT txid, fee, fee_sats, block_height, block_time, confirmations,
change_address, change_amount
FROM fees
ORDER BY block_height DESC
`).all();
for (const fee of feesFromDb) {
fees.push({
txid: fee.txid,
fee: fee.fee,
fee_sats: fee.fee_sats,
blockHeight: fee.block_height,
blockTime: fee.block_time,
confirmations: fee.confirmations || 0,
changeAddress: fee.change_address,
changeAmount: fee.change_amount,
});
}
} catch (error) {
logger.warn('Error reading fees from database', { error: error.message });
}
// Calculer availableForAnchor
const minAnchorAmount = 2000 / 100000000;
const availableForAnchor = anchors.filter(u =>
u.amount >= minAnchorAmount &&
(u.confirmations || 0) > 0 &&
!u.isSpentOnchain &&
!u.isLockedInMutex
).length;
const confirmedAvailableForAnchor = anchors.filter(u =>
u.amount >= minAnchorAmount &&
(u.confirmations || 0) >= 6 &&
!u.isSpentOnchain &&
!u.isLockedInMutex
).length;
const total = blocRewards.length + anchors.length + changes.length + fees.length;
logger.debug('UTXO list returned from database (no update needed)', {
blocRewards: blocRewards.length,
anchors: anchors.length,
changes: changes.length,
fees: fees.length,
total,
availableForAnchor,
});
return {
blocRewards,
anchors,
changes,
fees,
total,
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 de la base de données
// 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 la base de données mais plus dans listunspent)
// Mettre à jour TOUS les UTXOs de la base de données, pas seulement ceux en mémoire
// Optimisation : utiliser une requête SQL directe au lieu de parcourir tous les UTXOs en mémoire
const updateSpentUtxos = db.prepare(`
UPDATE utxos
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
WHERE is_spent_onchain = 0
AND (txid || ':' || vout) NOT IN (
SELECT txid || ':' || vout FROM (
SELECT txid, vout FROM utxos WHERE is_spent_onchain = 0 LIMIT 1
)
)
`);
// Créer une liste des UTXOs disponibles pour la requête SQL
// Utiliser une table temporaire pour éviter de charger tous les UTXOs en mémoire
db.exec(`
CREATE TEMP TABLE IF NOT EXISTS temp_available_utxos (
txid TEXT,
vout INTEGER,
PRIMARY KEY (txid, vout)
)
`);
// Insérer les UTXOs disponibles par batch pour réduire la mémoire
const SYNC_BATCH_SIZE = 1000;
const insertAvailableUtxo = db.prepare(`
INSERT OR IGNORE INTO temp_available_utxos (txid, vout)
VALUES (?, ?)
`);
const insertBatch = db.transaction((utxos) => {
for (const utxo of utxos) {
insertAvailableUtxo.run(utxo.txid, utxo.vout);
}
});
// Insérer par batch pour réduire la consommation mémoire
for (let i = 0; i < unspent.length; i += SYNC_BATCH_SIZE) {
const batch = unspent.slice(i, i + SYNC_BATCH_SIZE);
insertBatch(batch);
}
// Mettre à jour les UTXOs dépensés en une seule requête SQL
const updateSpentStmt = db.prepare(`
UPDATE utxos
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
WHERE is_spent_onchain = 0
AND (txid || ':' || vout) NOT IN (
SELECT txid || ':' || vout FROM temp_available_utxos
)
`);
const updateResult = updateSpentStmt.run();
// Nettoyer la table temporaire
db.exec('DROP TABLE IF EXISTS temp_available_utxos');
logger.info('UTXOs dépensés mis à jour dans la base de données', {
updated: updateResult.changes,
});
// Mettre à jour aussi les UTXOs en mémoire pour cohérence
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 dans la base de données
const now = new Date().toISOString();
const cacheContent = `${now};${currentHeight}`;
const updateCache = db.prepare('INSERT OR REPLACE INTO cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)');
updateCache.run('utxo_list_cache', cacheContent);
// Mettre à jour la base de données avec tous les UTXOs
const insertOrUpdateUtxo = db.prepare(`
INSERT INTO utxos (category, txid, vout, amount, confirmations, is_anchor_change, block_time, is_spent_onchain, is_locked_in_mutex, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(txid, vout) DO UPDATE SET
category = excluded.category,
amount = excluded.amount,
confirmations = excluded.confirmations,
is_anchor_change = excluded.is_anchor_change,
block_time = excluded.block_time,
is_spent_onchain = excluded.is_spent_onchain,
is_locked_in_mutex = excluded.is_locked_in_mutex,
updated_at = CURRENT_TIMESTAMP
`);
const insertManyUtxos = db.transaction((utxos) => {
for (const item of utxos) {
insertOrUpdateUtxo.run(
item.category,
item.txid,
item.vout,
item.amount,
item.confirmations,
item.isAnchorChange ? 1 : 0,
item.blockTime || null,
item.isSpentOnchain ? 1 : 0,
item.isLockedInMutex ? 1 : 0
);
}
});
insertManyUtxos(Array.from(existingUtxosMap.values()));
// 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;
});
// Les frais sont déjà chargés depuis la base de données dans la section précédente (ligne ~537-560)
// Pas besoin de recharger ici
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 la base de données
* @param {number} sinceBlockHeight - Hauteur de bloc à partir de laquelle récupérer (optionnel, depuis dernier frais de la base)
* @returns {Promise<Object>} Résultat avec nombre de frais récupérés
*/
async updateFeesFromAnchors(sinceBlockHeight = null) {
try {
const db = getDatabase();
// Lire les frais existants depuis la base de données
const existingFees = new Map();
// Optimisation : ne charger que txid pour vérifier l'existence
const feesFromDb = db.prepare('SELECT txid FROM fees').all();
for (const fee of feesFromDb) {
existingFees.set(fee.txid, true);
}
// Déterminer depuis quelle hauteur récupérer
let startHeight = sinceBlockHeight;
if (!startHeight) {
// Trouver la hauteur maximale des frais existants
const maxHeightRow = db.prepare('SELECT MAX(block_height) as max_height FROM fees WHERE block_height IS NOT NULL').get();
startHeight = maxHeightRow?.max_height || 0;
}
// Lire les ancrages depuis la base de données pour obtenir les txids
const anchorTxids = new Set();
const anchorsFromDb = db.prepare('SELECT DISTINCT txid FROM anchors').all();
for (const anchor of anchorsFromDb) {
anchorTxids.add(anchor.txid);
}
// 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 à la base de données
if (newFees.length > 0) {
const insertFee = db.prepare(`
INSERT OR REPLACE INTO fees
(txid, fee, fee_sats, block_height, block_time, confirmations, change_address, change_amount, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
const insertManyFees = db.transaction((fees) => {
for (const fee of fees) {
insertFee.run(
fee.txid,
fee.fee,
fee.fee_sats,
fee.blockHeight || null,
fee.blockTime || null,
fee.confirmations,
fee.changeAddress || null,
fee.changeAmount || null
);
}
});
insertManyFees(newFees);
const totalFees = db.prepare('SELECT COUNT(*) as count FROM fees').get();
logger.info('Fees list updated in database', { newFees: newFees.length, total: totalFees.count });
}
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 depuis la base de données
* Vérifie et met à jour la base de données si nécessaire avant de compter
* @returns {Promise<number>} Nombre d'ancrages
*/
async getAnchorCount() {
try {
const db = getDatabase();
// Vérifier rapidement s'il y a de nouveaux blocs à traiter
let needsUpdate = false;
try {
const blockchainInfo = await this.client.getBlockchainInfo();
const currentHeight = blockchainInfo.blocks;
// Vérifier le cache dans la base de données
const cacheRow = db.prepare('SELECT value FROM cache WHERE key = ?').get('hash_list_cache');
if (cacheRow) {
const parts = cacheRow.value.split(';');
if (parts.length >= 2) {
const cachedHeight = parseInt(parts[1], 10);
if (!isNaN(cachedHeight) && cachedHeight < currentHeight) {
needsUpdate = true;
logger.debug('New blocks detected, updating hash list before counting', {
cachedHeight,
currentHeight,
newBlocks: currentHeight - cachedHeight,
});
}
} else {
needsUpdate = true;
}
} else {
needsUpdate = true;
}
} catch (error) {
logger.warn('Error checking for new blocks, using existing data', { error: error.message });
}
// Si des nouveaux blocs sont détectés, mettre à jour la base de données
if (needsUpdate) {
try {
await this.getHashList();
logger.debug('Hash list updated before counting anchors');
} catch (error) {
logger.warn('Error updating hash list before counting, using existing data', { error: error.message });
}
}
// Compter depuis la base de données
const countRow = db.prepare('SELECT COUNT(*) as count FROM anchors').get();
const anchorCount = countRow?.count || 0;
logger.debug('Anchor count read from database', { count: anchorCount });
return anchorCount;
} 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();