**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é
1843 lines
73 KiB
JavaScript
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();
|