/** * 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 || '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(); // 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 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 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, ]); // 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) // - 7 outputs de provisionnement de 2500 sats chacun // - Le reste en change const utxoAmount = 0.000025; // 2500 sats par UTXO const numberOfProvisioningUtxos = 7; // 7 UTXOs pour les ancrages futurs const anchorOutputAmount = utxoAmount; // 1 UTXO pour l'ancrage actuel const totalProvisioningAmount = utxoAmount * numberOfProvisioningUtxos; 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 + numberOfProvisioningUtxos + 1; // 1 ancrage + 7 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, totalProvisioningAmount, totalOutputAmount, estimatedFee, totalNeeded, }); // Obtenir les UTXOs disponibles 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}`); } 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 et non confirmés pour éviter les erreurs "too-long-mempool-chain" // Ne garder que les UTXOs avec au moins 1 confirmation const availableUtxos = unspent .filter(utxo => !this.isUtxoLocked(utxo.txid, utxo.vout)) .filter(utxo => (utxo.confirmations || 0) > 0) // Only confirmed UTXOs .sort((a, b) => b.amount - a.amount); // Trier par montant décroissant logger.info('Available UTXOs (after filtering locked and unconfirmed)', { total: unspent.length, available: availableUtxos.length, locked: unspent.filter(utxo => this.isUtxoLocked(utxo.txid, utxo.vout)).length, unconfirmed: unspent.filter(utxo => (utxo.confirmations || 0) === 0).length, largest: availableUtxos.length > 0 ? availableUtxos[0].amount : 0, }); if (availableUtxos.length === 0) { throw new Error('No available UTXOs (all are locked or in use)'); } // Trouver un UTXO assez grand pour créer 8 outputs de 2500 sats + frais selectedUtxo = availableUtxos.find(utxo => utxo.amount >= totalNeeded); if (!selectedUtxo) { throw new Error( `No UTXO large enough for anchor with provisioning. Required: ${totalNeeded} BTC, ` + `Largest available: ${availableUtxos.length > 0 ? availableUtxos[0].amount : 0} BTC` ); } logger.info('Selected UTXO for anchor with provisioning', { txid: selectedUtxo.txid.substring(0, 16) + '...', vout: selectedUtxo.vout, amount: selectedUtxo.amount, 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); // 7 outputs de provisionnement de 2500 sats chacun (arrondis à 8 décimales) const provisioningAddresses = []; for (let i = 0; i < numberOfProvisioningUtxos; i++) { const provisioningAddress = await this.getNewAddress(); provisioningAddresses.push(provisioningAddress); outputs[provisioningAddress] = roundTo8Decimals(utxoAmount); } // Calculer le change (arrondi à 8 décimales) const change = roundTo8Decimals(selectedUtxo.amount - totalOutputAmount - estimatedFee); let changeAddress = null; if (change > 0.00001) { 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 }); } // 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, }); // Créer la transaction const inputs = [{ txid: selectedUtxo.txid, vout: selectedUtxo.vout, }]; 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 with provisioning sent to mempool', { txid, hash: hash.substring(0, 16) + '...', address, provisioningAddresses: provisioningAddresses.map(addr => addr.substring(0, 16) + '...'), numberOfProvisioningUtxos, }); // 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 let totalInputAmount = 0; let totalOutputAmountInTx = 0; // Calculer la somme des inputs if (rawTx.vin) { for (const input of rawTx.vin) { // Obtenir les informations de la transaction précédente pour connaître le montant de l'input try { const prevTx = await this.client.getRawTransaction(input.txid, true); if (prevTx.vout && prevTx.vout[input.vout]) { totalInputAmount += prevTx.vout[input.vout].value || 0; } } catch (error) { // Si on ne peut pas obtenir la transaction précédente, utiliser le montant de l'UTXO sélectionné logger.debug('Could not get previous transaction for fee calculation', { txid: input.txid, error: error.message, }); totalInputAmount += selectedUtxo.amount; break; // Utiliser le montant connu de l'UTXO sélectionné } } } // Calculer la somme des outputs if (rawTx.vout) { for (const output of rawTx.vout) { totalOutputAmountInTx += output.value || 0; } } const actualFee = roundTo8Decimals(totalInputAmount - 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, }); } } // Déverrouiller l'UTXO maintenant que la transaction est dans le mempool // L'UTXO sera automatiquement marqué comme dépensé par Bitcoin Core this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout); // 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 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 l'UTXO et libérer le mutex if (selectedUtxo) { this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout); } 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();