ncantu c391e7151a Fix IPv6 connection issue in anchor API
**Motivations:**
- L'API d'ancrage tentait de se connecter au nœud Bitcoin via IPv6 (::1:38332) alors que le nœud n'écoute que sur IPv4
- Les ancrages de documents échouaient à cause de cette erreur de connexion

**Root causes:**
- Le code utilisait 'localhost' comme valeur par défaut, qui peut être résolu en IPv6 (::1) selon la configuration système
- Le nœud Bitcoin n'écoute que sur IPv4 (127.0.0.1), pas sur IPv6

**Correctifs:**
- Remplacement de 'localhost' par '127.0.0.1' dans le constructeur BitcoinRPC (ligne 13)
- Remplacement de 'localhost' par '127.0.0.1' dans la fonction createAnchorTransaction (ligne 234)
- Mise à jour de .env.example pour utiliser 127.0.0.1 au lieu de localhost
- Documentation du problème dans fixKnowledge/anchor-api-ipv6-connection-error.md

**Evolutions:**
- Valeur par défaut sécurisée : le code utilise maintenant 127.0.0.1 par défaut, forçant IPv4
- Documentation : le fichier .env.example reflète la bonne pratique

**Pages affectées:**
- api-anchorage/src/bitcoin-rpc.js
- api-anchorage/.env.example
- fixKnowledge/anchor-api-ipv6-connection-error.md
2026-01-26 00:18:51 +01:00

681 lines
25 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';
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<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;
// 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<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) {
const key = `${txid}:${vout}`;
this.lockedUtxos.delete(key);
logger.debug('UTXO unlocked', { 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) {
// 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:<hash>|CHANGE:<address>:<amount>|FEE:<amount>"
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:<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,
});
// 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<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();