/** * Client Bitcoin RPC * * Gère la connexion et les appels RPC vers le nœud Bitcoin Signet */ import Client from 'bitcoin-core'; import { logger } from './logger.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'), }); // Mutex pour gérer l'accès concurrent aux UTXOs // Utilise une Promise-based queue pour sérialiser les accès this.utxoMutexPromise = Promise.resolve(); // Liste des UTXOs en cours d'utilisation (format: "txid:vout") this.lockedUtxos = new Set(); } /** * Acquiert le mutex pour l'accès aux UTXOs * @returns {Promise} Fonction pour libérer le mutex */ async acquireUtxoMutex() { // Attendre que le mutex précédent soit libéré const previousMutex = this.utxoMutexPromise; let releaseMutex; // Créer une nouvelle Promise qui sera résolue quand le mutex est libéré this.utxoMutexPromise = new Promise((resolve) => { releaseMutex = resolve; }); // Attendre que le mutex précédent soit libéré await previousMutex; // Retourner la fonction pour libérer le mutex return releaseMutex; } /** * Vérifie si un UTXO est verrouillé * @param {string} txid - ID de la transaction * @param {number} vout - Index de l'output * @returns {boolean} True si l'UTXO est verrouillé */ isUtxoLocked(txid, vout) { const key = `${txid}:${vout}`; return this.lockedUtxos.has(key); } /** * Verrouille un UTXO * @param {string} txid - ID de la transaction * @param {number} vout - Index de l'output */ lockUtxo(txid, vout) { const key = `${txid}:${vout}`; this.lockedUtxos.add(key); logger.debug('UTXO locked', { txid: txid.substring(0, 16) + '...', vout }); } /** * Verrouille plusieurs UTXOs * @param {Array} utxos - Liste des UTXOs à verrouiller */ lockUtxos(utxos) { for (const utxo of utxos) { this.lockUtxo(utxo.txid, utxo.vout); } } /** * Déverrouille un UTXO * @param {string} txid - ID de la transaction * @param {number} vout - Index de l'output */ unlockUtxo(txid, vout) { const key = `${txid}:${vout}`; this.lockedUtxos.delete(key); logger.debug('UTXO unlocked', { txid: txid.substring(0, 16) + '...', vout }); } /** * Déverrouille plusieurs UTXOs * @param {Array} utxos - Liste des UTXOs à déverrouiller */ unlockUtxos(utxos) { for (const utxo of utxos) { this.unlockUtxo(utxo.txid, utxo.vout); } } /** * Vérifie la connexion au nœud Bitcoin * @returns {Promise} Informations sur le nœud */ async checkConnection() { try { const networkInfo = await this.client.getNetworkInfo(); const blockchainInfo = await this.client.getBlockchainInfo(); return { connected: true, blocks: blockchainInfo.blocks, chain: blockchainInfo.chain, networkactive: networkInfo.networkactive, connections: networkInfo.connections, }; } catch (error) { logger.error('Bitcoin RPC connection error', { error: error.message }); return { connected: false, error: error.message, }; } } /** * Obtient une nouvelle adresse depuis le wallet * @returns {Promise} Adresse Bitcoin */ async getNewAddress() { try { return await this.client.getNewAddress(); } catch (error) { logger.error('Error getting new address', { error: error.message }); throw new Error(`Failed to get new address: ${error.message}`); } } /** * Obtient le solde du wallet * @returns {Promise} Solde en BTC */ async getBalance() { try { return await this.client.getBalance(); } catch (error) { logger.error('Error getting balance', { error: error.message }); throw new Error(`Failed to get balance: ${error.message}`); } } /** * Crée une transaction d'ancrage * * @param {string} hash - Hash du document à ancrer (hex) * @param {string} recipientAddress - Adresse de destination (optionnel, utilise getNewAddress si non fourni) * @returns {Promise} Transaction créée avec txid */ async createAnchorTransaction(hash, recipientAddress = null) { // Acquérir le mutex pour l'accès aux UTXOs const releaseMutex = await this.acquireUtxoMutex(); let selectedUtxos = []; try { // Vérifier que le hash est valide (64 caractères hex) if (!/^[0-9a-fA-F]{64}$/.test(hash)) { throw new Error('Invalid hash format. Must be 64 character hexadecimal string.'); } // Obtenir une adresse de destination si non fournie const address = recipientAddress || await this.getNewAddress(); // Obtenir le solde disponible const balance = await this.getBalance(); const feeRate = parseFloat(process.env.MINING_FEE_RATE || '0.00001'); if (balance < feeRate) { throw new Error(`Insufficient balance. Required: ${feeRate} BTC, Available: ${balance} BTC`); } // Créer une transaction avec le hash dans les données OP_RETURN // Format: OP_RETURN + "ANCHOR:" + hash (32 bytes) const hashBuffer = Buffer.from(hash, 'hex'); const anchorData = Buffer.concat([ Buffer.from('ANCHOR:', 'utf8'), hashBuffer, ]); // Obtenir les UTXOs disponibles (inclure les non confirmés pour avoir plus d'options) // Utiliser fetch directement avec l'URL RPC incluant le wallet pour éviter les problèmes de wallet const walletName = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; const host = process.env.BITCOIN_RPC_HOST || 'localhost'; const port = process.env.BITCOIN_RPC_PORT || '38332'; const username = process.env.BITCOIN_RPC_USER || 'bitcoin'; const password = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin'; const rpcUrl = `http://${host}:${port}/wallet/${walletName}`; // Utiliser Basic Auth dans les headers (fetch ne supporte pas les credentials dans l'URL) 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: [0], }), }); if (!rpcResponse.ok) { const errorText = await rpcResponse.text(); logger.error('HTTP error in listunspent', { status: rpcResponse.status, statusText: rpcResponse.statusText, response: errorText }); throw new Error(`HTTP error fetching UTXOs: ${rpcResponse.status} ${rpcResponse.statusText}`); } const rpcResult = await rpcResponse.json(); if (rpcResult.error) { logger.error('RPC error in listunspent', { error: rpcResult.error }); throw new Error(`RPC error: ${rpcResult.error.message}`); } const unspent = rpcResult.result; logger.info('Fetched UTXOs', { count: unspent.length, firstFew: unspent.slice(0, 3).map(u => ({ txid: u.txid.substring(0, 16), vout: u.vout, amount: u.amount })) }); if (unspent.length === 0) { throw new Error('No unspent outputs available'); } // Filtrer les UTXOs verrouillés (en cours d'utilisation par d'autres transactions) const availableUtxos = unspent.filter(utxo => !this.isUtxoLocked(utxo.txid, utxo.vout)); logger.info('Available UTXOs (after filtering locked)', { total: unspent.length, available: availableUtxos.length, locked: unspent.length - availableUtxos.length, amounts: availableUtxos.map(u => u.amount).slice(0, 10), largest: availableUtxos.length > 0 ? Math.max(...availableUtxos.map(u => u.amount)) : 0, }); if (availableUtxos.length === 0) { throw new Error('No available UTXOs (all are locked or in use)'); } // Sélectionner plusieurs UTXOs si nécessaire (coin selection) // Stratégie : préférer les UTXOs qui sont juste assez grands, puis combiner plusieurs petits UTXOs const amount = 0.00001; // Montant minimal pour la transaction const estimatedFeePerInput = 0.000001; // Estimation des frais par input (conservateur) const estimatedFeeBase = 0.00001; // Frais de base pour la transaction const maxChangeRatio = 10; // Maximum 10x le montant requis pour éviter un change trop grand // Sélectionner les UTXOs nécessaires pour couvrir le montant + frais const selectedUtxos = []; let totalSelected = 0; // Estimer le nombre d'inputs nécessaires (itération pour ajuster les frais) let estimatedInputs = 1; let totalNeeded = amount + estimatedFeeBase; // Itérer jusqu'à trouver une combinaison qui fonctionne for (let iteration = 0; iteration < 10; iteration++) { totalNeeded = amount + estimatedFeeBase + (estimatedInputs * estimatedFeePerInput); selectedUtxos.length = 0; totalSelected = 0; // Trier les UTXOs : d'abord ceux qui sont juste assez grands, puis les plus petits const sortedUnspent = [...availableUtxos].sort((a, b) => { // Préférer les UTXOs qui sont juste assez grands (pas trop grands) const aGood = a.amount >= totalNeeded && a.amount <= totalNeeded * maxChangeRatio; const bGood = b.amount >= totalNeeded && b.amount <= totalNeeded * maxChangeRatio; if (aGood && !bGood) return -1; if (!aGood && bGood) return 1; // Sinon, trier par montant croissant pour minimiser le change return a.amount - b.amount; }); // Sélectionner les UTXOs jusqu'à avoir suffisamment de fonds for (const utxo of sortedUnspent) { if (totalSelected >= totalNeeded) { break; } // Éviter les UTXOs trop grands qui créeraient un change énorme // Sauf si c'est le seul UTXO disponible ou si on a déjà plusieurs UTXOs if (selectedUtxos.length === 0 && utxo.amount > totalNeeded * maxChangeRatio) { // Si c'est le premier UTXO et qu'il est trop grand, continuer à chercher // Mais si c'est le seul disponible, l'utiliser quand même continue; } selectedUtxos.push(utxo); totalSelected += utxo.amount; } // Si on a assez de fonds, sortir de la boucle if (totalSelected >= totalNeeded) { break; } // Sinon, réessayer avec plus d'inputs estimés estimatedInputs = selectedUtxos.length + 1; } // Vérifier qu'on a assez de fonds if (totalSelected < totalNeeded) { throw new Error(`Insufficient UTXO amount. Required: ${totalNeeded} BTC, Available: ${totalSelected} BTC. Selected ${selectedUtxos.length} UTXOs from ${sortedUnspent.length} available.`); } const now = new Date().toISOString(); logger.info('Selected UTXOs for transaction', { hash: hash, date: now, count: selectedUtxos.length, totalAmount: totalSelected, required: totalNeeded, change: totalSelected - totalNeeded, }); // Verrouiller les UTXOs sélectionnés pour éviter qu'ils soient utilisés par d'autres transactions this.lockUtxos(selectedUtxos); // Créer la transaction raw avec les inputs et outputs (sans fundrawtransaction) // Cela évite les erreurs de frais trop élevés avec la bibliothèque bitcoin-core const inputs = selectedUtxos.map(utxo => ({ txid: utxo.txid, vout: utxo.vout, })); // Calculer le change (monnaie restante après avoir payé le montant) // Estimation des frais : base + (nombre d'inputs * frais par input) const estimatedFee = estimatedFeeBase + (selectedUtxos.length * estimatedFeePerInput); let change = totalSelected - amount - estimatedFee; // Arrondir le change à 8 décimales (précision Bitcoin standard) change = Math.round(change * 100000000) / 100000000; // Créer les outputs const outputs = { data: anchorData.toString('hex'), // OP_RETURN output (doit être en premier) }; // Ajouter l'output de destination avec le montant minimal (arrondi à 8 décimales) outputs[address] = Math.round(amount * 100000000) / 100000000; // Si le change est significatif (> 0.00001 BTC pour éviter les problèmes de précision), l'envoyer à une adresse de change // Sinon, il sera considéré comme frais (dust) if (change > 0.00001) { const changeAddress = await this.getNewAddress(); outputs[changeAddress] = change; logger.info('Adding change output', { changeAddress, change }); } else if (change > 0) { logger.info('Change too small, will be included in fees', { change }); } const tx = await this.client.command('createrawtransaction', inputs, outputs); // Signer la transaction // Utiliser command() directement pour éviter les problèmes avec la bibliothèque const signedTx = await this.client.command('signrawtransactionwithwallet', tx); if (!signedTx.complete) { throw new Error('Transaction signing failed'); } // Envoyer la transaction au mempool // Utiliser command() avec maxfeerate comme deuxième paramètre (0 = accepter n'importe quel taux) // Le test direct avec bitcoin-cli fonctionne avec cette syntaxe const txid = await this.client.command('sendrawtransaction', signedTx.hex, 0); logger.info('Anchor transaction sent to mempool', { txid, hash: hash.substring(0, 16) + '...', address, }); // Obtenir les informations de la transaction (dans le mempool) const txInfo = await this.getTransactionInfo(txid); // Déverrouiller les UTXOs maintenant que la transaction est dans le mempool // Les UTXOs seront automatiquement marqués comme dépensés par Bitcoin Core this.unlockUtxos(selectedUtxos); // Libérer le mutex releaseMutex(); return { txid, status: 'confirmed', // Transaction dans le mempool confirmations: txInfo.confirmations || 0, block_height: txInfo.blockheight || null, // null si pas encore dans un bloc }; } catch (error) { logger.error('Error creating anchor transaction', { error: error.message, hash: hash?.substring(0, 16) + '...', }); // En cas d'erreur, déverrouiller les UTXOs et libérer le mutex if (selectedUtxos.length > 0) { this.unlockUtxos(selectedUtxos); } releaseMutex(); throw error; } } /** * Obtient les informations d'une transaction * @param {string} txid - ID de la transaction * @returns {Promise} Informations de la transaction */ async getTransactionInfo(txid) { try { const tx = await this.client.getTransaction(txid); const blockchainInfo = await this.client.getBlockchainInfo(); return { txid: tx.txid, confirmations: tx.confirmations || 0, blockheight: tx.blockheight || null, blockhash: tx.blockhash || null, time: tx.time || null, currentBlockHeight: blockchainInfo.blocks, }; } catch (error) { logger.error('Error getting transaction info', { error: error.message, txid }); throw new Error(`Failed to get transaction info: ${error.message}`); } } /** * Vérifie si un hash est ancré dans la blockchain * * @param {string} hash - Hash à vérifier * @param {string} txid - ID de transaction optionnel pour accélérer la recherche * @returns {Promise} Résultat de la vérification */ async verifyAnchor(hash, txid = null) { try { // Vérifier que le hash est valide if (!/^[0-9a-fA-F]{64}$/.test(hash)) { throw new Error('Invalid hash format. Must be 64 character hexadecimal string.'); } // Si un txid est fourni, vérifier directement cette transaction if (txid) { try { const tx = await this.client.getTransaction(txid, true); const rawTx = await this.client.getRawTransaction(txid, true); // Vérifier si le hash est dans les outputs OP_RETURN const hashFound = this.checkHashInTransaction(rawTx, hash); if (hashFound) { return { verified: true, anchor_info: { transaction_id: txid, block_height: tx.blockheight || null, confirmations: tx.confirmations || 0, }, }; } } catch (error) { // Si la transaction n'existe pas, continuer la recherche logger.warn('Transaction not found, searching blockchain', { txid, error: error.message }); } } // Rechercher dans les blocs récents (derniers 100 blocs) const blockchainInfo = await this.client.getBlockchainInfo(); const currentHeight = blockchainInfo.blocks; const searchRange = 100; // Rechercher dans les 100 derniers blocs for (let height = currentHeight; height >= Math.max(0, currentHeight - searchRange); height--) { try { const blockHash = await this.client.getBlockHash(height); const block = await this.client.getBlock(blockHash, 2); // Verbose level 2 // Parcourir toutes les transactions du bloc for (const tx of block.tx || []) { try { const rawTx = await this.client.getRawTransaction(tx.txid, true); const hashFound = this.checkHashInTransaction(rawTx, hash); if (hashFound) { return { verified: true, anchor_info: { transaction_id: tx.txid, block_height: height, confirmations: currentHeight - height + 1, }, }; } } catch (error) { // Continuer avec la transaction suivante logger.debug('Error checking transaction', { txid: tx.txid, error: error.message }); } } } catch (error) { // Continuer avec le bloc suivant logger.debug('Error checking block', { height, error: error.message }); } } // Hash non trouvé return { verified: false, message: 'Hash not found in recent blocks', }; } catch (error) { logger.error('Error verifying anchor', { error: error.message, hash: hash?.substring(0, 16) + '...' }); throw error; } } /** * Vérifie si un hash est présent dans une transaction * @param {Object} rawTx - Transaction brute * @param {string} hash - Hash à rechercher * @returns {boolean} True si le hash est trouvé */ checkHashInTransaction(rawTx, hash) { try { // Parcourir les outputs de la transaction for (const output of rawTx.vout || []) { // Chercher dans les scripts OP_RETURN if (output.scriptPubKey && output.scriptPubKey.hex) { const scriptHex = output.scriptPubKey.hex; // Vérifier si le script contient "ANCHOR:" suivi du hash const anchorPrefix = Buffer.from('ANCHOR:', 'utf8').toString('hex'); const hashHex = hash.toLowerCase(); if (scriptHex.includes(anchorPrefix + hashHex)) { return true; } } } return false; } catch (error) { logger.error('Error checking hash in transaction', { error: error.message }); return false; } } } // Export class and singleton export { BitcoinRPC }; export const bitcoinRPC = new BitcoinRPC();