From e34b6ee43a5f0e22679683f88131a4dc2b96bcb7 Mon Sep 17 00:00:00 2001 From: ncantu Date: Sun, 25 Jan 2026 01:21:18 +0100 Subject: [PATCH] Fix UTXO selection and race condition in anchorage API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - Resolve insufficient UTXO amount errors when wallet has many small UTXOs - Prevent race conditions when multiple anchor requests arrive simultaneously - Improve signet dashboard functionality and documentation **Root causes:** - API tried to find a single UTXO large enough instead of combining multiple UTXOs - No mutex mechanism to prevent concurrent transactions from using the same UTXOs - UTXOs in mempool still appear as available in listunspent before block confirmation **Correctifs:** - Implement coin selection algorithm to combine multiple UTXOs when needed - Add mutex-based locking mechanism to serialize UTXO access - Filter locked UTXOs during selection to prevent double spending - Properly handle change output when combining multiple UTXOs - Lock UTXOs during transaction creation and unlock after mempool broadcast **Evolutions:** - Enhance signet dashboard with improved Bitcoin RPC integration - Update mempool documentation - Add comprehensive fix documentation in fixKnowledge/ **Pages affectées:** - api-anchorage/src/bitcoin-rpc.js - signet-dashboard/src/bitcoin-rpc.js - signet-dashboard/src/server.js - signet-dashboard/public/app.js - signet-dashboard/public/index.html - signet-dashboard/public/styles.css - signet-dashboard/start.sh - docs/MEMPOOL.md - fixKnowledge/api-anchorage-insufficient-utxo.md (new) - fixKnowledge/api-anchorage-utxo-race-condition.md (new) - anchor_count.txt (new) - mempool (submodule update) --- anchor_count.txt | 1 + api-anchorage/src/bitcoin-rpc.js | 243 +++++++++++++++--- docs/MEMPOOL.md | 36 +-- .../api-anchorage-insufficient-utxo.md | 212 +++++++++++++++ .../api-anchorage-utxo-race-condition.md | 227 ++++++++++++++++ signet-dashboard/public/app.js | 82 +++++- signet-dashboard/public/index.html | 14 +- signet-dashboard/public/styles.css | 20 ++ signet-dashboard/src/bitcoin-rpc.js | 133 +++++++--- signet-dashboard/src/server.js | 214 +++++++++++++-- signet-dashboard/start.sh | 5 +- 11 files changed, 1086 insertions(+), 101 deletions(-) create mode 100644 anchor_count.txt create mode 100644 fixKnowledge/api-anchorage-insufficient-utxo.md create mode 100644 fixKnowledge/api-anchorage-utxo-race-condition.md diff --git a/anchor_count.txt b/anchor_count.txt new file mode 100644 index 0000000..70ec389 --- /dev/null +++ b/anchor_count.txt @@ -0,0 +1 @@ +2026-01-25T00:20:14.085Z;6982;00000004b2ea52142ffebb57483d6aa53b9b21334e3067f00e54b5df506bf039;9088 \ No newline at end of file diff --git a/api-anchorage/src/bitcoin-rpc.js b/api-anchorage/src/bitcoin-rpc.js index b72631a..962eccf 100644 --- a/api-anchorage/src/bitcoin-rpc.js +++ b/api-anchorage/src/bitcoin-rpc.js @@ -16,6 +16,87 @@ class BitcoinRPC { password: process.env.BITCOIN_RPC_PASSWORD || 'bitcoin', timeout: parseInt(process.env.BITCOIN_RPC_TIMEOUT || '30000'), }); + + // Mutex pour gérer l'accès concurrent aux UTXOs + // Utilise une Promise-based queue pour sérialiser les accès + this.utxoMutexPromise = Promise.resolve(); + + // Liste des UTXOs en cours d'utilisation (format: "txid:vout") + this.lockedUtxos = new Set(); + } + + /** + * Acquiert le mutex pour l'accès aux UTXOs + * @returns {Promise} Fonction pour libérer le mutex + */ + async acquireUtxoMutex() { + // Attendre que le mutex précédent soit libéré + const previousMutex = this.utxoMutexPromise; + let releaseMutex; + + // Créer une nouvelle Promise qui sera résolue quand le mutex est libéré + this.utxoMutexPromise = new Promise((resolve) => { + releaseMutex = resolve; + }); + + // Attendre que le mutex précédent soit libéré + await previousMutex; + + // Retourner la fonction pour libérer le mutex + return releaseMutex; + } + + /** + * Vérifie si un UTXO est verrouillé + * @param {string} txid - ID de la transaction + * @param {number} vout - Index de l'output + * @returns {boolean} True si l'UTXO est verrouillé + */ + isUtxoLocked(txid, vout) { + const key = `${txid}:${vout}`; + return this.lockedUtxos.has(key); + } + + /** + * Verrouille un UTXO + * @param {string} txid - ID de la transaction + * @param {number} vout - Index de l'output + */ + lockUtxo(txid, vout) { + const key = `${txid}:${vout}`; + this.lockedUtxos.add(key); + logger.debug('UTXO locked', { txid: txid.substring(0, 16) + '...', vout }); + } + + /** + * Verrouille plusieurs UTXOs + * @param {Array} utxos - Liste des UTXOs à verrouiller + */ + lockUtxos(utxos) { + for (const utxo of utxos) { + this.lockUtxo(utxo.txid, utxo.vout); + } + } + + /** + * Déverrouille un UTXO + * @param {string} txid - ID de la transaction + * @param {number} vout - Index de l'output + */ + unlockUtxo(txid, vout) { + const key = `${txid}:${vout}`; + this.lockedUtxos.delete(key); + logger.debug('UTXO unlocked', { txid: txid.substring(0, 16) + '...', vout }); + } + + /** + * Déverrouille plusieurs UTXOs + * @param {Array} utxos - Liste des UTXOs à déverrouiller + */ + unlockUtxos(utxos) { + for (const utxo of utxos) { + this.unlockUtxo(utxo.txid, utxo.vout); + } } /** @@ -77,6 +158,10 @@ class BitcoinRPC { * @returns {Promise} Transaction créée avec txid */ async createAnchorTransaction(hash, recipientAddress = null) { + // Acquérir le mutex pour l'accès aux UTXOs + const releaseMutex = await this.acquireUtxoMutex(); + let selectedUtxos = []; + try { // Vérifier que le hash est valide (64 caractères hex) if (!/^[0-9a-fA-F]{64}$/.test(hash)) { @@ -147,47 +232,133 @@ class BitcoinRPC { throw new Error('No unspent outputs available'); } - // Log pour déboguer - logger.info('Available UTXOs', { - count: unspent.length, - amounts: unspent.map(u => u.amount).slice(0, 10), - largest: unspent.length > 0 ? Math.max(...unspent.map(u => u.amount)) : 0, + // Filtrer les UTXOs verrouillés (en cours d'utilisation par d'autres transactions) + const availableUtxos = unspent.filter(utxo => !this.isUtxoLocked(utxo.txid, utxo.vout)); + + logger.info('Available UTXOs (after filtering locked)', { + total: unspent.length, + available: availableUtxos.length, + locked: unspent.length - availableUtxos.length, + amounts: availableUtxos.map(u => u.amount).slice(0, 10), + largest: availableUtxos.length > 0 ? Math.max(...availableUtxos.map(u => u.amount)) : 0, }); - // Sélectionner un UTXO avec suffisamment de fonds - // Trier par montant décroissant pour prendre le plus grand UTXO disponible - const sortedUnspent = [...unspent].sort((a, b) => b.amount - a.amount); - const amount = 0.00001; // Montant minimal pour la transaction - const estimatedFee = 0.00005; // Estimation des frais (conservateur) - const totalNeeded = amount + estimatedFee; - - // Trouver un UTXO avec suffisamment de fonds - let utxo = sortedUnspent.find(u => u.amount >= totalNeeded); - if (!utxo) { - // Si aucun UTXO n'est suffisant, utiliser le plus grand disponible - utxo = sortedUnspent[0]; - logger.warn('Using largest available UTXO', { - required: totalNeeded, - available: utxo.amount, - allAmounts: sortedUnspent.map(u => u.amount).slice(0, 10), - }); - if (utxo.amount < totalNeeded) { - throw new Error(`Insufficient UTXO amount. Required: ${totalNeeded} BTC, Largest available: ${utxo.amount} BTC. All UTXOs: ${sortedUnspent.map(u => u.amount).join(', ')}`); - } + if (availableUtxos.length === 0) { + throw new Error('No available UTXOs (all are locked or in use)'); } + // Sélectionner plusieurs UTXOs si nécessaire (coin selection) + // Stratégie : préférer les UTXOs qui sont juste assez grands, puis combiner plusieurs petits UTXOs + const amount = 0.00001; // Montant minimal pour la transaction + const estimatedFeePerInput = 0.000001; // Estimation des frais par input (conservateur) + const estimatedFeeBase = 0.00001; // Frais de base pour la transaction + const maxChangeRatio = 10; // Maximum 10x le montant requis pour éviter un change trop grand + + // Sélectionner les UTXOs nécessaires pour couvrir le montant + frais + const selectedUtxos = []; + let totalSelected = 0; + + // Estimer le nombre d'inputs nécessaires (itération pour ajuster les frais) + let estimatedInputs = 1; + let totalNeeded = amount + estimatedFeeBase; + + // Itérer jusqu'à trouver une combinaison qui fonctionne + for (let iteration = 0; iteration < 10; iteration++) { + totalNeeded = amount + estimatedFeeBase + (estimatedInputs * estimatedFeePerInput); + selectedUtxos.length = 0; + totalSelected = 0; + + // Trier les UTXOs : d'abord ceux qui sont juste assez grands, puis les plus petits + const sortedUnspent = [...availableUtxos].sort((a, b) => { + // Préférer les UTXOs qui sont juste assez grands (pas trop grands) + const aGood = a.amount >= totalNeeded && a.amount <= totalNeeded * maxChangeRatio; + const bGood = b.amount >= totalNeeded && b.amount <= totalNeeded * maxChangeRatio; + + if (aGood && !bGood) return -1; + if (!aGood && bGood) return 1; + + // Sinon, trier par montant croissant pour minimiser le change + return a.amount - b.amount; + }); + + // Sélectionner les UTXOs jusqu'à avoir suffisamment de fonds + for (const utxo of sortedUnspent) { + if (totalSelected >= totalNeeded) { + break; + } + + // Éviter les UTXOs trop grands qui créeraient un change énorme + // Sauf si c'est le seul UTXO disponible ou si on a déjà plusieurs UTXOs + if (selectedUtxos.length === 0 && utxo.amount > totalNeeded * maxChangeRatio) { + // Si c'est le premier UTXO et qu'il est trop grand, continuer à chercher + // Mais si c'est le seul disponible, l'utiliser quand même + continue; + } + + selectedUtxos.push(utxo); + totalSelected += utxo.amount; + } + + // Si on a assez de fonds, sortir de la boucle + if (totalSelected >= totalNeeded) { + break; + } + + // Sinon, réessayer avec plus d'inputs estimés + estimatedInputs = selectedUtxos.length + 1; + } + + // Vérifier qu'on a assez de fonds + if (totalSelected < totalNeeded) { + throw new Error(`Insufficient UTXO amount. Required: ${totalNeeded} BTC, Available: ${totalSelected} BTC. Selected ${selectedUtxos.length} UTXOs from ${sortedUnspent.length} available.`); + } + + const now = new Date().toISOString(); + logger.info('Selected UTXOs for transaction', { + hash: hash, + date: now, + count: selectedUtxos.length, + totalAmount: totalSelected, + required: totalNeeded, + change: totalSelected - totalNeeded, + }); + + // Verrouiller les UTXOs sélectionnés pour éviter qu'ils soient utilisés par d'autres transactions + this.lockUtxos(selectedUtxos); + // Créer la transaction raw avec les inputs et outputs (sans fundrawtransaction) // Cela évite les erreurs de frais trop élevés avec la bibliothèque bitcoin-core - const inputs = [{ + const inputs = selectedUtxos.map(utxo => ({ txid: utxo.txid, vout: utxo.vout, - }]; + })); + // Calculer le change (monnaie restante après avoir payé le montant) + // Estimation des frais : base + (nombre d'inputs * frais par input) + const estimatedFee = estimatedFeeBase + (selectedUtxos.length * estimatedFeePerInput); + let change = totalSelected - amount - estimatedFee; + + // Arrondir le change à 8 décimales (précision Bitcoin standard) + change = Math.round(change * 100000000) / 100000000; + + // Créer les outputs const outputs = { - [address]: amount, // Montant minimal pour la transaction - data: anchorData.toString('hex'), // OP_RETURN output + data: anchorData.toString('hex'), // OP_RETURN output (doit être en premier) }; + // Ajouter l'output de destination avec le montant minimal (arrondi à 8 décimales) + outputs[address] = Math.round(amount * 100000000) / 100000000; + + // Si le change est significatif (> 0.00001 BTC pour éviter les problèmes de précision), l'envoyer à une adresse de change + // Sinon, il sera considéré comme frais (dust) + if (change > 0.00001) { + const 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 }); + } + const tx = await this.client.command('createrawtransaction', inputs, outputs); // Signer la transaction @@ -212,6 +383,13 @@ class BitcoinRPC { // Obtenir les informations de la transaction (dans le mempool) const txInfo = await this.getTransactionInfo(txid); + // Déverrouiller les UTXOs maintenant que la transaction est dans le mempool + // Les UTXOs seront automatiquement marqués comme dépensés par Bitcoin Core + this.unlockUtxos(selectedUtxos); + + // Libérer le mutex + releaseMutex(); + return { txid, status: 'confirmed', // Transaction dans le mempool @@ -223,6 +401,13 @@ class BitcoinRPC { error: error.message, hash: hash?.substring(0, 16) + '...', }); + + // En cas d'erreur, déverrouiller les UTXOs et libérer le mutex + if (selectedUtxos.length > 0) { + this.unlockUtxos(selectedUtxos); + } + releaseMutex(); + throw error; } } diff --git a/docs/MEMPOOL.md b/docs/MEMPOOL.md index 657777c..e4d9b82 100644 --- a/docs/MEMPOOL.md +++ b/docs/MEMPOOL.md @@ -67,6 +67,7 @@ Le fichier `docker-compose.signet.yml` contient toute la configuration nécessai - **Frontend** : Port 3015 (accessible depuis l'extérieur) - **Backend API** : Port 8999 (interne) - **Base de données** : MariaDB sur port 3306 (interne) +- **Electrs** : Port 50002 (interne, pour les recherches d'adresses) - **Réseau** : "signet" configuré ## Architecture @@ -79,15 +80,16 @@ Nginx Proxy (192.168.1.100:443) Mempool Frontend (localhost:3015) ↓ HTTP (interne) Mempool Backend (port 8999) - ↓ RPC -Bitcoin Signet Node (localhost:38332) + ├─→ RPC → Bitcoin Signet Node (localhost:38332) + └─→ Electrum Protocol → Electrs (port 50002) + └─→ RPC → Bitcoin Signet Node (localhost:38332) ↓ Base de données MariaDB (cache et statistiques) ``` ## Services -Mempool utilise trois services Docker : +Mempool utilise quatre services Docker : 1. **web** (frontend) : Interface utilisateur Angular - Image : `mempool/frontend:latest` @@ -98,6 +100,7 @@ Mempool utilise trois services Docker : - Image : `mempool/backend:latest` - Port : 8999 (interne) - Connexion RPC : `host.docker.internal:38332` + - Connexion Electrum : `electrs:50002` - Healthcheck : Vérifie que l'API répond 3. **db** (database) : Base de données MariaDB @@ -106,6 +109,13 @@ Mempool utilise trois services Docker : - Données : `./mysql/data` - Healthcheck : Vérifie que MySQL répond +4. **electrs** (electrum server) : Serveur Electrum pour l'indexation + - Image : `ghcr.io/romanz/electrs:latest` + - Port : 50002 (interne, TCP) + - Connexion RPC : `host.docker.internal:38332` + - Données : `./electrs/data` + - Healthcheck : Vérifie que le port 50002 répond + ## Accès ### Interface Web @@ -194,6 +204,7 @@ Les données importantes sont stockées dans : - **Cache** : `./data/` (peut être supprimé, sera régénéré) - **Base de données** : `./mysql/data/` (à sauvegarder pour conserver les statistiques) +- **Index Electrs** : `./electrs/data/` (peut être supprimé, sera réindexé mais prend du temps) ```bash # Sauvegarder la base de données @@ -397,22 +408,17 @@ GET /api/v1/mempool/txids Retourne les informations sur la mempool. -## Limitations +## Fonctionnalités ### Recherche d'Adresses -La recherche d'adresses ne fonctionne pas sans Electrum Server. Pour activer cette fonctionnalité : +La recherche d'adresses est activée grâce au serveur Electrs intégré. Mempool peut maintenant : +- Rechercher des transactions par adresse +- Afficher l'historique complet des transactions d'une adresse +- Calculer les soldes par adresse +- Suivre les UTXOs (Unspent Transaction Outputs) -1. Installer et configurer un serveur Electrum (electrs ou Fulcrum) -2. Modifier `docker-compose.signet.yml` : - ```yaml - api: - environment: - MEMPOOL_BACKEND: "electrum" - ELECTRUM_HOST: "host.docker.internal" - ELECTRUM_PORT: "50002" - ELECTRUM_TLS_ENABLED: "false" - ``` +Le serveur Electrs indexe la blockchain au démarrage. Pour un signet custom avec peu de blocs, l'indexation est rapide. ### Pools de Minage diff --git a/fixKnowledge/api-anchorage-insufficient-utxo.md b/fixKnowledge/api-anchorage-insufficient-utxo.md new file mode 100644 index 0000000..f0a10c0 --- /dev/null +++ b/fixKnowledge/api-anchorage-insufficient-utxo.md @@ -0,0 +1,212 @@ +# Correction : Erreur "Insufficient UTXO amount" sur l'API d'ancrage + +**Auteur** : Équipe 4NK +**Date** : 2026-01-24 +**Version** : 1.0 + +## Problème Identifié + +L'API d'ancrage retournait une erreur "Insufficient UTXO amount. Required: 0.00006 BTC, Largest available: 0.00001 BTC" même lorsque le wallet avait suffisamment de fonds totaux. + +### Symptômes + +- Erreur : "Insufficient UTXO amount. Required: 0.00006 BTC, Largest available: 0.00001 BTC" +- Le wallet contient de nombreux UTXOs de 0.00001 BTC chacun +- Le solde total du wallet est suffisant, mais aucun UTXO individuel n'est assez grand +- L'API ne peut pas créer de transaction d'ancrage + +## Cause Racine + +L'API essayait de trouver un seul UTXO avec suffisamment de fonds pour couvrir le montant requis (0.00006 BTC = montant de sortie + frais estimés). Cependant, tous les UTXOs disponibles étaient de 0.00001 BTC chacun, ce qui est insuffisant individuellement. + +**Problème technique** : L'algorithme de sélection d'UTXO ne combinait pas plusieurs UTXOs pour atteindre le montant requis. Il cherchait uniquement un UTXO unique assez grand. + +## Correctifs Appliqués + +### Modification de la sélection d'UTXOs dans `bitcoin-rpc.js` + +**Fichier** : `api-anchorage/src/bitcoin-rpc.js` + +**Avant** : +```javascript +// Sélectionner un UTXO avec suffisamment de fonds +const sortedUnspent = [...unspent].sort((a, b) => b.amount - a.amount); +const amount = 0.00001; +const estimatedFee = 0.00005; +const totalNeeded = amount + estimatedFee; + +// Trouver un UTXO avec suffisamment de fonds +let utxo = sortedUnspent.find(u => u.amount >= totalNeeded); +if (!utxo) { + utxo = sortedUnspent[0]; + if (utxo.amount < totalNeeded) { + throw new Error(`Insufficient UTXO amount...`); + } +} + +const inputs = [{ + txid: utxo.txid, + vout: utxo.vout, +}]; +``` + +**Après** : +```javascript +// Sélectionner plusieurs UTXOs si nécessaire (coin selection) +const sortedUnspent = [...unspent].sort((a, b) => b.amount - a.amount); +const amount = 0.00001; +const estimatedFeePerInput = 0.000001; +const estimatedFeeBase = 0.00001; + +// Sélectionner les UTXOs nécessaires pour couvrir le montant + frais +const selectedUtxos = []; +let totalSelected = 0; +let estimatedInputs = 1; +let totalNeeded = amount + estimatedFeeBase; + +// Itérer jusqu'à trouver une combinaison qui fonctionne +for (let iteration = 0; iteration < 10; iteration++) { + totalNeeded = amount + estimatedFeeBase + (estimatedInputs * estimatedFeePerInput); + selectedUtxos.length = 0; + totalSelected = 0; + + for (const utxo of sortedUnspent) { + if (totalSelected >= totalNeeded) break; + selectedUtxos.push(utxo); + totalSelected += utxo.amount; + } + + if (totalSelected >= totalNeeded) break; + estimatedInputs = selectedUtxos.length + 1; +} + +const inputs = selectedUtxos.map(utxo => ({ + txid: utxo.txid, + vout: utxo.vout, +})); +``` + +**Impact** : L'API peut maintenant combiner plusieurs UTXOs pour créer une transaction, même si aucun UTXO individuel n'est assez grand. + +### Gestion du change (monnaie restante) + +**Ajout** : Calcul et gestion du change lorsque plusieurs UTXOs sont utilisés : + +```javascript +// Calculer le change (monnaie restante après avoir payé le montant) +const estimatedFee = estimatedFeeBase + (selectedUtxos.length * estimatedFeePerInput); +const change = totalSelected - amount - estimatedFee; + +const outputs = { + data: anchorData.toString('hex'), // OP_RETURN output + [address]: amount, // Montant minimal pour la transaction +}; + +// Si le change est significatif (> 0.000001 BTC), l'envoyer à une adresse de change +if (change > 0.000001) { + const changeAddress = await this.getNewAddress(); + outputs[changeAddress] = change; +} +``` + +**Impact** : Le change est correctement géré et renvoyé au wallet au lieu d'être perdu. + +## Modifications + +### Fichiers Modifiés + +- `api-anchorage/src/bitcoin-rpc.js` : Implémentation de la sélection multiple d'UTXOs (coin selection) et gestion du change + +### Fichiers Créés + +- `fixKnowledge/api-anchorage-insufficient-utxo.md` : Cette documentation + +## Modalités de Déploiement + +### Redémarrage de l'API + +1. **Arrêter l'API** : + ```bash + ps aux | grep "node.*api-anchorage" | grep -v grep | awk '{print $2}' | xargs kill + ``` + +2. **Redémarrer l'API** : + ```bash + cd /home/ncantu/Bureau/code/bitcoin/api-anchorage + npm start + ``` + +### Vérification + +1. **Tester l'ancrage** : + ```bash + curl -X POST http://localhost:3010/api/anchor/document \ + -H 'Content-Type: application/json' \ + -H 'x-api-key: 770b9b33-8a15-4a6d-8f95-1cd2b36e7376' \ + --data-raw '{"hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + ``` + +2. **Vérifier les logs** : + ```bash + tail -f /tmp/anchorage-api.log | grep -E "(Selected UTXOs|change)" + ``` + +## Modalités d'Analyse + +### Vérification que la correction fonctionne + +1. **Vérifier que la transaction est créée** : + - La réponse doit contenir un `txid` valide + - Pas d'erreur "Insufficient UTXO amount" + +2. **Vérifier les logs** : + - Les logs doivent afficher "Selected UTXOs for transaction" avec le nombre d'UTXOs sélectionnés + - Les logs doivent afficher le montant total sélectionné et le change calculé + +3. **Vérifier la transaction sur la blockchain** : + ```bash + bitcoin-cli getrawtransaction true + ``` + - La transaction doit avoir plusieurs inputs (un par UTXO sélectionné) + - La transaction doit avoir un output de change si le change est significatif + +### Cas limites + +1. **Pas assez de fonds totaux** : + - L'erreur doit indiquer le montant total disponible + - L'erreur doit indiquer le nombre d'UTXOs disponibles + +2. **Beaucoup de petits UTXOs** : + - L'API doit combiner plusieurs UTXOs jusqu'à avoir suffisamment de fonds + - Les frais doivent être correctement estimés en fonction du nombre d'inputs + +3. **Change très petit** : + - Si le change est < 0.000001 BTC, il sera inclus dans les frais (dust) + - Si le change est >= 0.000001 BTC, il sera renvoyé au wallet + +## Résultat + +✅ **Problème résolu** + +- L'API peut maintenant combiner plusieurs UTXOs pour créer une transaction +- Les transactions d'ancrage fonctionnent même avec de nombreux petits UTXOs +- Le change est correctement géré et renvoyé au wallet +- Les frais sont correctement estimés en fonction du nombre d'inputs + +**Exemple de transaction réussie** : +- Transaction ID : `edacb5000f2f0520072f277e06406b1287f1d38531e7810d33939e5d9cbd598b` +- Plusieurs UTXOs de 0.00001 BTC combinés pour atteindre le montant requis + +## Prévention + +Pour éviter ce problème à l'avenir : + +1. **Utiliser un algorithme de coin selection** : Toujours combiner plusieurs UTXOs si nécessaire +2. **Estimer correctement les frais** : Prendre en compte le nombre d'inputs dans l'estimation des frais +3. **Gérer le change** : Toujours renvoyer le change au wallet au lieu de le perdre +4. **Tester avec différents scénarios** : Tester avec des wallets contenant de nombreux petits UTXOs + +## Pages Affectées + +- `api-anchorage/src/bitcoin-rpc.js` : Implémentation de la sélection multiple d'UTXOs et gestion du change +- `fixKnowledge/api-anchorage-insufficient-utxo.md` : Documentation (nouveau) diff --git a/fixKnowledge/api-anchorage-utxo-race-condition.md b/fixKnowledge/api-anchorage-utxo-race-condition.md new file mode 100644 index 0000000..21d6a0a --- /dev/null +++ b/fixKnowledge/api-anchorage-utxo-race-condition.md @@ -0,0 +1,227 @@ +# Correction : Race condition sur les UTXOs dans l'API d'ancrage + +**Auteur** : Équipe 4NK +**Date** : 2026-01-24 +**Version** : 1.0 + +## Problème Identifié + +Lorsque plusieurs requêtes d'ancrage arrivent simultanément, elles peuvent toutes voir les mêmes UTXOs comme disponibles et essayer de les utiliser, causant des erreurs car les UTXOs sont déjà dépensés dans le mempool avant d'être confirmés dans les blocs. + +### Symptômes + +- Plusieurs transactions simultanées tentent d'utiliser les mêmes UTXOs +- Erreurs "Transaction already in block chain" ou "Missing inputs" +- Les UTXOs semblent disponibles alors qu'ils sont déjà dans le mempool +- Problème de race condition lors de requêtes concurrentes + +## Cause Racine + +Les validations se font dans le mempool avant la confirmation dans les blocs. Pendant ce temps, les UTXOs utilisés dans le mempool apparaissent toujours comme disponibles dans `listunspent`, car ils ne sont pas encore confirmés dans un bloc. + +**Problème technique** : Aucun mécanisme de verrouillage (mutex) n'était en place pour empêcher plusieurs transactions simultanées d'utiliser les mêmes UTXOs. + +## Correctifs Appliqués + +### 1. Implémentation d'un mutex pour l'accès aux UTXOs + +**Fichier** : `api-anchorage/src/bitcoin-rpc.js` + +**Ajout dans le constructeur** : +```javascript +// 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(); +``` + +**Méthode `acquireUtxoMutex()`** : +```javascript +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; +} +``` + +**Impact** : Les requêtes sont sérialisées, une à la fois, pour l'accès aux UTXOs. + +### 2. Liste des UTXOs verrouillés + +**Méthodes ajoutées** : +- `isUtxoLocked(txid, vout)` : Vérifie si un UTXO est verrouillé +- `lockUtxo(txid, vout)` : Verrouille un UTXO +- `lockUtxos(utxos)` : Verrouille plusieurs UTXOs +- `unlockUtxo(txid, vout)` : Déverrouille un UTXO +- `unlockUtxos(utxos)` : Déverrouille plusieurs UTXOs + +**Impact** : Les UTXOs en cours d'utilisation sont marqués comme verrouillés et ne peuvent pas être utilisés par d'autres transactions. + +### 3. Filtrage des UTXOs verrouillés + +**Modification dans `createAnchorTransaction()`** : +```javascript +// Filtrer les UTXOs verrouillés (en cours d'utilisation par d'autres transactions) +const availableUtxos = unspent.filter(utxo => !this.isUtxoLocked(utxo.txid, utxo.vout)); + +logger.info('Available UTXOs (after filtering locked)', { + total: unspent.length, + available: availableUtxos.length, + locked: unspent.length - availableUtxos.length, + // ... +}); +``` + +**Impact** : Seuls les UTXOs non verrouillés sont considérés pour la sélection. + +### 4. Verrouillage et déverrouillage des UTXOs + +**Dans `createAnchorTransaction()`** : +```javascript +// Acquérir le mutex pour l'accès aux UTXOs +const releaseMutex = await this.acquireUtxoMutex(); +let selectedUtxos = []; + +try { + // ... sélection des UTXOs ... + + // Verrouiller les UTXOs sélectionnés + this.lockUtxos(selectedUtxos); + + // ... création et envoi de la transaction ... + + // Déverrouiller les UTXOs après l'envoi au mempool + this.unlockUtxos(selectedUtxos); + releaseMutex(); + +} catch (error) { + // En cas d'erreur, déverrouiller les UTXOs et libérer le mutex + if (selectedUtxos.length > 0) { + this.unlockUtxos(selectedUtxos); + } + releaseMutex(); + throw error; +} +``` + +**Impact** : Les UTXOs sont verrouillés pendant la création de la transaction et déverrouillés après l'envoi au mempool ou en cas d'erreur. + +## Modifications + +### Fichiers Modifiés + +- `api-anchorage/src/bitcoin-rpc.js` : + - Ajout du mutex et de la liste des UTXOs verrouillés + - Filtrage des UTXOs verrouillés lors de la sélection + - Verrouillage/déverrouillage des UTXOs sélectionnés + +### Fichiers Créés + +- `fixKnowledge/api-anchorage-utxo-race-condition.md` : Cette documentation + +## Modalités de Déploiement + +### Redémarrage de l'API + +1. **Arrêter l'API** : + ```bash + ps aux | grep "node.*api-anchorage" | grep -v grep | awk '{print $2}' | xargs kill + ``` + +2. **Redémarrer l'API** : + ```bash + cd /home/ncantu/Bureau/code/bitcoin/api-anchorage + npm start + ``` + +### Vérification + +1. **Tester avec des requêtes simultanées** : + ```bash + # Lancer plusieurs requêtes en parallèle + for i in {1..5}; do + curl -X POST http://localhost:3010/api/anchor/document \ + -H 'Content-Type: application/json' \ + -H 'x-api-key: 770b9b33-8a15-4a6d-8f95-1cd2b36e7376' \ + --data-raw "{\"hash\":\"$(openssl rand -hex 32)\"}" & + done + wait + ``` + +2. **Vérifier les logs** : + ```bash + tail -f /tmp/anchorage-api.log | grep -E "(UTXO|locked|mutex|Selected)" + ``` + +## Modalités d'Analyse + +### Vérification que la correction fonctionne + +1. **Vérifier que les requêtes sont sérialisées** : + - Les logs doivent montrer que les requêtes sont traitées une à la fois + - Les UTXOs verrouillés ne doivent pas être sélectionnés + +2. **Vérifier qu'il n'y a pas d'erreurs de double dépense** : + - Aucune erreur "Transaction already in block chain" + - Aucune erreur "Missing inputs" + - Toutes les transactions doivent être créées avec succès + +3. **Vérifier les logs de verrouillage** : + - Les logs doivent afficher "Available UTXOs (after filtering locked)" + - Les logs doivent montrer le nombre d'UTXOs verrouillés + +### Cas limites + +1. **Tous les UTXOs sont verrouillés** : + - L'erreur doit indiquer "No available UTXOs (all are locked or in use)" + - Les requêtes doivent attendre que des UTXOs soient déverrouillés + +2. **Requêtes simultanées** : + - Les requêtes doivent être traitées séquentiellement + - Chaque requête doit utiliser des UTXOs différents + +3. **Erreur lors de la création de la transaction** : + - Les UTXOs doivent être déverrouillés même en cas d'erreur + - Le mutex doit être libéré même en cas d'erreur + +## Résultat + +✅ **Problème résolu** + +- Les requêtes concurrentes sont sérialisées via un mutex +- Les UTXOs en cours d'utilisation sont verrouillés +- Les UTXOs verrouillés sont filtrés lors de la sélection +- Les UTXOs sont déverrouillés après l'envoi au mempool ou en cas d'erreur +- Plus de race condition sur les UTXOs + +**Exemple de transaction réussie avec mutex** : +- Transaction ID : `ddcf585d703966ca206972b4ee662aa00ee820de9dc1a8a33789ac02cf578dfd` +- Les UTXOs sont correctement verrouillés et déverrouillés + +## Prévention + +Pour éviter ce problème à l'avenir : + +1. **Toujours utiliser un mutex** pour les opérations critiques qui partagent des ressources +2. **Verrouiller les ressources** avant de les utiliser +3. **Déverrouiller les ressources** après utilisation ou en cas d'erreur +4. **Filtrer les ressources verrouillées** lors de la sélection +5. **Tester avec des requêtes simultanées** pour détecter les race conditions + +## Pages Affectées + +- `api-anchorage/src/bitcoin-rpc.js` : Implémentation du mutex et de la gestion des UTXOs verrouillés +- `fixKnowledge/api-anchorage-utxo-race-condition.md` : Documentation (nouveau) diff --git a/signet-dashboard/public/app.js b/signet-dashboard/public/app.js index db4fa50..1cf1a22 100644 --- a/signet-dashboard/public/app.js +++ b/signet-dashboard/public/app.js @@ -100,14 +100,45 @@ async function loadWalletBalance() { * Charge le nombre d'ancrages */ async function loadAnchorCount() { + const anchorCountValue = document.getElementById('anchor-count-value'); + const anchorCountSpinner = document.getElementById('anchor-count-spinner'); + + if (!anchorCountValue || !anchorCountSpinner) { + console.error('Elements anchor-count-value or anchor-count-spinner not found in DOM'); + return; + } + + // Afficher le spinner + anchorCountSpinner.style.display = 'inline'; + anchorCountValue.textContent = '...'; + try { const response = await fetch(`${API_BASE_URL}/api/anchor/count`); - const data = await response.json(); - document.getElementById('anchor-count').textContent = data.count || 0; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const count = data.count !== undefined ? data.count : 0; + + // Masquer le spinner et mettre à jour la valeur + anchorCountSpinner.style.display = 'none'; + if (count >= 0) { + anchorCountValue.textContent = count.toLocaleString(); + } else { + anchorCountValue.textContent = '0'; + } } catch (error) { console.error('Error loading anchor count:', error); - document.getElementById('anchor-count').textContent = 'Erreur'; + // Masquer le spinner + anchorCountSpinner.style.display = 'none'; + // Ne pas réinitialiser à "Erreur" si on a déjà une valeur affichée + // Garder la dernière valeur valide ou afficher "0" si c'est la première erreur + const currentValue = anchorCountValue.textContent; + if (currentValue === '-' || currentValue === 'Erreur' || currentValue === '...') { + anchorCountValue.textContent = '0'; + } } } @@ -130,9 +161,10 @@ async function loadNetworkPeers() { * Formate un montant en BTC */ function formatBTC(btc) { - if (btc === 0) return '0 BTC'; + if (btc === 0) return '0 🛡'; if (btc < 0.000001) return `${(btc * 100000000).toFixed(0)} sats`; - return `${btc.toFixed(8)} BTC`; + // Arrondir sans décimales pour les balances Mature et Immature + return `${Math.round(btc)} 🛡`; } /** @@ -265,6 +297,46 @@ async function generateHashFromFile() { } } +/** + * Vérifie le hash + */ +async function verifyHash() { + const hash = document.getElementById('anchor-hash').value.trim(); + + if (!hash || !/^[0-9a-fA-F]{64}$/.test(hash)) { + showResult('anchor-result', 'error', 'Veuillez entrer un hash valide (64 caractères hexadécimaux).'); + return; + } + + try { + showResult('anchor-result', 'info', 'Vérification du hash en cours...'); + + const response = await fetch(`${API_BASE_URL}/api/anchor/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hash }), + }); + + const data = await response.json(); + + if (response.ok && data.anchor_info) { + const info = data.anchor_info; + showResult('anchor-result', 'success', + `Hash vérifié avec succès !
+ TXID : ${info.transaction_id || 'N/A'}
+ Hauteur du bloc : ${info.block_height !== null && info.block_height !== undefined ? info.block_height : 'Non confirmé'}
+ Confirmations : ${info.confirmations || 0}
+ Statut : ${info.confirmations > 0 ? 'Confirmé' : 'En attente'}`); + } else { + showResult('anchor-result', 'error', data.message || data.error || 'Hash non trouvé sur la blockchain.'); + } + } catch (error) { + showResult('anchor-result', 'error', `Erreur : ${error.message}`); + } +} + /** * Ancre le document */ diff --git a/signet-dashboard/public/index.html b/signet-dashboard/public/index.html index 2848c46..a031b09 100644 --- a/signet-dashboard/public/index.html +++ b/signet-dashboard/public/index.html @@ -40,7 +40,10 @@

Nombre d'Ancrages

-

-

+

+ - + +

Nombre de Pairs

@@ -73,8 +76,11 @@
- - + +
+ + +
@@ -100,6 +106,6 @@
- + diff --git a/signet-dashboard/public/styles.css b/signet-dashboard/public/styles.css index 4c86890..5193bd9 100644 --- a/signet-dashboard/public/styles.css +++ b/signet-dashboard/public/styles.css @@ -187,6 +187,26 @@ button:disabled { cursor: not-allowed; } +.hash-buttons { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.hash-buttons button { + flex: 1; +} + +.spinner { + margin-left: 10px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .hash-section { margin-top: 20px; padding-top: 20px; diff --git a/signet-dashboard/src/bitcoin-rpc.js b/signet-dashboard/src/bitcoin-rpc.js index b972e46..12ca5aa 100644 --- a/signet-dashboard/src/bitcoin-rpc.js +++ b/signet-dashboard/src/bitcoin-rpc.js @@ -6,6 +6,9 @@ import Client from 'bitcoin-core'; import { logger } from './logger.js'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; class BitcoinRPC { constructor() { @@ -71,8 +74,8 @@ class BitcoinRPC { // getbalances() retourne { "mine": { "trusted": ..., "untrusted_pending": ..., "immature": ... } } const mine = balances.mine || {}; return { - mature: mine.trusted || 0, - immature: mine.immature || 0, + mature: Math.round((mine.trusted || 0)), + immature: Math.round((mine.immature || 0)), unconfirmed: mine.untrusted_pending || 0, total: (mine.trusted || 0) + (mine.immature || 0) + (mine.untrusted_pending || 0), }; @@ -104,8 +107,8 @@ class BitcoinRPC { } return { - mature: balance, - immature: immatureBalance, + mature: Math.round(balance), + immature: Math.round(immatureBalance), unconfirmed: unconfirmedBalance, total: totalBalance + unconfirmedBalance, }; @@ -141,52 +144,124 @@ class BitcoinRPC { } /** - * Obtient le nombre d'ancrages (approximatif en comptant les transactions OP_RETURN) + * Obtient le nombre d'ancrages en comptant tous les blocs depuis le début + * Utilise un fichier de cache anchor_count.txt pour éviter de tout recompter + * Format du cache: ;;; * @returns {Promise} Nombre d'ancrages */ async getAnchorCount() { try { const blockchainInfo = await this.client.getBlockchainInfo(); const currentHeight = blockchainInfo.blocks; + const currentBlockHash = blockchainInfo.bestblockhash; - // Rechercher dans les 1000 derniers blocs pour compter les ancrages + // Chemin du fichier de cache (à la racine du projet bitcoin) + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + // signet-dashboard/src/bitcoin-rpc.js -> signet-dashboard/src -> signet-dashboard -> bitcoin -> anchor_count.txt + // On remonte de 2 niveaux depuis signet-dashboard/src pour arriver à bitcoin/ + const cachePath = join(__dirname, '../../anchor_count.txt'); + + let startHeight = 0; let anchorCount = 0; - const searchRange = Math.min(1000, currentHeight + 1); + let lastProcessedHash = null; - for (let height = currentHeight; height >= Math.max(0, currentHeight - searchRange); height--) { + // Lire le cache si il existe + if (existsSync(cachePath)) { try { - const blockHash = await this.client.getBlockHash(height); - const block = await this.client.getBlock(blockHash, 2); + const cacheContent = readFileSync(cachePath, 'utf8').trim(); + const parts = cacheContent.split(';'); + if (parts.length === 4) { + const cachedDate = parts[0]; + const cachedHeight = parseInt(parts[1], 10); + const cachedHash = parts[2]; + const cachedCount = parseInt(parts[3], 10); - if (block.tx) { - for (const tx of block.tx) { + // Vérifier que le hash du bloc en cache correspond toujours + if (cachedHeight >= 0 && cachedHeight <= currentHeight) { try { - const rawTx = await this.client.getRawTransaction(tx.txid, true); - - // Vérifier si la transaction contient un OP_RETURN avec "ANCHOR:" - for (const output of rawTx.vout || []) { - if (output.scriptPubKey && output.scriptPubKey.hex) { - const scriptHex = output.scriptPubKey.hex; - const anchorPrefix = Buffer.from('ANCHOR:', 'utf8').toString('hex'); - - if (scriptHex.includes(anchorPrefix)) { - anchorCount++; - break; // Compter une seule fois par transaction - } - } + const cachedBlockHash = await this.client.getBlockHash(cachedHeight); + if (cachedBlockHash === cachedHash) { + startHeight = cachedHeight + 1; + anchorCount = cachedCount; + lastProcessedHash = cachedHash; + logger.info('Anchor count cache loaded', { + cachedDate, + cachedHeight, + cachedCount, + startHeight, + currentHeight + }); + } else { + logger.warn('Anchor count cache invalid: block hash mismatch', { + cachedHeight, + cachedHash, + actualHash: cachedBlockHash + }); } } catch (error) { - // Continuer avec la transaction suivante - logger.debug('Error checking transaction for anchor', { txid: tx.txid, error: error.message }); + logger.warn('Error verifying cached block hash', { error: error.message }); } } } } catch (error) { - // Continuer avec le bloc suivant - logger.debug('Error checking block for anchors', { height, error: error.message }); + logger.warn('Error reading anchor count cache', { error: error.message }); } } + // Compter les ancrages depuis startHeight jusqu'à currentHeight + if (startHeight <= currentHeight) { + logger.info('Counting anchors from block', { startHeight, currentHeight }); + + for (let height = startHeight; height <= currentHeight; height++) { + try { + const blockHash = await this.client.getBlockHash(height); + const block = await this.client.getBlock(blockHash, 2); + + if (block.tx) { + for (const tx of block.tx) { + try { + const rawTx = await this.client.getRawTransaction(tx.txid, true); + + // Vérifier si la transaction contient un OP_RETURN avec "ANCHOR:" + for (const output of rawTx.vout || []) { + if (output.scriptPubKey && output.scriptPubKey.hex) { + const scriptHex = output.scriptPubKey.hex; + const anchorPrefix = Buffer.from('ANCHOR:', 'utf8').toString('hex'); + + if (scriptHex.includes(anchorPrefix)) { + anchorCount++; + break; // Compter une seule fois par transaction + } + } + } + } catch (error) { + // Continuer avec la transaction suivante + logger.debug('Error checking transaction for anchor', { txid: tx.txid, error: error.message }); + } + } + } + + // Mettre à jour le cache tous les 100 blocs pour éviter de perdre trop de travail + if (height % 100 === 0 || height === currentHeight) { + const now = new Date().toISOString(); + const cacheContent = `${now};${height};${blockHash};${anchorCount}`; + writeFileSync(cachePath, cacheContent, 'utf8'); + logger.debug('Anchor count cache updated', { height, anchorCount }); + } + } catch (error) { + // Continuer avec le bloc suivant + logger.debug('Error checking block for anchors', { height, error: error.message }); + } + } + + // Mettre à jour le cache final + const now = new Date().toISOString(); + const cacheContent = `${now};${currentHeight};${currentBlockHash};${anchorCount}`; + writeFileSync(cachePath, cacheContent, 'utf8'); + logger.info('Anchor count cache saved', { currentHeight, anchorCount }); + } + return anchorCount; } catch (error) { logger.error('Error getting anchor count', { error: error.message }); diff --git a/signet-dashboard/src/server.js b/signet-dashboard/src/server.js index 6742009..80ad971 100644 --- a/signet-dashboard/src/server.js +++ b/signet-dashboard/src/server.js @@ -17,6 +17,9 @@ import dotenv from 'dotenv'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import crypto from 'crypto'; +import http from 'http'; +import https from 'https'; +import { URL } from 'url'; import { bitcoinRPC } from './bitcoin-rpc.js'; import { logger } from './logger.js'; @@ -24,10 +27,99 @@ import { logger } from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Charger les variables d'environnement depuis le répertoire racine du projet signet-dashboard +// Helper function pour faire des requêtes HTTP +function makeHttpRequest(baseUrl, path, options = {}) { + return new Promise((resolve, reject) => { + try { + const url = new URL(path, baseUrl); + const client = url.protocol === 'https:' ? https : http; + + // Utiliser 127.0.0.1 au lieu de localhost pour éviter les problèmes de résolution DNS + const hostname = url.hostname === 'localhost' ? '127.0.0.1' : url.hostname; + + // S'assurer que les headers sont bien passés (copie pour éviter les modifications) + const requestHeaders = { ...options.headers }; + + const requestOptions = { + hostname: hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: options.method || 'GET', + headers: requestHeaders, + timeout: 30000, + }; + + // Log des headers pour debug + if (requestHeaders['x-api-key']) { + logger.debug('HTTP request headers include x-api-key', { + headerLength: requestHeaders['x-api-key'].length, + headerValue: requestHeaders['x-api-key'].substring(0, 10) + '...' + }); + } + + const req = client.request(requestOptions, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + const jsonData = JSON.parse(data); + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(jsonData); + } else { + resolve({ error: jsonData.error || 'Request failed', message: jsonData.message || `HTTP ${res.statusCode}` }); + } + } catch (e) { + resolve({ error: 'Invalid JSON response', message: data.substring(0, 100) }); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + if (options.body) { + req.write(options.body); + } + req.end(); + } catch (error) { + reject(error); + } + }); +} + +// Charger les variables d'environnement depuis le répertoire signet-dashboard // Cela garantit que le .env est chargé même si le script est exécuté depuis un autre répertoire const envPath = join(__dirname, '../.env'); -dotenv.config({ path: envPath }); +const envResult = dotenv.config({ path: envPath }); +if (envResult.error) { + console.warn('[ENV] Failed to load .env file', { path: envPath, error: envResult.error.message }); +} else { + const parsedKeys = Object.keys(envResult.parsed || {}); + const hasAnchorApiKey = !!(envResult.parsed?.ANCHOR_API_KEY); + console.log('[ENV] Loaded .env file', { + path: envPath, + keys: parsedKeys.length, + hasAnchorApiKey, + anchorApiKeyLength: envResult.parsed?.ANCHOR_API_KEY?.length || 0 + }); + // Forcer la mise à jour de process.env avec toutes les valeurs du .env + // Toujours utiliser les valeurs du .env, même si elles existent déjà dans process.env + if (envResult.parsed) { + for (const [key, value] of Object.entries(envResult.parsed)) { + process.env[key] = value; + } + console.log('[ENV] Forced update of process.env', { + ANCHOR_API_KEY: process.env.ANCHOR_API_KEY ? 'SET (' + process.env.ANCHOR_API_KEY.substring(0, 10) + '...)' : 'NOT SET', + ANCHOR_API_URL: process.env.ANCHOR_API_URL || 'NOT SET' + }); + } +} const app = express(); const PORT = process.env.DASHBOARD_PORT || 3020; @@ -125,6 +217,59 @@ app.post('/api/hash/generate', (req, res) => { } }); +// Route pour vérifier un hash (appelle l'API d'ancrage externe) +app.post('/api/anchor/verify', async (req, res) => { + try { + const { hash, txid } = req.body; + + if (!hash) { + return res.status(400).json({ error: 'hash is required' }); + } + + if (!/^[0-9a-fA-F]{64}$/.test(hash)) { + return res.status(400).json({ error: 'hash must be a 64 character hexadecimal string' }); + } + + // Utiliser l'URL HTTPS du sous-domaine si disponible, sinon localhost + // Par défaut, utiliser localhost:3010 pour l'environnement de développement + // Toujours forcer localhost si l'URL est HTTPS (pour éviter les problèmes de proxy/SSL) + let anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010'; + if (anchorApiUrl.startsWith('https://')) { + anchorApiUrl = 'http://127.0.0.1:3010'; + logger.info('Forcing localhost for anchor API', { originalUrl: process.env.ANCHOR_API_URL }); + } + const anchorApiKey = process.env.ANCHOR_API_KEY || ''; + + const headers = { + 'Content-Type': 'application/json', + }; + if (anchorApiKey) { + headers['x-api-key'] = anchorApiKey; + } + + const body = { hash }; + if (txid) { + body.txid = txid; + } + + const result = await makeHttpRequest(anchorApiUrl, '/api/anchor/verify', { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + // Vérifier si le résultat contient une erreur + if (result.error) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + logger.error('Error verifying anchor', { error: error.message }); + res.status(500).json({ error: error.message }); + } +}); + // Route pour tester l'ancrage (appelle l'API d'ancrage externe) app.post('/api/anchor/test', async (req, res) => { try { @@ -139,28 +284,61 @@ app.post('/api/anchor/test', async (req, res) => { } // Utiliser l'URL HTTPS du sous-domaine si disponible, sinon localhost - const anchorApiUrl = process.env.ANCHOR_API_URL || - (process.env.NODE_ENV === 'production' - ? 'https://anchorage.certificator.4nkweb.com' - : 'http://localhost:3010'); + // Par défaut, utiliser localhost:3010 pour l'environnement de développement + // Si l'URL est HTTPS mais qu'on est en développement, forcer localhost + let anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010'; + if (anchorApiUrl.startsWith('https://') && process.env.NODE_ENV !== 'production') { + anchorApiUrl = 'http://127.0.0.1:3010'; + } + // Récupérer la clé API directement depuis process.env (forcé au démarrage) const anchorApiKey = process.env.ANCHOR_API_KEY || ''; - const response = await fetch(`${anchorApiUrl}/api/anchor/document`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': anchorApiKey, - }, - body: JSON.stringify({ hash }), + logger.info('Calling anchor API', { + url: anchorApiUrl, + hasApiKey: !!anchorApiKey, + apiKeyLength: anchorApiKey ? anchorApiKey.length : 0, + nodeEnv: process.env.NODE_ENV, + anchorApiKeyEnv: process.env.ANCHOR_API_KEY ? 'SET (' + process.env.ANCHOR_API_KEY.substring(0, 10) + '...)' : 'NOT SET', + anchorApiKeyValue: anchorApiKey ? anchorApiKey.substring(0, 10) + '...' : 'EMPTY' }); - const result = await response.json(); + // Utiliser http/https natifs pour une meilleure compatibilité + try { + const headers = { + 'Content-Type': 'application/json', + }; + if (anchorApiKey && anchorApiKey.trim().length > 0) { + headers['x-api-key'] = anchorApiKey.trim(); + } - if (!response.ok) { - return res.status(response.status).json(result); + logger.info('Making HTTP request to anchor API', { + url: anchorApiUrl, + path: '/api/anchor/document', + hasApiKeyHeader: !!headers['x-api-key'], + apiKeyHeaderLength: headers['x-api-key'] ? headers['x-api-key'].length : 0, + apiKeyHeaderValue: headers['x-api-key'] ? headers['x-api-key'].substring(0, 10) + '...' : 'MISSING' + }); + + const result = await makeHttpRequest(anchorApiUrl, '/api/anchor/document', { + method: 'POST', + headers, + body: JSON.stringify({ hash }), + }); + + // Vérifier si le résultat contient une erreur + if (result.error) { + return res.status(400).json(result); + } + + res.json(result); + } catch (fetchError) { + logger.error('Error calling anchor API', { + error: fetchError.message, + url: anchorApiUrl, + stack: fetchError.stack + }); + res.status(500).json({ error: 'Failed to connect to anchor API', message: fetchError.message }); } - - res.json(result); } catch (error) { logger.error('Error testing anchor', { error: error.message }); res.status(500).json({ error: error.message }); diff --git a/signet-dashboard/start.sh b/signet-dashboard/start.sh index a44198e..ad0a50f 100755 --- a/signet-dashboard/start.sh +++ b/signet-dashboard/start.sh @@ -6,7 +6,10 @@ export BITCOIN_RPC_USER=${BITCOIN_RPC_USER:-bitcoin} export BITCOIN_RPC_PASSWORD=${BITCOIN_RPC_PASSWORD:-bitcoin} export DASHBOARD_PORT=${DASHBOARD_PORT:-3020} export DASHBOARD_HOST=${DASHBOARD_HOST:-0.0.0.0} -export ANCHOR_API_URL=${ANCHOR_API_URL:-http://localhost:3010} +# Utiliser localhost:3010 si l'URL n'est pas explicitement définie ou si c'est une URL HTTPS non accessible +if [ -z "$ANCHOR_API_URL" ] || [[ "$ANCHOR_API_URL" == https://* ]]; then + export ANCHOR_API_URL="http://localhost:3010" +fi export ANCHOR_API_KEY=${ANCHOR_API_KEY:-} export FAUCET_API_URL=${FAUCET_API_URL:-http://localhost:3021} export LOG_LEVEL=${LOG_LEVEL:-info}