ncantu 1090e39a8a Improve UTXO robustness and fix fee calculation bug
**Motivations:**
- Corriger le bug de calcul des frais qui empêchait l'utilisation de tous les UTXOs disponibles
- Améliorer la robustesse de la gestion des UTXOs pour les ancrages avec provisioning
- Utiliser tous les UTXOs disponibles si nécessaire au lieu de limiter à 20
- Améliorer les messages d'erreur avec des suggestions de solutions

**Root causes:**
- Bug de calcul des frais : la condition utilisait totalNeeded + estimatedFeeForMultipleInputs alors que totalNeeded inclut déjà estimatedFee (double comptage)
- Limitation à 20 UTXOs maximum empêchait d'utiliser tous les UTXOs disponibles
- Messages d'erreur peu informatifs ne suggéraient pas de solutions

**Correctifs:**
- Correction du bug de calcul des frais : utilisation de totalOutputAmount + currentEstimatedFee au lieu de totalNeeded + estimatedFeeForMultipleInputs
- Utilisation de tous les UTXOs disponibles si nécessaire (au lieu de limiter à 20)
- Augmentation de la limite de combinaison de 20 à 100 UTXOs
- Recalcul correct des frais avec le nombre réel d'inputs à chaque étape

**Evolutions:**
- Amélioration des messages d'erreur avec suggestions de solutions (faucet, mining, consolidation, réduction du provisioning)
- Calcul du déficit (shortfall) pour informer l'utilisateur du montant manquant
- Logique de fallback pour utiliser tous les UTXOs disponibles si la première tentative échoue

**Pages affectées:**
- api-anchorage/src/bitcoin-rpc.js
- fixKnowledge/api-anchorage-utxo-robustness-improvements.md
2026-01-28 15:15:47 +01:00

1236 lines
49 KiB
JavaScript

/**
* 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<Function>} 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<Object>} 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<Object>} 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<Object>} 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<any>} 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<any>} 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<string>} 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<number>} 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<Object>} 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:<hash>|CHANGE:<address>:<amount>|FEE:<amount>"
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:<address>:<amount_sats>|FEE:<amount_sats>"
// 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 <txid>; 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<Object>} 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<Object>} 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();