**Motivations:** - Aligner la doc API du dashboard avec les évolutions (pagination serveur, base SQLite, frais, mining, hash, UTXO, etc.) - Documenter le paramètre skipIfExists de l'API d'ancrage - Corriger les références /health pour le dashboard (utiliser /api/blockchain/info) **Root causes:** - N/A (évolution documentation) **Correctifs:** - N/A **Evolutions:** - Section API Dashboard dans api-docs.html ; endpoints utxo/count, utxo/list (pagination, category), utxo/fees, fees/update, hash/list, hash/generate, mining, transactions, anchor/example - Paramètre skipIfExists et réponses old: true/false pour POST /api/anchor/document - DASHBOARD.md : liste endpoints à jour, tests sans /health - DOMAINS_AND_PORTS.md : tests dashboard via /api/blockchain/info - features/dashboard-api-docs-update.md **Pages affectées:** - signet-dashboard/public/api-docs.html - docs/DASHBOARD.md - docs/DOMAINS_AND_PORTS.md - features/dashboard-api-docs-update.md - api-anchorage/src/bitcoin-rpc.js, bitcoin-rpc.js.backup - data/sync-utxos.log - fixKnowledge/api-anchorage-null-includes-error.md, api-anchorage-rbf-replacement-error.md
969 lines
37 KiB
Plaintext
969 lines
37 KiB
Plaintext
/**
|
|
* 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<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;
|
|
|
|
// 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<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.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<string>} 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<number>} 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<Object>} 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:<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.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<Object>} 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<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.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();
|