/** * 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'; import dns from 'dns'; import { getDatabase } from './database.js'; // Force IPv4 first to avoid IPv6 connection issues // This ensures that even if the system prefers IPv6, Node.js will try IPv4 first dns.setDefaultResultOrder('ipv4first'); class BitcoinRPC { constructor() { this.client = new Client({ host: process.env.BITCOIN_RPC_HOST || '127.0.0.1', 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(); this.utxoMutexLocked = false; this.utxoMutexWaiting = 0; // Timeout pour l'attente du mutex (180s = 3 minutes) // Si une requête prend plus de 180s, elle sera automatiquement libérée this.utxoMutexTimeout = 180000; // Note: Les UTXOs verrouillés sont maintenant gérés uniquement dans la base de données // via is_locked_in_mutex pour éviter la duplication et réduire la consommation mémoire } /** * Obtient l'état actuel du mutex * @returns {Object} État du mutex */ getMutexState() { return { locked: this.utxoMutexLocked, waiting: this.utxoMutexWaiting, }; } /** * Acquiert le mutex pour l'accès aux UTXOs avec timeout * @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; let timeoutId; // Incrémenter le compteur d'attente this.utxoMutexWaiting++; // Créer une nouvelle Promise qui sera résolue quand le mutex est libéré this.utxoMutexPromise = new Promise((resolve) => { releaseMutex = () => { this.utxoMutexLocked = false; this.utxoMutexWaiting = Math.max(0, this.utxoMutexWaiting - 1); resolve(); }; }); // Créer une Promise avec timeout pour éviter les blocages indéfinis const mutexWithTimeout = Promise.race([ previousMutex, new Promise((_, reject) => { timeoutId = setTimeout(() => { logger.warn('Mutex acquisition timeout, forcing release', { timeout: this.utxoMutexTimeout, }); this.utxoMutexWaiting = Math.max(0, this.utxoMutexWaiting - 1); reject(new Error(`Mutex acquisition timeout after ${this.utxoMutexTimeout}ms`)); }, this.utxoMutexTimeout); }), ]); try { await mutexWithTimeout; this.utxoMutexLocked = true; this.utxoMutexWaiting = Math.max(0, this.utxoMutexWaiting - 1); } finally { if (timeoutId) { clearTimeout(timeoutId); } } // 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) { try { const db = getDatabase(); const result = db.prepare(` SELECT is_locked_in_mutex FROM utxos WHERE txid = ? AND vout = ? `).get(txid, vout); return result?.is_locked_in_mutex === 1; } catch (error) { logger.warn('Error checking UTXO lock status in database', { error: error.message, txid: txid.substring(0, 16) + '...', vout, }); return false; } } /** * Verrouille un UTXO * @param {string} txid - ID de la transaction * @param {number} vout - Index de l'output */ lockUtxo(txid, vout) { // Marquer l'UTXO comme verrouillé dans la base de données uniquement try { const db = getDatabase(); db.prepare(` UPDATE utxos SET is_locked_in_mutex = 1, updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND vout = ? `).run(txid, vout); logger.debug('UTXO locked', { txid: txid.substring(0, 16) + '...', vout }); } catch (error) { logger.warn('Error updating UTXO lock status in database', { error: error.message, 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) { // Marquer l'UTXO comme non verrouillé dans la base de données uniquement try { const db = getDatabase(); db.prepare(` UPDATE utxos SET is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND vout = ? `).run(txid, vout); logger.debug('UTXO unlocked', { txid: txid.substring(0, 16) + '...', vout }); } catch (error) { logger.warn('Error updating UTXO unlock status in database', { error: error.message, 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.callRPCWithRetry('getNetworkInfo', []); const blockchainInfo = await this.callRPCWithRetry('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, }; } } /** * Appelle une méthode RPC avec retry et backoff exponentiel * @param {string} method - Nom de la méthode RPC * @param {Array} params - Paramètres de la méthode * @param {number} maxRetries - Nombre maximum de tentatives (défaut: 3) * @returns {Promise} Résultat de l'appel RPC */ async callRPCWithRetry(method, params = [], maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await this.client[method](...params); } catch (error) { const isTimeoutError = error.message.includes('ESOCKETTIMEDOUT') || error.message.includes('ETIMEDOUT') || error.message.includes('timeout'); if (i === maxRetries - 1 || !isTimeoutError) { throw error; } const delay = Math.min(1000 * Math.pow(2, i), 10000); // Backoff exponentiel, max 10s logger.warn(`RPC call failed, retrying in ${delay}ms`, { method, attempt: i + 1, maxRetries, error: error.message, }); await new Promise((resolve) => setTimeout(resolve, delay)); } } } /** * Appelle une commande RPC avec retry et backoff exponentiel * @param {string} command - Nom de la commande RPC * @param {...any} args - Arguments de la commande * @param {number} maxRetries - Nombre maximum de tentatives (défaut: 3) * @returns {Promise} Résultat de l'appel RPC */ async callRPCCommandWithRetry(command, ...args) { const maxRetries = 3; for (let i = 0; i < maxRetries; i++) { try { return await this.client.command(command, ...args); } catch (error) { const isTimeoutError = error.message.includes('ESOCKETTIMEDOUT') || error.message.includes('ETIMEDOUT') || error.message.includes('timeout'); if (i === maxRetries - 1 || !isTimeoutError) { throw error; } const delay = Math.min(1000 * Math.pow(2, i), 10000); // Backoff exponentiel, max 10s logger.warn(`RPC command failed, retrying in ${delay}ms`, { command, attempt: i + 1, maxRetries, error: error.message, }); await new Promise((resolve) => setTimeout(resolve, delay)); } } } /** * Obtient une nouvelle adresse depuis le wallet * @returns {Promise} Adresse Bitcoin */ async getNewAddress() { try { return await this.callRPCWithRetry('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.callRPCWithRetry('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, provisioningAddresses = null, numberOfProvisioningUtxos = null, retryCount = 0) { const startTime = Date.now(); // Acquérir le mutex pour l'accès aux UTXOs const releaseMutex = await this.acquireUtxoMutex(); let selectedUtxo = null; let selectedUtxos = []; let mutexSafetyTimeout; // Timeout de sécurité: libérer le mutex après 5 minutes maximum mutexSafetyTimeout = setTimeout(() => { logger.error('Mutex held for too long, forcing release', { hash: hash?.substring(0, 16) + '...', duration: Date.now() - startTime, }); releaseMutex(); }, 300000); // 5 minutes 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 les adresses nécessaires en parallèle pour optimiser les performances // On a besoin de : 1 adresse principale + N adresses de provisioning + 1 adresse de change (si nécessaire) // Utiliser le paramètre fourni ou la valeur par défaut de 7 const provisioningCount = numberOfProvisioningUtxos ?? 7; const addressesNeeded = 1 + provisioningCount + 1; // principal + provisioning + change // Générer toutes les adresses en parallèle avec timeout const addressPromises = []; for (let i = 0; i < addressesNeeded; i++) { addressPromises.push(this.getNewAddress()); } // Timeout de 30 secondes pour la génération d'adresses const allAddresses = await Promise.race([ Promise.all(addressPromises), new Promise((_, reject) => { setTimeout(() => { reject(new Error('Address generation timeout after 30s')); }, 30000); }), ]); // Utiliser l'adresse fournie ou la première générée const address = recipientAddress || allAddresses[0]; const finalProvisioningAddresses = provisioningAddresses || allAddresses.slice(1, 1 + provisioningCount); const changeAddressCandidate = allAddresses[addressesNeeded - 1]; // 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, ]); // Fonction helper pour arrondir à 8 décimales (précision Bitcoin standard) const roundTo8Decimals = (amount) => { return Math.round(amount * 100000000) / 100000000; }; // Stratégie : Provisionner à chaque ancrage // Utiliser un gros UTXO pour créer : // - 1 output d'ancrage de 2500 sats (0.000025 BTC) // - N outputs de provisionnement de 2500 sats chacun // - Le reste en change const utxoAmount = 0.000025; // 2500 sats par UTXO const anchorOutputAmount = utxoAmount; // 1 UTXO pour l'ancrage actuel const totalProvisioningAmount = utxoAmount * provisioningCount; const totalOutputAmount = anchorOutputAmount + totalProvisioningAmount; // Estimation des frais : base + frais par output // On va ajouter 2 OP_RETURN supplémentaires (change + frais), donc 3 OP_RETURN au total const estimatedFeePerOutput = 0.000001; // Frais par output (conservateur) const estimatedFeePerOpReturn = 0.0000015; // Frais par OP_RETURN (légèrement plus cher) const estimatedFeeBase = 0.00001; // Frais de base const numberOfOpReturns = 3; // OP_RETURN anchor + OP_RETURN change + OP_RETURN fees const numberOfRegularOutputs = 1 + provisioningCount + 1; // 1 ancrage + N provisioning + 1 change (si nécessaire) const estimatedFeeBeforeMargin = estimatedFeeBase + (numberOfOpReturns * estimatedFeePerOpReturn) + (numberOfRegularOutputs * estimatedFeePerOutput); // Prendre une marge de sécurité de 30% sur les frais const feeMargin = 0.3; // 30% de marge const estimatedFee = roundTo8Decimals(estimatedFeeBeforeMargin * (1 + feeMargin)); const totalNeeded = totalOutputAmount + estimatedFee; logger.info('Anchor transaction with provisioning', { hash: hash.substring(0, 16) + '...', anchorOutputAmount, numberOfProvisioningUtxos: provisioningCount, totalProvisioningAmount, totalOutputAmount, estimatedFee, totalNeeded, }); // Obtenir un UTXO disponible depuis la base de données // Optimisation : ne charger qu'un seul UTXO au lieu de tous les UTXOs // Le filtrage des UTXOs verrouillés se fait directement dans la requête SQL const db = getDatabase(); // Sélectionner un UTXO disponible depuis la DB // Critères : confirmé, non dépensé, non verrouillé, montant suffisant const utxoQuery = db.prepare(` SELECT txid, vout, address, amount, confirmations, block_time FROM utxos WHERE confirmations > 0 AND is_spent_onchain = 0 AND is_locked_in_mutex = 0 AND amount >= ? ORDER BY amount DESC LIMIT 1 `); let utxoFromDb = utxoQuery.get(totalNeeded); // Si aucun UTXO assez grand, essayer de combiner plusieurs petits UTXOs let totalSelectedAmount = 0; let estimatedFeeForMultipleInputs = estimatedFee; if (!utxoFromDb) { // Chercher plusieurs petits UTXOs dont la somme est suffisante const combineUtxosQuery = db.prepare(` SELECT txid, vout, address, amount, confirmations, block_time FROM utxos WHERE confirmations > 0 AND is_spent_onchain = 0 AND is_locked_in_mutex = 0 ORDER BY amount DESC `); const availableUtxos = combineUtxosQuery.all(); if (availableUtxos.length === 0) { throw new Error('No available UTXOs in database (all are locked, spent, or unconfirmed)'); } // Calculer le montant total nécessaire avec une marge pour les frais supplémentaires // (combiner plusieurs UTXOs augmente la taille de la transaction) // Estimation: ~148 bytes par input supplémentaire const estimatedBytesPerInput = 148; const estimatedFeePerInput = 0.0000001; // Conservateur const maxUtxosToCombine = 100; // Limite élevée pour permettre d'utiliser tous les UTXOs si nécessaire estimatedFeeForMultipleInputs = estimatedFee; // Calculer le total disponible de tous les UTXOs const totalAvailable = availableUtxos.reduce((sum, u) => sum + u.amount, 0); // Sélectionner les UTXOs jusqu'à atteindre le montant nécessaire // CORRECTION: totalNeeded inclut déjà estimatedFee, donc on utilise totalOutputAmount + estimatedFeeForMultipleInputs for (let i = 0; i < availableUtxos.length && i < maxUtxosToCombine; i++) { const utxo = availableUtxos[i]; // Recalculer les frais avec le nombre actuel d'inputs const currentEstimatedFee = estimatedFee + (selectedUtxos.length * estimatedFeePerInput); const currentTotalNeeded = totalOutputAmount + currentEstimatedFee; if (totalSelectedAmount >= currentTotalNeeded) { estimatedFeeForMultipleInputs = currentEstimatedFee; break; } selectedUtxos.push({ txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, blockTime: utxo.block_time, }); totalSelectedAmount += utxo.amount; // Ajuster l'estimation des frais pour chaque input supplémentaire if (selectedUtxos.length > 1) { estimatedFeeForMultipleInputs = estimatedFee + (selectedUtxos.length * estimatedFeePerInput); } } // Si on n'a pas assez avec les UTXOs sélectionnés, essayer d'utiliser TOUS les UTXOs disponibles // Recalculer les frais finaux avec le nombre réel d'inputs estimatedFeeForMultipleInputs = estimatedFee + (selectedUtxos.length * estimatedFeePerInput); let finalTotalNeeded = totalOutputAmount + estimatedFeeForMultipleInputs; if (totalSelectedAmount < finalTotalNeeded && selectedUtxos.length < availableUtxos.length) { // Essayer d'utiliser TOUS les UTXOs disponibles selectedUtxos = []; totalSelectedAmount = 0; for (let i = 0; i < availableUtxos.length; i++) { const utxo = availableUtxos[i]; selectedUtxos.push({ txid: utxo.txid, vout: utxo.vout, address: utxo.address || '', amount: utxo.amount, confirmations: utxo.confirmations || 0, blockTime: utxo.block_time, }); totalSelectedAmount += utxo.amount; } // Recalculer les frais avec tous les UTXOs estimatedFeeForMultipleInputs = estimatedFee + (selectedUtxos.length * estimatedFeePerInput); finalTotalNeeded = totalOutputAmount + estimatedFeeForMultipleInputs; } if (totalSelectedAmount < finalTotalNeeded) { const largestUtxo = availableUtxos[0]; // Suggérer des solutions dans le message d'erreur let suggestion = ''; const shortfall = finalTotalNeeded - totalAvailable; if (shortfall > 0) { suggestion = ` Total available from all ${availableUtxos.length} UTXOs: ${totalAvailable.toFixed(8)} BTC. ` + `Shortfall: ${shortfall.toFixed(8)} BTC. ` + `Solutions: 1) Use faucet to get more funds, 2) Mine more blocks, 3) Consolidate UTXOs via dashboard, 4) Reduce provisioning count.`; } else { suggestion = ` All ${availableUtxos.length} available UTXOs total ${totalAvailable.toFixed(8)} BTC, which should be sufficient. ` + `This may be a fee estimation issue. Consider consolidating UTXOs via dashboard to create larger UTXOs.`; } throw new Error( `No UTXO large enough for anchor with provisioning. Required: ${finalTotalNeeded.toFixed(8)} BTC, ` + `Largest available: ${largestUtxo.amount} BTC. ` + `Total from ${selectedUtxos.length} UTXOs: ${totalSelectedAmount.toFixed(8)} BTC.${suggestion}` ); } logger.info('Combining multiple UTXOs for anchor transaction', { numberOfUtxos: selectedUtxos.length, totalAmount: totalSelectedAmount, totalNeeded: finalTotalNeeded, estimatedFee: estimatedFeeForMultipleInputs, }); // Verrouiller tous les UTXOs sélectionnés for (const utxo of selectedUtxos) { this.lockUtxo(utxo.txid, utxo.vout); } // Utiliser le premier UTXO comme référence pour la compatibilité avec le code existant selectedUtxo = selectedUtxos[0]; } else { // Un seul UTXO assez grand trouvé selectedUtxos = [{ txid: utxoFromDb.txid, vout: utxoFromDb.vout, address: utxoFromDb.address || '', amount: utxoFromDb.amount, confirmations: utxoFromDb.confirmations || 0, blockTime: utxoFromDb.block_time, }]; totalSelectedAmount = utxoFromDb.amount; selectedUtxo = selectedUtxos[0]; logger.info('Selected UTXO from database', { txid: selectedUtxo.txid.substring(0, 16) + '...', vout: selectedUtxo.vout, amount: selectedUtxo.amount, confirmations: selectedUtxo.confirmations, totalNeeded, }); // Verrouiller l'UTXO sélectionné this.lockUtxo(selectedUtxo.txid, selectedUtxo.vout); } // Créer les outputs // Note: Bitcoin Core ne permet qu'un seul OP_RETURN par transaction via 'data' // Pour plusieurs OP_RETURN, il faut créer la transaction manuellement avec des scripts // Pour l'instant, on utilise un seul OP_RETURN combiné avec format: "ANCHOR:|CHANGE:
:|FEE:" const outputs = {}; // 1 output d'ancrage de 2500 sats (arrondi à 8 décimales) outputs[address] = roundTo8Decimals(anchorOutputAmount); // N outputs de provisionnement de 2500 sats chacun (arrondis à 8 décimales) // Les adresses ont déjà été générées en parallèle plus haut for (let i = 0; i < provisioningCount; i++) { outputs[finalProvisioningAddresses[i]] = roundTo8Decimals(utxoAmount); } // Calculer le change (arrondi à 8 décimales) // Utiliser totalSelectedAmount si plusieurs UTXOs sont combinés const totalInputAmount = selectedUtxos.length > 1 ? totalSelectedAmount : selectedUtxo.amount; // Ajuster les frais si plusieurs inputs const finalEstimatedFee = selectedUtxos.length > 1 ? estimatedFeeForMultipleInputs : estimatedFee; const change = roundTo8Decimals(totalInputAmount - totalOutputAmount - finalEstimatedFee); let changeAddress = null; if (change > 0.00001) { changeAddress = changeAddressCandidate; 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 }); } // Construire les données OP_RETURN avec marquage onchain du change et des frais // Format: "ANCHOR:" + hash (32 bytes) + "|CHANGE:
:|FEE:" // Le hash doit être en bytes, pas en hex string, pour compatibilité avec la vérification const changeAmountSats = change > 0.00001 ? Math.round(change * 100000000) : 0; const changeAddressStr = changeAddress || 'none'; const feeAmountSats = Math.round(estimatedFee * 100000000); // Construire les métadonnées de change et frais (format compact avec sats) const metadataParts = [ `CHANGE:${changeAddressStr}:${changeAmountSats}`, `FEE:${feeAmountSats}`, ]; const metadataString = metadataParts.join('|'); // Créer l'OP_RETURN : "ANCHOR:" + hash (bytes) + "|" + métadonnées const metadataBuffer = Buffer.from(metadataString, 'utf8'); const combinedData = Buffer.concat([ Buffer.from('ANCHOR:', 'utf8'), hashBuffer, // hash en bytes (32 bytes) Buffer.from('|', 'utf8'), metadataBuffer, // métadonnées en UTF-8 ]); // Ajouter l'OP_RETURN (doit être en premier dans les outputs) outputs.data = combinedData.toString('hex'); logger.info('OP_RETURN metadata created', { hash: hash.substring(0, 16) + '...', changeAddress: changeAddressStr.substring(0, 16) + '...', changeAmountSats, feeAmountSats, totalSize: combinedData.length, }); // Vérifier que tous les UTXOs sont toujours disponibles avant de les utiliser // (peut avoir été dépensés entre la sélection et l'utilisation) // Limiter les tentatives pour éviter les boucles infinies if (retryCount < 3) { try { // Récupérer toutes les adresses uniques des UTXOs sélectionnés const uniqueAddresses = [...new Set(selectedUtxos.map(u => u.address))]; const utxoCheck = await this.callRPCWithRetry('listunspent', [0, 9999999, uniqueAddresses]); // Vérifier que tous les UTXOs sont toujours disponibles let allUtxosAvailable = true; for (const utxo of selectedUtxos) { const utxoStillAvailable = utxoCheck.some(u => u.txid === utxo.txid && u.vout === utxo.vout ); if (!utxoStillAvailable) { allUtxosAvailable = false; logger.warn('Selected UTXO no longer available, marking as spent', { txid: utxo.txid.substring(0, 16) + '...', vout: utxo.vout, }); // Marquer l'UTXO comme dépensé dans la DB try { const dbForUpdate = getDatabase(); dbForUpdate.prepare(` UPDATE utxos SET is_spent_onchain = 1, is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND vout = ? `).run(utxo.txid, utxo.vout); } catch (dbError) { logger.warn('Error updating UTXO in database', { error: dbError.message }); } } } if (!allUtxosAvailable) { // Au moins un UTXO n'est plus disponible, déverrouiller tous et réessayer logger.warn('Some UTXOs no longer available, unlocking all and retrying', { retryCount, numberOfUtxos: selectedUtxos.length, }); // Déverrouiller tous les UTXOs for (const utxo of selectedUtxos) { this.unlockUtxo(utxo.txid, utxo.vout); } // Réessayer (récursion limitée à 3 tentatives) return this.createAnchorTransaction(hash, recipientAddress, provisioningAddresses, numberOfProvisioningUtxos, retryCount + 1); } } catch (checkError) { logger.warn('Error checking UTXO availability, proceeding anyway', { error: checkError.message, numberOfUtxos: selectedUtxos.length, }); // Continuer même si la vérification échoue (peut être un problème réseau temporaire) } } else { logger.error('Max retry count reached for UTXO selection', { retryCount }); throw new Error('Failed to find available UTXOs after multiple attempts'); } // Créer la transaction avec tous les UTXOs sélectionnés const inputs = selectedUtxos.map(utxo => ({ txid: utxo.txid, vout: utxo.vout, })); let tx; try { tx = await this.callRPCCommandWithRetry('createrawtransaction', inputs, outputs); } catch (error) { logger.error('Error creating raw transaction', { error: error.message, txid: selectedUtxo.txid.substring(0, 16) + '...', vout: selectedUtxo.vout, }); // Marquer tous les UTXOs comme dépensés si l'erreur suggère qu'ils n'existent plus if (error.message.includes('not found') || error.message.includes('does not exist')) { try { const dbForUpdate = getDatabase(); const updateStmt = dbForUpdate.prepare(` UPDATE utxos SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND vout = ? `); for (const utxo of selectedUtxos) { updateStmt.run(utxo.txid, utxo.vout); } } catch (dbError) { logger.warn('Error updating UTXOs in database', { error: dbError.message }); } } throw new Error(`Failed to create transaction: ${error.message}`); } // Signer la transaction // Utiliser command() directement pour éviter les problèmes avec la bibliothèque const signedTx = await this.callRPCCommandWithRetry('signrawtransactionwithwallet', tx); if (!signedTx.complete) { const errorDetails = signedTx.errors || []; const errorMessages = errorDetails.map(e => { const errorMsg = e.error || 'Unknown error'; const txid = e.txid || selectedUtxo.txid.substring(0, 16) + '...'; const vout = e.vout !== undefined ? e.vout : selectedUtxo.vout; return `${errorMsg} (txid: ${txid}, vout: ${vout})`; }).join('; '); logger.error('Transaction signing failed', { txid: selectedUtxo.txid.substring(0, 16) + '...', vout: selectedUtxo.vout, errors: errorDetails, signedTxHex: signedTx.hex ? signedTx.hex.substring(0, 32) + '...' : 'none', }); // Si l'erreur indique que l'UTXO n'existe plus ou est déjà dépensé, le marquer comme dépensé const hasUtxoNotFoundError = errorDetails.some(e => { const errorMsg = (e.error || '').toLowerCase(); return errorMsg.includes('not found') || errorMsg.includes('does not exist') || errorMsg.includes('missing') || errorMsg.includes('already spent') || errorMsg.includes('input not found'); }); if (hasUtxoNotFoundError) { try { const dbForUpdate = getDatabase(); const updateStmt = dbForUpdate.prepare(` UPDATE utxos SET is_spent_onchain = 1, is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND vout = ? `); for (const utxo of selectedUtxos) { updateStmt.run(utxo.txid, utxo.vout); } logger.info('UTXOs marked as spent due to signing error', { numberOfUtxos: selectedUtxos.length, error: errorMessages, }); } catch (dbError) { logger.warn('Error updating UTXO in database', { error: dbError.message }); } } throw new Error(`Transaction signing failed: ${errorMessages || 'Unknown error'}`); } // 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 let txid; try { txid = await this.callRPCCommandWithRetry('sendrawtransaction', signedTx.hex, 0); } catch (sendError) { // Gérer l'erreur de remplacement RBF (Replace By Fee) // Si une transaction avec les mêmes inputs existe déjà, Bitcoin Core rejette la nouvelle // si les frais ne sont pas plus élevés const errorMessage = sendError.message || sendError.toString() || JSON.stringify(sendError); logger.warn('Error sending transaction to mempool', { error: errorMessage, hash: hash.substring(0, 16) + '...', }); // Vérifier si c'est une erreur de remplacement RBF const isRbfError = errorMessage.includes('insufficient fee') && (errorMessage.includes('rejecting replacement') || errorMessage.includes('replacement')); if (isRbfError) { // Extraire le txid de la transaction existante depuis le message d'erreur // Format: "insufficient fee, rejecting replacement ; new feerate ... <= old feerate ..." const replacementMatch = errorMessage.match(/rejecting replacement ([a-fA-F0-9]{64})/); if (replacementMatch && replacementMatch[1]) { const existingTxid = replacementMatch[1]; logger.info('Transaction replacement rejected, using existing transaction', { existingTxid: existingTxid.substring(0, 16) + '...', hash: hash.substring(0, 16) + '...', }); // Vérifier si la transaction existe dans le mempool ou dans la blockchain try { const mempoolEntry = await this.callRPCCommandWithRetry('getmempoolentry', existingTxid); if (mempoolEntry) { // La transaction existe dans le mempool, utiliser cette transaction txid = existingTxid; logger.info('Using existing transaction from mempool', { txid: txid.substring(0, 16) + '...', }); } else { // La transaction n'existe pas dans le mempool, vérifier si elle est confirmée logger.warn('Existing transaction not found in mempool, checking blockchain', { existingTxid: existingTxid.substring(0, 16) + '...', }); throw new Error('Transaction not in mempool'); } } catch (mempoolError) { // Si getmempoolentry échoue, la transaction n'existe peut-être pas dans le mempool // mais elle pourrait être confirmée dans la blockchain const errorMsg = mempoolError.message || mempoolError.toString(); if (errorMsg.includes('not in mempool') || errorMsg.includes('Transaction not in mempool')) { // La transaction n'est pas dans le mempool, vérifier si elle est confirmée try { const txInfo = await this.callRPCWithRetry('getTransaction', [existingTxid]); if (txInfo && txInfo.txid) { // La transaction existe dans la blockchain (confirmée), utiliser cette transaction txid = existingTxid; logger.info('Using existing confirmed transaction from blockchain', { txid: txid.substring(0, 16) + '...', confirmations: txInfo.confirmations || 0, }); } else { // La transaction n'existe ni dans le mempool ni dans la blockchain logger.warn('Existing transaction not found in mempool or blockchain', { existingTxid: existingTxid.substring(0, 16) + '...', }); throw sendError; } } catch (txError) { // La transaction n'existe pas, relancer l'erreur originale logger.warn('Could not verify existing transaction in mempool or blockchain', { error: txError.message || txError.toString(), existingTxid: existingTxid.substring(0, 16) + '...', }); throw sendError; } } else { // Autre erreur, relancer l'erreur originale logger.warn('Error checking mempool entry', { error: errorMsg, existingTxid: existingTxid.substring(0, 16) + '...', }); throw sendError; } } } else { // Impossible d'extraire le txid, relancer l'erreur logger.warn('Could not extract txid from RBF error message', { errorMessage: errorMessage.substring(0, 200), }); throw sendError; } } else { // Autre type d'erreur, relancer throw sendError; } } logger.info('Anchor transaction with provisioning sent to mempool', { txid, hash: hash.substring(0, 16) + '...', address, provisioningAddresses: finalProvisioningAddresses.map(addr => addr.substring(0, 16) + '...'), numberOfProvisioningUtxos: provisioningCount, }); // Obtenir les informations de la transaction (dans le mempool) const txInfo = await this.getTransactionInfo(txid); // Obtenir la transaction brute pour identifier les index des outputs const rawTx = await this.callRPCWithRetry('getRawTransaction', [txid, true]); // Calculer les frais réels de la transaction // Frais = somme des inputs - somme des outputs // Optimisation : utiliser les montants déjà connus des UTXOs sélectionnés au lieu de faire des appels RPC // Cela évite N appels RPC supplémentaires (un par input) const totalInputAmountForFee = selectedUtxos.length > 1 ? totalSelectedAmount : selectedUtxo.amount; let totalOutputAmountInTx = 0; // Calculer la somme des outputs if (rawTx.vout) { for (const output of rawTx.vout) { totalOutputAmountInTx += output.value || 0; } } const actualFee = roundTo8Decimals(totalInputAmountForFee - totalOutputAmountInTx); // Construire la liste des outputs avec leur type explicite // En analysant les outputs réels de la transaction brute const outputsInfo = []; const anchorAmountRounded = roundTo8Decimals(anchorOutputAmount); const provisioningAmountRounded = roundTo8Decimals(utxoAmount); // Parcourir tous les outputs de la transaction brute if (rawTx.vout) { for (let i = 0; i < rawTx.vout.length; i++) { const output = rawTx.vout[i]; const outputAddresses = output.scriptPubKey?.addresses || []; const outputAddress = outputAddresses.length > 0 ? outputAddresses[0] : null; const outputAmount = output.value || 0; // Identifier le type d'output let outputType = 'unknown'; let matchedAddress = null; // Vérifier si c'est un OP_RETURN if (output.scriptPubKey?.type === 'nulldata') { outputType = 'op_return'; } // Vérifier si c'est l'output d'ancrage (adresse correspond et montant = 2500 sats) else if (outputAddress === address && Math.abs(outputAmount - anchorAmountRounded) < 0.00000001) { outputType = 'anchor'; matchedAddress = address; } // Vérifier si c'est un output de provisionnement (adresse dans la liste et montant = 2500 sats) else if (finalProvisioningAddresses && finalProvisioningAddresses.includes(outputAddress) && Math.abs(outputAmount - provisioningAmountRounded) < 0.00000001) { outputType = 'provisioning'; matchedAddress = outputAddress; } // Vérifier si c'est le change (adresse correspond à changeAddress) else if (change > 0.00001 && outputAddress === changeAddress) { outputType = 'change'; matchedAddress = changeAddress; } outputsInfo.push({ index: i, type: outputType, address: matchedAddress || outputAddress, amount: outputAmount, }); } } // Marquer tous les UTXOs comme dépensés dans la base de données // Les UTXOs sont dépensés dans une transaction (mempool), mais pas encore confirmés dans un bloc try { const dbForUpdate = getDatabase(); const updateStmt = dbForUpdate.prepare(` UPDATE utxos SET is_spent_onchain = 1, is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND vout = ? `); for (const utxo of selectedUtxos) { updateStmt.run(utxo.txid, utxo.vout); } logger.debug('UTXOs marked as spent in database', { numberOfUtxos: selectedUtxos.length, txid: selectedUtxo.txid.substring(0, 16) + '...', vout: selectedUtxo.vout, }); } catch (error) { logger.warn('Error updating UTXO in database', { error: error.message, txid: selectedUtxo.txid.substring(0, 16) + '...', vout: selectedUtxo.vout, }); } // Déverrouiller tous les UTXOs maintenant que la transaction est dans le mempool // (mise à jour DB déjà faite ci-dessus, mais on déverrouille aussi en mémoire) for (const utxo of selectedUtxos) { this.unlockUtxo(utxo.txid, utxo.vout); } // Stocker l'ancre dans la base de données try { const dbForAnchor = getDatabase(); const date = new Date().toISOString(); dbForAnchor.prepare(` INSERT OR REPLACE INTO anchors (hash, txid, block_height, confirmations, date, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `).run( hash, txid, txInfo.blockheight || null, txInfo.confirmations || 0, date ); logger.debug('Anchor stored in database', { hash: hash.substring(0, 16) + '...', txid: txid.substring(0, 16) + '...', }); } catch (error) { logger.warn('Error storing anchor in database', { error: error.message, hash: hash.substring(0, 16) + '...', txid: txid.substring(0, 16) + '...', }); // Ne pas faire échouer la transaction si le stockage en base échoue } // Libérer le mutex (sera aussi libéré dans finally, mais on le fait ici pour être explicite) 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 outputs: outputsInfo, fee: actualFee, fee_sats: Math.round(actualFee * 100000000), }; } catch (error) { logger.error('Error creating anchor transaction', { error: error.message, hash: hash?.substring(0, 16) + '...', }); // En cas d'erreur, déverrouiller tous les UTXOs if (selectedUtxos && selectedUtxos.length > 0) { // Déverrouiller tous les UTXOs (mise à jour DB + mémoire) for (const utxo of selectedUtxos) { this.unlockUtxo(utxo.txid, utxo.vout); } } // Le mutex sera libéré dans le bloc finally pour garantir la libération même en cas d'erreur non gérée throw error; } finally { // Nettoyer le timeout de sécurité if (mutexSafetyTimeout) { clearTimeout(mutexSafetyTimeout); } // Logger la durée de l'opération const duration = Date.now() - startTime; if (duration > 30000) { logger.warn('Anchor transaction took too long', { duration, hash: hash?.substring(0, 16) + '...', }); } // Garantir que le mutex est toujours libéré, même en cas d'erreur non gérée try { releaseMutex(); } catch (releaseError) { logger.warn('Error releasing mutex', { error: releaseError.message }); } } } /** * 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.callRPCWithRetry('getTransaction', [txid]); const blockchainInfo = await this.callRPCWithRetry('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.callRPCWithRetry('getTransaction', [txid, true]); const rawTx = await this.callRPCWithRetry('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.callRPCWithRetry('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.callRPCWithRetry('getBlockHash', [height]); const block = await this.callRPCWithRetry('getBlock', [blockHash, 2]); // Verbose level 2 // Parcourir toutes les transactions du bloc for (const tx of block.tx || []) { try { const rawTx = await this.callRPCWithRetry('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();