diff --git a/signet-dashboard/public/utxo-list.html b/signet-dashboard/public/utxo-list.html index d86e0c4..ff71975 100644 --- a/signet-dashboard/public/utxo-list.html +++ b/signet-dashboard/public/utxo-list.html @@ -147,6 +147,61 @@ background: #6c757d; cursor: not-allowed; } + .consolidate-button { + background: #ffc107; + color: #000; + border: none; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + font-size: 1em; + margin-top: 10px; + } + .consolidate-button:hover { + background: #e0a800; + } + .consolidate-button:disabled { + background: #6c757d; + cursor: not-allowed; + } + .sortable-header { + cursor: pointer; + user-select: none; + position: relative; + } + .sortable-header:hover { + background: #e9ecef; + } + .sort-arrow { + display: inline-block; + margin-left: 5px; + font-size: 0.8em; + } + .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin: 20px 0; + } + .pagination-button { + padding: 8px 16px; + background: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + } + .pagination-button:hover:not(:disabled) { + background: #0056b3; + } + .pagination-button:disabled { + background: #6c757d; + cursor: not-allowed; + } + .pagination-info { + font-weight: bold; + } .total-amount { font-size: 1.2em; font-weight: bold; @@ -208,7 +263,8 @@

Liste des UTXO

Total d'UTXO : -

-

Capacité d'ancrage restante : - ancrages

+

Capacité d'ancrage restante : - ancrages (- UTXOs confirmés)

+

Montant total : -

Dernière mise à jour : -

@@ -231,12 +287,58 @@ diff --git a/signet-dashboard/src/bitcoin-rpc.js b/signet-dashboard/src/bitcoin-rpc.js index 4e78a06..ee62bf6 100644 --- a/signet-dashboard/src/bitcoin-rpc.js +++ b/signet-dashboard/src/bitcoin-rpc.js @@ -338,7 +338,7 @@ class BitcoinRPC { jsonrpc: '1.0', id: 'listunspent', method: 'listunspent', - params: [0], // Inclure les non confirmés + params: [1], // Minimum 1 confirmation to avoid too-long-mempool-chain errors }), }); @@ -673,11 +673,22 @@ class BitcoinRPC { return b.amount - a.amount; }); - // Calculer le nombre d'UTXO disponibles pour l'ancrage (> 2000 sats et non dépensés) + // Calculer le nombre d'UTXO disponibles pour l'ancrage (> 2000 sats, confirmés et non dépensés) const allUtxos = [...blocRewards, ...anchors, ...changes]; const minAnchorAmount = 2000 / 100000000; // 2000 sats en BTC const availableForAnchor = allUtxos.filter(utxo => - utxo.amount >= minAnchorAmount && !utxo.isSpentOnchain && !utxo.isLockedInMutex + utxo.amount >= minAnchorAmount && + (utxo.confirmations || 0) > 0 && // Only confirmed UTXOs + !utxo.isSpentOnchain && + !utxo.isLockedInMutex + ).length; + + // Compter les UTXOs confirmés disponibles pour l'ancrage + const confirmedAvailableForAnchor = allUtxos.filter(utxo => + utxo.amount >= minAnchorAmount && + (utxo.confirmations || 0) > 0 && // Only confirmed UTXOs + !utxo.isSpentOnchain && + !utxo.isLockedInMutex ).length; // Mettre à jour le cache @@ -725,6 +736,7 @@ class BitcoinRPC { fees, total: allUtxos.length, availableForAnchor, + confirmedAvailableForAnchor, }; } catch (error) { logger.error('Error getting UTXO list', { error: error.message }); @@ -732,6 +744,266 @@ class BitcoinRPC { } } + /** + * Consolide les UTXOs de moins de 2500 sats en un gros UTXO + * @returns {Promise} Transaction créée avec txid + */ + async consolidateSmallUtxos() { + try { + const walletName = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; + const host = process.env.BITCOIN_RPC_HOST || 'localhost'; + 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'); + + // Récupérer les UTXOs confirmés + 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 + }), + }); + + if (!rpcResponse.ok) { + const errorText = await rpcResponse.text(); + logger.error('HTTP error in listunspent for consolidation', { + 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 for consolidation', { error: rpcResult.error }); + throw new Error(`RPC error: ${rpcResult.error.message}`); + } + + const unspent = rpcResult.result || []; + + // Récupérer les UTXOs verrouillés depuis l'API d'ancrage + let lockedUtxos = new Set(); + try { + const anchorApiUrl = process.env.ANCHOR_API_URL || 'http://localhost:3010'; + const anchorApiKey = process.env.ANCHOR_API_KEY || ''; + + const headers = { + 'Content-Type': 'application/json', + }; + if (anchorApiKey) { + headers['x-api-key'] = anchorApiKey; + } + + const lockedResponse = await fetch(`${anchorApiUrl}/api/anchor/locked-utxos`, { + method: 'GET', + headers, + }); + + if (lockedResponse.ok) { + const lockedData = await lockedResponse.json(); + for (const locked of lockedData.locked || []) { + lockedUtxos.add(`${locked.txid}:${locked.vout}`); + } + } + } catch (error) { + logger.debug('Error getting locked UTXOs for consolidation', { error: error.message }); + } + + // Filtrer les UTXOs de moins de 2500 sats (0.000025 BTC), confirmés, non dépensés et non verrouillés + const maxAmount = 0.000025; // 2500 sats + const smallUtxos = unspent.filter(utxo => { + const utxoKey = `${utxo.txid}:${utxo.vout}`; + return utxo.amount < maxAmount && + (utxo.confirmations || 0) > 0 && + !lockedUtxos.has(utxoKey); + }); + + // Vérifier si l'UTXO est dépensé onchain + const availableSmallUtxos = []; + for (const utxo of smallUtxos) { + try { + const txOutResponse = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${auth}`, + }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'gettxout', + method: 'gettxout', + params: [utxo.txid, utxo.vout], + }), + }); + + if (txOutResponse.ok) { + const txOutResult = await txOutResponse.json(); + // Si gettxout retourne null, l'UTXO est dépensé + if (txOutResult.result !== null) { + availableSmallUtxos.push(utxo); + } + } + } catch (error) { + logger.debug('Error checking if UTXO is spent for consolidation', { txid: utxo.txid, vout: utxo.vout, error: error.message }); + } + } + + if (availableSmallUtxos.length === 0) { + throw new Error('No small UTXOs available for consolidation'); + } + + logger.info('Consolidating small UTXOs', { + count: availableSmallUtxos.length, + totalAmount: availableSmallUtxos.reduce((sum, utxo) => sum + utxo.amount, 0), + }); + + // Calculer le montant total + const totalAmount = availableSmallUtxos.reduce((sum, utxo) => sum + utxo.amount, 0); + + // Estimation des frais : base + frais par input + const estimatedFeePerInput = 0.000001; // Frais par input (conservateur) + const estimatedFeeBase = 0.00001; // Frais de base + const estimatedFee = estimatedFeeBase + (availableSmallUtxos.length * estimatedFeePerInput); + + // Arrondir à 8 décimales + const roundTo8Decimals = (amount) => { + return Math.round(amount * 100000000) / 100000000; + }; + + const change = roundTo8Decimals(totalAmount - estimatedFee); + + if (change <= 0) { + throw new Error('Consolidation would result in negative or zero change after fees'); + } + + // Obtenir une adresse de destination pour le gros UTXO consolidé + const address = await this.getNewAddress(); + + // Créer les inputs + const inputs = availableSmallUtxos.map(utxo => ({ + txid: utxo.txid, + vout: utxo.vout, + })); + + // Créer les outputs + const outputs = { + [address]: change, + }; + + // Créer la transaction + const createTxResponse = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${auth}`, + }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'createrawtransaction', + method: 'createrawtransaction', + params: [inputs, outputs], + }), + }); + + if (!createTxResponse.ok) { + const errorText = await createTxResponse.text(); + throw new Error(`HTTP error creating transaction: ${createTxResponse.status} ${errorText}`); + } + + const createTxResult = await createTxResponse.json(); + if (createTxResult.error) { + throw new Error(`RPC error creating transaction: ${createTxResult.error.message}`); + } + + const rawTx = createTxResult.result; + + // Signer la transaction + const signTxResponse = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${auth}`, + }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'signrawtransactionwithwallet', + method: 'signrawtransactionwithwallet', + params: [rawTx], + }), + }); + + if (!signTxResponse.ok) { + const errorText = await signTxResponse.text(); + throw new Error(`HTTP error signing transaction: ${signTxResponse.status} ${errorText}`); + } + + const signTxResult = await signTxResponse.json(); + if (signTxResult.error) { + throw new Error(`RPC error signing transaction: ${signTxResult.error.message}`); + } + + if (!signTxResult.result.complete) { + throw new Error('Transaction signing failed'); + } + + // Envoyer la transaction au mempool + const sendTxResponse = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${auth}`, + }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'sendrawtransaction', + method: 'sendrawtransaction', + params: [signTxResult.result.hex, 0], // maxfeerate = 0 (accepter n'importe quel taux) + }), + }); + + if (!sendTxResponse.ok) { + const errorText = await sendTxResponse.text(); + throw new Error(`HTTP error sending transaction: ${sendTxResponse.status} ${errorText}`); + } + + const sendTxResult = await sendTxResponse.json(); + if (sendTxResult.error) { + throw new Error(`RPC error sending transaction: ${sendTxResult.error.message}`); + } + + const txid = sendTxResult.result; + + logger.info('Consolidation transaction sent to mempool', { + txid, + inputCount: availableSmallUtxos.length, + totalInputAmount: totalAmount, + changeAmount: change, + estimatedFee, + }); + + return { + txid, + inputCount: availableSmallUtxos.length, + totalInputAmount: totalAmount, + changeAmount: change, + estimatedFee, + }; + } catch (error) { + logger.error('Error consolidating small UTXOs', { error: error.message }); + throw error; + } + } + /** * 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 diff --git a/signet-dashboard/src/server.js b/signet-dashboard/src/server.js index 4cd8a65..60d1e28 100644 --- a/signet-dashboard/src/server.js +++ b/signet-dashboard/src/server.js @@ -129,8 +129,9 @@ const HOST = process.env.DASHBOARD_HOST || '0.0.0.0'; // Middleware app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// Augmenter la limite de taille pour le body JSON (100MB) pour supporter les gros fichiers en base64 +app.use(express.json({ limit: '100mb' })); +app.use(express.urlencoded({ extended: true, limit: '100mb' })); // Middleware de logging app.use((req, res, next) => { @@ -141,6 +142,22 @@ app.use((req, res, next) => { next(); }); +// Middleware pour gérer les erreurs de parsing JSON +app.use((err, req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + logger.error('JSON parsing error', { + error: err.message, + path: req.path, + method: req.method + }); + return res.status(400).json({ + error: 'Invalid JSON', + message: err.message + }); + } + next(err); +}); + // Routes spécifiques AVANT le middleware static pour éviter les conflits // Route pour la page principale app.get('/', (req, res) => { @@ -272,6 +289,7 @@ app.get('/api/utxo/list', async (req, res) => { fees: (utxoData.fees || []).length, total: utxoData.total, availableForAnchor: utxoData.availableForAnchor || 0, + confirmedAvailableForAnchor: utxoData.confirmedAvailableForAnchor || 0, }, }); } catch (error) { @@ -280,6 +298,27 @@ app.get('/api/utxo/list', async (req, res) => { } }); +// Route pour consolider les UTXOs de moins de 2500 sats +app.post('/api/utxo/consolidate', async (req, res) => { + try { + const result = await bitcoinRPC.consolidateSmallUtxos(); + res.json({ + success: true, + txid: result.txid, + inputCount: result.inputCount, + totalInputAmount: result.totalInputAmount, + changeAmount: result.changeAmount, + estimatedFee: result.estimatedFee, + }); + } catch (error) { + logger.error('Error consolidating UTXOs', { error: error.message }); + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + // Route pour servir le fichier texte des UTXO app.get('/api/utxo/list.txt', async (req, res) => { try { @@ -471,18 +510,66 @@ function formatBlockTime(seconds) { // Route pour générer un hash SHA256 app.post('/api/hash/generate', (req, res) => { try { - const { text, fileContent } = req.body; + const { text, fileContent, isBase64 } = req.body; + // Validation des paramètres if (!text && !fileContent) { return res.status(400).json({ error: 'text or fileContent is required' }); } - const content = text || fileContent; - const hash = crypto.createHash('sha256').update(content, 'utf8').digest('hex'); + if (text && fileContent) { + return res.status(400).json({ error: 'Cannot provide both text and fileContent' }); + } + + let hash; + + if (text) { + // Pour le texte, utiliser UTF-8 + if (typeof text !== 'string') { + return res.status(400).json({ error: 'text must be a string' }); + } + hash = crypto.createHash('sha256').update(text, 'utf8').digest('hex'); + } else if (fileContent) { + if (typeof fileContent !== 'string') { + return res.status(400).json({ error: 'fileContent must be a string' }); + } + + if (isBase64) { + // Pour les fichiers binaires, décoder le base64 et calculer le hash sur les bytes bruts + if (!fileContent || fileContent.trim().length === 0) { + return res.status(400).json({ error: 'fileContent cannot be empty' }); + } + + let buffer; + try { + buffer = Buffer.from(fileContent, 'base64'); + } catch (error) { + logger.error('Error decoding base64', { + error: error.message, + fileContentLength: fileContent.length, + fileContentPreview: fileContent.substring(0, 100) + }); + return res.status(400).json({ error: 'Invalid base64 data', message: error.message }); + } + + if (buffer.length === 0) { + return res.status(400).json({ error: 'Empty file content after base64 decoding' }); + } + + hash = crypto.createHash('sha256').update(buffer).digest('hex'); + } else { + // Fallback pour compatibilité : traiter comme UTF-8 (pour fichiers texte) + hash = crypto.createHash('sha256').update(fileContent, 'utf8').digest('hex'); + } + } res.json({ hash }); } catch (error) { - logger.error('Error generating hash', { error: error.message }); + logger.error('Error generating hash', { + error: error.message, + stack: error.stack, + bodyKeys: req.body ? Object.keys(req.body) : [] + }); res.status(500).json({ error: error.message }); } });