/** * 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(); // 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 } /** * 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; // Créer une nouvelle Promise qui sera résolue quand le mutex est libéré this.utxoMutexPromise = new Promise((resolve) => { releaseMutex = 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, }); reject(new Error(`Mutex acquisition timeout after ${this.utxoMutexTimeout}ms`)); }, this.utxoMutexTimeout); }), ]); try { await mutexWithTimeout; } 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.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, provisioningAddresses = null, numberOfProvisioningUtxos = null, retryCount = 0) { // Acquérir le mutex pour l'accès aux UTXOs const releaseMutex = await this.acquireUtxoMutex(); let selectedUtxo = null; 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 const addressPromises = []; for (let i = 0; i < addressesNeeded; i++) { addressPromises.push(this.getNewAddress()); } const allAddresses = await Promise.all(addressPromises); // 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 selectedUtxos = []; 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 = 20; // Limite pour éviter des transactions trop grandes estimatedFeeForMultipleInputs = estimatedFee; // Sélectionner les UTXOs jusqu'à atteindre le montant nécessaire for (let i = 0; i < availableUtxos.length && i < maxUtxosToCombine; i++) { const utxo = availableUtxos[i]; if (totalSelectedAmount >= totalNeeded + estimatedFeeForMultipleInputs) { 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 += estimatedFeePerInput; } } if (totalSelectedAmount < totalNeeded + estimatedFeeForMultipleInputs) { const largestUtxo = availableUtxos[0]; throw new Error( `No UTXO large enough for anchor with provisioning. Required: ${totalNeeded.toFixed(8)} BTC, ` + `Largest available: ${largestUtxo.amount} BTC. ` + `Total from ${selectedUtxos.length} UTXOs: ${totalSelectedAmount.toFixed(8)} BTC` ); } logger.info('Combining multiple UTXOs for anchor transaction', { numberOfUtxos: selectedUtxos.length, totalAmount: totalSelectedAmount, totalNeeded: totalNeeded + 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.client.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.client.command('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.client.command('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 const txid = await this.client.command('sendrawtransaction', signedTx.hex, 0); 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.client.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 (provisioningAddresses.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 { // 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.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();