Fix: Automatic UTXO provisioning on each Bitcoin anchor transaction

**Motivations:**
- Fix insufficient UTXO amount error in anchor API
- Ensure continuous availability of usable UTXOs for anchor transactions
- Improve anchor transaction reliability and efficiency

**Root causes:**
- UTXO selection logic was too restrictive, rejecting UTXOs larger than needed
- No automatic provisioning of new usable UTXOs when existing ones were not suitable
- Algorithm prevented efficient use of available UTXOs

**Correctifs:**
- Refactored createAnchorTransaction() to provision 7 UTXOs of 2500 sats on each anchor
- Use single large UTXO to create 1 anchor output + 7 provisioning outputs + change
- Simplified UTXO selection: single large UTXO per transaction instead of multiple small ones
- Added UTXO provisioning logic in signet-dashboard
- Enhanced Bitcoin RPC methods in both api-anchorage and signet-dashboard
- Added documentation in fixKnowledge/api-anchorage-utxo-provisioning.md

**Evolutions:**
- Enhanced signet-dashboard with new pages (hash-list, utxo-list, join-signet, api-docs)
- Improved Bitcoin RPC client with better error handling and UTXO management
- Added cache files for hash and UTXO lists
- Updated api-faucet with improved server configuration
- Enhanced anchor count tracking

**Pages affectées:**
- api-anchorage/src/bitcoin-rpc.js: Complete refactor of createAnchorTransaction()
- api-anchorage/src/routes/anchor.js: Enhanced anchor route
- api-anchorage/src/server.js: Server configuration updates
- signet-dashboard/src/bitcoin-rpc.js: Added comprehensive Bitcoin RPC client
- signet-dashboard/src/server.js: Enhanced server with new routes
- signet-dashboard/public/: Added new HTML pages and updated app.js
- api-faucet/src/server.js: Server improvements
- api-faucet/README.md: Documentation updates
- fixKnowledge/api-anchorage-utxo-provisioning.md: New documentation
- anchor_count.txt, hash_list.txt, utxo_list.txt: Tracking files
This commit is contained in:
ncantu 2026-01-25 14:25:49 +01:00
parent b3973ddc41
commit 970b06ee8f
19 changed files with 25342 additions and 125 deletions

View File

@ -1 +1 @@
2026-01-25T02:55:07.388Z;7419;00000016a18555e0e95e3214e128029e8c86e8bbbe68c62d240821a6a2951061;10558
2026-01-25T13:23:57.632Z;8529;0000000473992d4d7c14e033c40dcf6f11c775d60e2454046cc05130960c6900;13989

View File

@ -150,6 +150,7 @@ class BitcoinRPC {
}
}
/**
* Crée une transaction d'ancrage
*
@ -160,7 +161,7 @@ class BitcoinRPC {
async createAnchorTransaction(hash, recipientAddress = null) {
// Acquérir le mutex pour l'accès aux UTXOs
const releaseMutex = await this.acquireUtxoMutex();
let selectedUtxos = [];
let selectedUtxo = null;
try {
// Vérifier que le hash est valide (64 caractères hex)
@ -187,16 +188,54 @@ class BitcoinRPC {
hashBuffer,
]);
// Obtenir les UTXOs disponibles (inclure les non confirmés pour avoir plus d'options)
// Utiliser fetch directement avec l'URL RPC incluant le wallet pour éviter les problèmes de wallet
// 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 || '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}`;
// Utiliser Basic Auth dans les headers (fetch ne supporte pas les credentials dans l'URL)
const auth = Buffer.from(`${username}:${password}`).toString('base64');
const rpcResponse = await fetch(rpcUrl, {
@ -215,7 +254,11 @@ class BitcoinRPC {
if (!rpcResponse.ok) {
const errorText = await rpcResponse.text();
logger.error('HTTP error in listunspent', { status: rpcResponse.status, statusText: rpcResponse.statusText, response: errorText });
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}`);
}
@ -224,142 +267,126 @@ class BitcoinRPC {
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 })) });
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 (en cours d'utilisation par d'autres transactions)
const availableUtxos = unspent.filter(utxo => !this.isUtxoLocked(utxo.txid, utxo.vout));
// Filtrer les UTXOs verrouillés et trouver un gros UTXO
const availableUtxos = unspent
.filter(utxo => !this.isUtxoLocked(utxo.txid, utxo.vout))
.sort((a, b) => b.amount - a.amount); // Trier par montant décroissant
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,
largest: availableUtxos.length > 0 ? availableUtxos[0].amount : 0,
});
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
let sortedUnspent = [];
// Trouver un UTXO assez grand pour créer 8 outputs de 2500 sats + frais
selectedUtxo = availableUtxos.find(utxo => utxo.amount >= totalNeeded);
// Sélectionner les UTXOs nécessaires pour couvrir le montant + frais
const selectedUtxos = [];
let totalSelected = 0;
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`
);
}
// 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
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;
logger.info('Selected UTXO for anchor with provisioning', {
txid: selectedUtxo.txid.substring(0, 16) + '...',
vout: selectedUtxo.vout,
amount: selectedUtxo.amount,
totalNeeded,
});
// 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 = 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;
// Verrouiller l'UTXO sélectionné
this.lockUtxo(selectedUtxo.txid, selectedUtxo.vout);
// Créer les outputs
const outputs = {
data: anchorData.toString('hex'), // OP_RETURN output (doit être en premier)
};
// 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 = {};
// Ajouter l'output de destination avec le montant minimal (arrondi à 8 décimales)
outputs[address] = Math.round(amount * 100000000) / 100000000;
// 1 output d'ancrage de 2500 sats (arrondi à 8 décimales)
outputs[address] = roundTo8Decimals(anchorOutputAmount);
// 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)
// 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) {
const changeAddress = await this.getNewAddress();
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
@ -375,18 +402,105 @@ class BitcoinRPC {
// Le test direct avec bitcoin-cli fonctionne avec cette syntaxe
const txid = await this.client.command('sendrawtransaction', signedTx.hex, 0);
logger.info('Anchor transaction sent to mempool', {
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);
// 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);
// 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();
@ -396,6 +510,9 @@ class BitcoinRPC {
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', {
@ -403,9 +520,9 @@ class BitcoinRPC {
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);
// En cas d'erreur, déverrouiller l'UTXO et libérer le mutex
if (selectedUtxo) {
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
}
releaseMutex();

View File

@ -8,6 +8,31 @@ import { logger } from '../logger.js';
export const anchorRouter = express.Router();
/**
* GET /api/anchor/locked-utxos
* Retourne la liste des UTXOs verrouillés dans le mutex
*/
anchorRouter.get('/locked-utxos', (req, res) => {
try {
const lockedUtxos = Array.from(bitcoinRPC.lockedUtxos || []);
const lockedList = lockedUtxos.map(key => {
const [txid, vout] = key.split(':');
return { txid, vout: parseInt(vout, 10) };
});
res.json({
locked: lockedList,
count: lockedList.length,
});
} catch (error) {
logger.error('Error getting locked UTXOs', { error: error.message });
res.status(500).json({
error: 'Internal Server Error',
message: error.message,
});
}
});
/**
* POST /api/anchor/document
* Ancre un document sur Bitcoin Signet
@ -51,6 +76,9 @@ anchorRouter.post('/document', async (req, res) => {
status: result.status,
confirmations: result.confirmations,
block_height: result.block_height,
outputs: result.outputs || [],
fee: result.fee || null,
fee_sats: result.fee_sats || null,
});
} catch (error) {
logger.error('Anchor error', { error: error.message, stack: error.stack });

View File

@ -50,8 +50,8 @@ app.use((req, res, next) => {
// Middleware d'authentification API Key
app.use((req, res, next) => {
// Exclure /health de l'authentification
if (req.path === '/health' || req.path === '/') {
// Exclure /health et /api/anchor/locked-utxos de l'authentification
if (req.path === '/health' || req.path === '/' || req.path.startsWith('/api/anchor/locked-utxos')) {
return next();
}

View File

@ -53,6 +53,9 @@ FAUCET_API_HOST=0.0.0.0
# Faucet Configuration
FAUCET_AMOUNT=0.0005 # Montant en BTC (50000 sats par défaut)
# API Keys (séparées par des virgules)
API_KEYS=your-api-key-here,another-api-key
# Logging
LOG_LEVEL=info
NODE_ENV=production
@ -61,6 +64,7 @@ NODE_ENV=production
**Important** :
- `BITCOIN_RPC_HOST` : Si l'API est dans un conteneur Docker, utiliser l'IP du conteneur Bitcoin ou `host.docker.internal`
- `FAUCET_AMOUNT` : Montant à distribuer en BTC (par défaut 0.0005 = 50000 sats)
- `API_KEYS` : Définir au moins une clé API valide (séparées par des virgules)
## Démarrage
@ -123,7 +127,13 @@ Vérifie l'état de l'API et de la connexion Bitcoin.
Demande des sats via le faucet.
**Authentification** : Non requise
**Authentification** : Requise (clé API dans le header `x-api-key`)
**Headers** :
```
x-api-key: your-api-key-here
Content-Type: application/json
```
**Body** :
```json
@ -146,7 +156,7 @@ Demande des sats via le faucet.
}
```
**Réponse (erreur)** :
**Réponse (erreur - adresse invalide)** :
```json
{
"error": "Bad Request",
@ -154,6 +164,14 @@ Demande des sats via le faucet.
}
```
**Réponse (erreur - clé API invalide)** :
```json
{
"error": "Unauthorized",
"message": "Invalid or missing API key"
}
```
## Exemples d'Utilisation
### Avec curl
@ -165,6 +183,7 @@ curl http://localhost:3021/health
# Demander des sats
curl -X POST http://localhost:3021/api/faucet/request \
-H "Content-Type: application/json" \
-H "x-api-key: your-api-key-here" \
-d '{
"address": "tb1qwe0nv3s0ewedd63w20r8kwnv22uw8dp2tnj3qc"
}'
@ -177,6 +196,7 @@ const response = await fetch('http://localhost:3021/api/faucet/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'your-api-key-here',
},
body: JSON.stringify({
address: 'tb1qwe0nv3s0ewedd63w20r8kwnv22uw8dp2tnj3qc',
@ -256,6 +276,7 @@ L'API valide automatiquement les adresses Bitcoin avant d'envoyer les fonds. Seu
### Gestion des Erreurs
- **401 Unauthorized** : Clé API manquante ou invalide
- **400 Bad Request** : Adresse invalide
- **503 Service Unavailable** : Solde insuffisant
- **500 Internal Server Error** : Erreur serveur

View File

@ -38,6 +38,29 @@ app.use((req, res, next) => {
next();
});
// Middleware d'authentification API Key
app.use((req, res, next) => {
// Exclure /health et / de l'authentification
if (req.path === '/health' || req.path === '/') {
return next();
}
const apiKey = req.headers['x-api-key'];
// Filtrer les clés vides pour éviter qu'une chaîne vide soit acceptée
const validKeys = process.env.API_KEYS?.split(',').map(k => k.trim()).filter(k => k.length > 0) || [];
// Vérifier que la clé API est présente, non vide, et dans la liste des clés valides
if (!apiKey || apiKey.trim().length === 0 || !validKeys.includes(apiKey.trim())) {
logger.warn('Unauthorized API access attempt', { ip: req.ip, path: req.path });
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or missing API key',
});
}
next();
});
// Routes
app.use('/health', healthRouter);
app.use('/api/faucet', faucetRouter);

View File

@ -0,0 +1,186 @@
# Correction : Provisionnement automatique d'UTXOs à chaque ancrage Bitcoin
**Auteur** : Équipe 4NK
**Date** : 2026-01-24
**Version** : 2.0
## Problème Identifié
L'API d'ancrage retournait une erreur "Insufficient UTXO amount. Required: 0.000021 BTC, Available: 0 BTC. Selected 0 UTXOs from 5112 available" même lorsque le wallet contenait de nombreux UTXOs.
### Symptômes
- Erreur : "Insufficient UTXO amount. Required: 0.000021 BTC, Available: 0 BTC. Selected 0 UTXOs from 5112 available"
- Le wallet contient 5112 UTXOs disponibles
- Aucun UTXO n'est sélectionné malgré la disponibilité
- L'API ne peut pas créer de transaction d'ancrage
## Cause Racine
La logique de sélection d'UTXOs avait deux problèmes :
1. **Rejet des UTXOs trop grands** : La condition rejetait systématiquement les UTXOs plus grands que nécessaire, même s'ils étaient les seuls disponibles.
2. **Manque d'UTXOs utilisables** : Même si des UTXOs étaient disponibles, ils n'étaient pas dans la plage optimale (trop petits ou trop grands), ce qui empêchait leur utilisation efficace.
**Problème technique** : L'algorithme de sélection était trop restrictif et ne provisionnait pas de nouveaux UTXOs utilisables lorsque les UTXOs existants n'étaient pas adaptés.
## Correctifs Appliqués
### Provisionnement à chaque ancrage
**Fichier** : `api-anchorage/src/bitcoin-rpc.js`
**Modification** : Refonte complète de `createAnchorTransaction()` pour provisionner à chaque ancrage
**Stratégie** : Utiliser un gros UTXO pour créer :
- 1 output d'ancrage de 2500 sats (0.000025 BTC) pour l'ancrage actuel
- 7 outputs de provisionnement de 2500 sats chacun pour les ancrages futurs
- Le reste en change
```javascript
async createAnchorTransaction(hash, recipientAddress = null) {
// Montants pour le provisionnement
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
const estimatedFee = estimatedFeeBase + (numberOfOutputs * estimatedFeePerOutput);
const totalNeeded = totalOutputAmount + estimatedFee;
// Trouver un gros UTXO assez grand
selectedUtxo = availableUtxos.find(utxo => utxo.amount >= totalNeeded);
// Créer les outputs
outputs[address] = anchorOutputAmount; // 1 output d'ancrage
// 7 outputs de provisionnement
for (let i = 0; i < numberOfProvisioningUtxos; i++) {
const provisioningAddress = await this.getNewAddress();
outputs[provisioningAddress] = utxoAmount;
}
// Le reste en change
// ...
}
```
**Impact** : Chaque ancrage provisionne automatiquement 7 UTXOs de 2500 sats pour les ancrages futurs, garantissant toujours la disponibilité d'UTXOs utilisables.
## Modifications
### Fichiers Modifiés
- `api-anchorage/src/bitcoin-rpc.js` :
- Refonte complète de `createAnchorTransaction()` : Provisionne à chaque ancrage
- Utilise un gros UTXO pour créer 1 output d'ancrage + 7 outputs de provisionnement
- Suppression de la logique complexe de sélection multiple d'UTXOs
- Simplification : un seul gros UTXO par transaction
### Fichiers Créés
- `fixKnowledge/api-anchorage-utxo-provisioning.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 /srv/4NK/prod.lecoffreio.4nkweb.com/api-anchorage
npm start
```
### Vérification
1. **Tester l'ancrage** :
```bash
curl -X POST https://prod.lecoffreio.4nkweb.com/api/anchor/document \
-H 'Content-Type: application/json' \
-H 'x-api-key: <api-key>' \
--data-raw '{"hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}'
```
2. **Vérifier les logs** :
```bash
tail -f /var/log/api-anchorage.log | grep -E "(UTXO|provisioning)"
```
3. **Vérifier les UTXOs dans le wallet** :
```bash
bitcoin-cli -rpcwallet=custom_signet listunspent 0 | jq '.[] | select(.amount >= 0.00001 and .amount <= 0.0001) | .amount' | wc -l
```
## Modalités d'Analyse
### Vérification que la correction fonctionne
1. **Vérifier le provisionnement à chaque ancrage** :
- Les logs doivent afficher "Anchor transaction with provisioning sent to mempool"
- Les logs doivent indiquer le nombre d'UTXOs de provisionnement créés (7)
2. **Vérifier la sélection d'UTXO** :
- Les logs doivent afficher "Selected UTXO for anchor with provisioning" avec 1 UTXO sélectionné
- Plus d'erreur "Selected 0 UTXOs"
3. **Vérifier les transactions d'ancrage** :
```bash
bitcoin-cli getrawtransaction <txid> true
```
- La transaction doit avoir 1 input (le gros UTXO)
- La transaction doit avoir :
- 1 output OP_RETURN (l'ancrage)
- 1 output d'ancrage de 0.000025 BTC
- 7 outputs de provisionnement de 0.000025 BTC chacun
- 1 output de change (si nécessaire)
### Cas limites
1. **Pas de gros UTXO disponible pour le provisionnement** :
- L'erreur doit indiquer le montant requis et le plus gros UTXO disponible
- Le provisionnement ne sera pas effectué, mais l'ancrage pourra quand même utiliser les UTXOs disponibles
2. **UTXOs provisionnés non encore confirmés** :
- Les UTXOs provisionnés sont visibles avec `listunspent 0` (minconf=0)
- Ils peuvent être utilisés immédiatement même s'ils ne sont pas encore confirmés
3. **Concurrence** :
- Le mutex garantit qu'une seule transaction de provisionnement est créée à la fois
- Les UTXOs sont verrouillés pendant la création de la transaction
## Résultat
✅ **Problème résolu**
- L'API provisionne automatiquement 7 UTXOs de 2500 sats à chaque ancrage
- Chaque ancrage utilise un gros UTXO pour créer 1 output d'ancrage + 7 outputs de provisionnement
- Plus d'erreur "Selected 0 UTXOs" : un seul gros UTXO est utilisé par transaction
- Les UTXOs de 2500 sats sont directement réutilisables pour les ancrages futurs
**Exemple de transaction d'ancrage avec provisionnement** :
- Transaction : 1 input (gros UTXO) → 1 OP_RETURN + 1 ancrage (2500 sats) + 7 provisioning (2500 sats chacun) + change
- Les 7 UTXOs créés sont directement réutilisables pour les ancrages futurs
## Prévention
Pour éviter ce problème à l'avenir :
1. **Provisionnement à chaque ancrage** : Chaque ancrage crée automatiquement 7 UTXOs pour les ancrages futurs
2. **Taille optimale** : Créer des UTXOs de 2500 sats (0.000025 BTC) pour être directement réutilisables
3. **Simplicité** : Utiliser un seul gros UTXO par transaction au lieu de combiner plusieurs petits UTXOs
4. **Efficacité** : Combiner l'ancrage et le provisionnement en une seule transaction
## Pages Affectées
- `api-anchorage/src/bitcoin-rpc.js` :
- Fonction `createAnchorTransaction()` : Refonte complète pour provisionner à chaque ancrage
- Utilise un gros UTXO pour créer 1 output d'ancrage + 7 outputs de provisionnement
- `fixKnowledge/api-anchorage-utxo-provisioning.md` : Documentation (nouveau)

13684
hash_list.txt Normal file

File diff suppressed because it is too large Load Diff

1
hash_list_cache.txt Normal file
View File

@ -0,0 +1 @@
2026-01-25T12:52:10.211Z;8490;000000030328b70ae083bcaf5ce1d2b3a02f792ec9321df23bf8f055ea5a478d

View File

@ -0,0 +1,701 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation API d'Ancrage - Bitcoin Ancrage</title>
<link rel="stylesheet" href="styles.css">
<style>
.api-docs-section {
margin-bottom: 40px;
}
.endpoint-card {
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.endpoint-header {
display: flex;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid var(--border-color);
}
.method-badge {
display: inline-block;
padding: 5px 15px;
border-radius: 5px;
font-weight: bold;
font-size: 0.9em;
margin-right: 15px;
min-width: 70px;
text-align: center;
}
.method-get {
background: #28a745;
color: white;
}
.method-post {
background: #007bff;
color: white;
}
.endpoint-path {
font-family: 'Courier New', monospace;
font-size: 1.2em;
color: var(--primary-color);
font-weight: bold;
}
.endpoint-description {
margin: 20px 0;
color: var(--text-color);
line-height: 1.6;
}
.endpoint-params {
margin: 20px 0;
}
.endpoint-params h4 {
color: var(--primary-color);
margin-bottom: 10px;
}
.param-table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
.param-table th,
.param-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.param-table th {
background: #f5f5f5;
font-weight: bold;
}
.param-name {
font-family: 'Courier New', monospace;
color: var(--primary-color);
font-weight: bold;
}
.param-required {
color: var(--error-color);
font-weight: bold;
}
.param-optional {
color: #666;
}
.code-block {
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
line-height: 1.6;
overflow-x: auto;
margin: 20px 0;
}
.code-block pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.response-example {
margin: 20px 0;
}
.response-example h4 {
color: var(--primary-color);
margin-bottom: 10px;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.warning-box {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.error-box {
background: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.auth-section {
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.copy-button {
background: var(--primary-color);
color: white;
border: none;
padding: 8px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 0.9em;
margin-top: 10px;
transition: background 0.3s;
}
.copy-button:hover {
background: #e0820d;
}
.status-code {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.85em;
font-weight: bold;
margin-right: 10px;
}
.status-200 {
background: #28a745;
color: white;
}
.status-400 {
background: #ffc107;
color: #000;
}
.status-401 {
background: #dc3545;
color: white;
}
.status-402 {
background: #ff9800;
color: white;
}
.status-500 {
background: #dc3545;
color: white;
}
.status-503 {
background: #ff9800;
color: white;
}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← Retour au Dashboard</a>
<header>
<h1>Documentation API d'Ancrage</h1>
<p class="subtitle">API REST pour ancrer et vérifier des documents sur Bitcoin Signet, et utiliser le faucet</p>
</header>
<main>
<!-- Section Authentification -->
<section class="api-docs-section">
<div class="auth-section">
<h2>🔐 Authentification</h2>
<p>Toutes les requêtes vers l'API d'ancrage (sauf les endpoints publics) nécessitent une clé API dans le header <code>X-API-Key</code>.</p>
<div class="code-block">
<pre>X-API-Key: votre-clé-api-ici</pre>
</div>
<div class="info-box">
<p><strong>Endpoints publics (sans authentification) :</strong></p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li><code>GET /health</code> - Vérification de santé (API d'ancrage)</li>
<li><code>GET /api/anchor/locked-utxos</code> - Liste des UTXO verrouillés</li>
<li><code>GET /health</code> - Vérification de santé (API faucet)</li>
</ul>
<p style="margin-top: 10px;"><strong>Endpoints nécessitant une clé API :</strong></p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li><code>POST /api/anchor/document</code> - Ancrer un document</li>
<li><code>POST /api/anchor/verify</code> - Vérifier un hash</li>
<li><code>POST /api/faucet/request</code> - Demander des sats</li>
</ul>
</div>
<div class="warning-box">
<p><strong>⚠️ Important :</strong> Conservez votre clé API secrète et ne la partagez jamais publiquement.</p>
</div>
</div>
</section>
<!-- Endpoint: Health -->
<section class="api-docs-section">
<div class="endpoint-card">
<div class="endpoint-header">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/health</span>
</div>
<div class="endpoint-description">
<p>Vérifie l'état de santé de l'API. Cet endpoint est public et ne nécessite pas d'authentification.</p>
</div>
<div class="response-example">
<h4>Réponse (200 OK)</h4>
<div class="code-block">
<pre>{
"ok": true,
"service": "anchor-api",
"bitcoin": {
"connected": true,
"blocks": 12345
},
"timestamp": "2026-01-25T12:00:00.000Z"
}</pre>
</div>
</div>
<div class="response-example">
<h4>Réponse (503 Service Unavailable) - Bitcoin non connecté</h4>
<div class="code-block">
<pre>{
"ok": false,
"service": "anchor-api",
"error": "Bitcoin RPC connection failed",
"timestamp": "2026-01-25T12:00:00.000Z"
}</pre>
</div>
</div>
</div>
</section>
<!-- Endpoint: Anchor Document -->
<section class="api-docs-section">
<div class="endpoint-card">
<div class="endpoint-header">
<span class="method-badge method-post">POST</span>
<span class="endpoint-path">/api/anchor/document</span>
</div>
<div class="endpoint-description">
<p>Ancre un document sur la blockchain Bitcoin Signet en créant une transaction qui inclut le hash du document dans un OP_RETURN.</p>
</div>
<div class="endpoint-params">
<h4>Paramètres (Body JSON)</h4>
<table class="param-table">
<thead>
<tr>
<th>Paramètre</th>
<th>Type</th>
<th>Requis</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td class="param-name">hash</td>
<td>string</td>
<td><span class="param-required">Oui</span></td>
<td>Hash SHA256 du document en hexadécimal (64 caractères)</td>
</tr>
<tr>
<td class="param-name">documentUid</td>
<td>string</td>
<td><span class="param-optional">Non</span></td>
<td>Identifiant optionnel du document (pour le logging)</td>
</tr>
</tbody>
</table>
</div>
<div class="response-example">
<h4>Exemple de requête</h4>
<div class="code-block">
<pre>curl -X POST https://certificator.4nkweb.com/api/anchor/document \
-H "Content-Type: application/json" \
-H "X-API-Key: votre-clé-api" \
-d '{
"hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"documentUid": "doc-12345"
}'</pre>
</div>
<button class="copy-button" onclick="copyCode(this)">📋 Copier</button>
</div>
<div class="response-example">
<h4>Réponse (200 OK)</h4>
<div class="code-block">
<pre>{
"txid": "abc123def456...",
"status": "pending",
"confirmations": 0,
"block_height": null
}</pre>
</div>
</div>
<div class="response-example">
<h4>Codes de statut possibles</h4>
<ul style="margin-left: 20px;">
<li><span class="status-code status-200">200</span> Succès - Transaction créée et envoyée au mempool</li>
<li><span class="status-code status-400">400</span> Requête invalide - Hash manquant ou format incorrect</li>
<li><span class="status-code status-401">401</span> Non autorisé - Clé API manquante ou invalide</li>
<li><span class="status-code status-402">402</span> Solde insuffisant - Pas assez de fonds pour créer la transaction</li>
<li><span class="status-code status-500">500</span> Erreur serveur - Erreur interne lors de la création de la transaction</li>
</ul>
</div>
<div class="error-box">
<h4>Exemple d'erreur (402 Payment Required)</h4>
<div class="code-block">
<pre>{
"error": "Insufficient Balance",
"message": "Insufficient balance to create anchor transaction"
}</pre>
</div>
</div>
</div>
</section>
<!-- Endpoint: Verify -->
<section class="api-docs-section">
<div class="endpoint-card">
<div class="endpoint-header">
<span class="method-badge method-post">POST</span>
<span class="endpoint-path">/api/anchor/verify</span>
</div>
<div class="endpoint-description">
<p>Vérifie si un hash est ancré sur la blockchain Bitcoin Signet. Recherche dans les transactions OP_RETURN pour trouver le hash.</p>
</div>
<div class="endpoint-params">
<h4>Paramètres (Body JSON)</h4>
<table class="param-table">
<thead>
<tr>
<th>Paramètre</th>
<th>Type</th>
<th>Requis</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td class="param-name">hash</td>
<td>string</td>
<td><span class="param-required">Oui</span></td>
<td>Hash SHA256 à vérifier (64 caractères hex)</td>
</tr>
<tr>
<td class="param-name">txid</td>
<td>string</td>
<td><span class="param-optional">Non</span></td>
<td>ID de transaction optionnel pour accélérer la recherche</td>
</tr>
</tbody>
</table>
</div>
<div class="response-example">
<h4>Exemple de requête</h4>
<div class="code-block">
<pre>curl -X POST https://certificator.4nkweb.com/api/anchor/verify \
-H "Content-Type: application/json" \
-H "X-API-Key: votre-clé-api" \
-d '{
"hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"txid": "abc123def456..."
}'</pre>
</div>
<button class="copy-button" onclick="copyCode(this)">📋 Copier</button>
</div>
<div class="response-example">
<h4>Réponse (200 OK) - Hash trouvé</h4>
<div class="code-block">
<pre>{
"found": true,
"txid": "abc123def456...",
"block_height": 12345,
"confirmations": 100,
"timestamp": "2026-01-25T10:00:00.000Z"
}</pre>
</div>
</div>
<div class="response-example">
<h4>Réponse (200 OK) - Hash non trouvé</h4>
<div class="code-block">
<pre>{
"found": false,
"txid": null,
"block_height": null,
"confirmations": null,
"timestamp": null
}</pre>
</div>
</div>
<div class="response-example">
<h4>Codes de statut possibles</h4>
<ul style="margin-left: 20px;">
<li><span class="status-code status-200">200</span> Succès - Vérification effectuée</li>
<li><span class="status-code status-400">400</span> Requête invalide - Hash manquant ou format incorrect</li>
<li><span class="status-code status-401">401</span> Non autorisé - Clé API manquante ou invalide</li>
<li><span class="status-code status-500">500</span> Erreur serveur - Erreur interne lors de la vérification</li>
</ul>
</div>
</div>
</section>
<!-- Endpoint: Locked UTXOs -->
<section class="api-docs-section">
<div class="endpoint-card">
<div class="endpoint-header">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/api/anchor/locked-utxos</span>
</div>
<div class="endpoint-description">
<p>Retourne la liste des UTXO actuellement verrouillés par le mutex de l'API. Cet endpoint est public et ne nécessite pas d'authentification.</p>
</div>
<div class="response-example">
<h4>Exemple de requête</h4>
<div class="code-block">
<pre>curl -X GET https://certificator.4nkweb.com/api/anchor/locked-utxos</pre>
</div>
<button class="copy-button" onclick="copyCode(this)">📋 Copier</button>
</div>
<div class="response-example">
<h4>Réponse (200 OK)</h4>
<div class="code-block">
<pre>{
"locked": [
{
"txid": "abc123def456...",
"vout": 0
},
{
"txid": "def456abc123...",
"vout": 1
}
],
"count": 2
}</pre>
</div>
</div>
</div>
</section>
<!-- Endpoint: Faucet Request -->
<section class="api-docs-section">
<div class="endpoint-card">
<div class="endpoint-header">
<span class="method-badge method-post">POST</span>
<span class="endpoint-path">/api/faucet/request</span>
</div>
<div class="endpoint-description">
<p>Demande des sats (testnet coins) via le faucet. Distribue 50 000 sats (0.0005 BTC) par défaut sur une adresse Bitcoin Signet valide. Nécessite une clé API valide dans le header <code>x-api-key</code>.</p>
</div>
<div class="endpoint-params">
<h4>Paramètres (Body JSON)</h4>
<table class="param-table">
<thead>
<tr>
<th>Paramètre</th>
<th>Type</th>
<th>Requis</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td class="param-name">address</td>
<td>string</td>
<td><span class="param-required">Oui</span></td>
<td>Adresse Bitcoin Signet valide (commence par <code>tb1</code>, <code>bcrt1</code>, <code>2</code> ou <code>3</code>)</td>
</tr>
</tbody>
</table>
</div>
<div class="response-example">
<h4>Exemple de requête</h4>
<div class="code-block">
<pre>curl -X POST https://certificator.4nkweb.com/api/faucet/request \
-H "Content-Type: application/json" \
-H "x-api-key: votre-clé-api" \
-d '{
"address": "tb1qwe0nv3s0ewedd63w20r8kwnv22uw8dp2tnj3qc"
}'</pre>
</div>
<button class="copy-button" onclick="copyCode(this)">📋 Copier</button>
</div>
<div class="response-example">
<h4>Réponse (200 OK)</h4>
<div class="code-block">
<pre>{
"success": true,
"txid": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890",
"address": "tb1qwe0nv3s0ewedd63w20r8kwnv22uw8dp2tnj3qc",
"amount": 0.0005,
"amount_sats": 50000,
"status": "pending",
"confirmations": 0,
"block_height": null
}</pre>
</div>
</div>
<div class="response-example">
<h4>Codes de statut possibles</h4>
<ul style="margin-left: 20px;">
<li><span class="status-code status-200">200</span> Succès - Transaction créée et envoyée au mempool</li>
<li><span class="status-code status-400">400</span> Requête invalide - Adresse manquante ou format incorrect</li>
<li><span class="status-code status-401">401</span> Non autorisé - Clé API manquante ou invalide</li>
<li><span class="status-code status-503">503</span> Service indisponible - Solde insuffisant dans le wallet du faucet</li>
<li><span class="status-code status-500">500</span> Erreur serveur - Erreur interne lors de la création de la transaction</li>
</ul>
</div>
<div class="error-box">
<h4>Exemple d'erreur (401 Unauthorized)</h4>
<div class="code-block">
<pre>{
"error": "Unauthorized",
"message": "Invalid or missing API key"
}</pre>
</div>
</div>
<div class="error-box">
<h4>Exemple d'erreur (503 Service Unavailable)</h4>
<div class="code-block">
<pre>{
"error": "Insufficient Balance",
"message": "Insufficient balance to send coins"
}</pre>
</div>
</div>
<div class="info-box">
<h4> Notes importantes</h4>
<ul style="margin-left: 20px; margin-top: 10px;">
<li>Le montant par défaut est de 50 000 sats (0.0005 BTC)</li>
<li>L'adresse doit être une adresse Bitcoin Signet valide</li>
<li>La transaction est envoyée au mempool immédiatement</li>
<li>Le statut "pending" signifie que la transaction est dans le mempool mais pas encore confirmée</li>
<li>Les confirmations augmentent à mesure que les blocs sont minés</li>
</ul>
</div>
</div>
</section>
<!-- Section Informations -->
<section class="api-docs-section">
<div class="endpoint-card">
<h2> Informations Complémentaires</h2>
<div class="info-box">
<h4>Format du Hash</h4>
<p>Le hash doit être un hash SHA256 en format hexadécimal, exactement 64 caractères.</p>
<div class="code-block">
<pre>Exemple valide: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
Longueur: 64 caractères
Format: hexadécimal (0-9, a-f, A-F)</pre>
</div>
</div>
<div class="info-box">
<h4>Format de la Transaction</h4>
<p>Les transactions d'ancrage incluent :</p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li>Un output OP_RETURN contenant "ANCHOR:" suivi du hash</li>
<li>Un output d'ancrage de 2500 sats</li>
<li>7 outputs de provisionnement de 2500 sats chacun</li>
<li>Un output de change (si nécessaire)</li>
</ul>
</div>
<div class="info-box">
<h4>Base URL</h4>
<p>L'API est accessible à l'adresse :</p>
<div class="code-block">
<pre>https://certificator.4nkweb.com</pre>
</div>
</div>
<div class="warning-box">
<h4>⚠️ Notes importantes</h4>
<ul style="margin-left: 20px; margin-top: 10px;">
<li>Les transactions sont envoyées au mempool immédiatement</li>
<li>Le statut "pending" signifie que la transaction est dans le mempool mais pas encore confirmée</li>
<li>Les confirmations augmentent à mesure que les blocs sont minés</li>
<li>En cas d'erreur 402 (Solde insuffisant), vous devez approvisionner le wallet de l'API</li>
</ul>
</div>
</div>
</section>
</main>
<footer>
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>
</footer>
</div>
<script>
function copyCode(button) {
const codeBlock = button.previousElementSibling;
const code = codeBlock.querySelector('pre')?.textContent || codeBlock.textContent;
navigator.clipboard.writeText(code).then(() => {
const originalText = button.textContent;
button.textContent = '✅ Copié !';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}).catch(err => {
console.error('Erreur lors de la copie:', err);
alert('Erreur lors de la copie. Veuillez sélectionner et copier manuellement.');
});
}
</script>
</body>
</html>

View File

@ -655,8 +655,14 @@ async function anchorDocument() {
* Demande des sats via le faucet
*/
async function requestFaucet() {
const apiKey = document.getElementById('faucet-api-key').value.trim();
const address = document.getElementById('faucet-address').value.trim();
if (!apiKey) {
showResult('faucet-result', 'error', 'Veuillez entrer une clé API.');
return;
}
if (!address) {
showResult('faucet-result', 'error', 'Veuillez entrer une adresse Bitcoin.');
return;
@ -676,6 +682,7 @@ async function requestFaucet() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify({ address }),
});

View File

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Liste des Hash Ancrés - Bitcoin Ancrage Dashboard</title>
<link rel="stylesheet" href="styles.css">
<style>
.hash-list-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header-section {
margin-bottom: 30px;
}
.header-section h1 {
margin-bottom: 10px;
}
.header-section .back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
}
.header-section .back-link:hover {
text-decoration: underline;
}
.info-section {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.info-section p {
margin: 5px 0;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #007bff;
color: white;
font-weight: bold;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f5f5;
}
.hash-cell {
font-family: monospace;
font-size: 0.9em;
word-break: break-all;
}
.txid-cell {
font-family: monospace;
font-size: 0.9em;
}
.txid-link {
color: #007bff;
text-decoration: none;
}
.txid-link:hover {
text-decoration: underline;
}
.loading {
text-align: center;
padding: 40px;
font-size: 1.2em;
color: #666;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.refresh-button {
background: #28a745;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
margin-top: 10px;
}
.refresh-button:hover {
background: #218838;
}
.refresh-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="container">
<div class="hash-list-container">
<div class="header-section">
<a href="/" class="back-link">← Retour au dashboard</a>
<h1>Liste des Hash Ancrés</h1>
<div class="info-section">
<p><strong>Total de hash ancrés :</strong> <span id="hash-count">-</span></p>
<p><strong>Dernière mise à jour :</strong> <span id="last-update">-</span></p>
<button class="refresh-button" onclick="loadHashList()">Actualiser</button>
<a href="/api/hash/list.txt" download="hash_list.txt" style="margin-left: 10px; color: #007bff; text-decoration: none;">📥 Télécharger le fichier texte</a>
</div>
</div>
<div id="content">
<div class="loading">Chargement des hash...</div>
</div>
</div>
</div>
<script>
const API_BASE_URL = window.location.origin;
// Charger la liste au chargement de la page
document.addEventListener('DOMContentLoaded', () => {
loadHashList();
});
async function loadHashList() {
const contentDiv = document.getElementById('content');
const refreshButton = document.querySelector('.refresh-button');
refreshButton.disabled = true;
contentDiv.innerHTML = '<div class="loading">Chargement des hash...</div>';
try {
const response = await fetch(`${API_BASE_URL}/api/hash/list`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const hashes = data.hashes || [];
document.getElementById('hash-count').textContent = hashes.length.toLocaleString('fr-FR');
updateLastUpdateTime();
if (hashes.length === 0) {
contentDiv.innerHTML = '<div class="info-section"><p>Aucun hash ancré pour le moment.</p></div>';
} else {
let tableHTML = '<div class="table-container"><table><thead><tr>';
tableHTML += '<th>Hash</th>';
tableHTML += '<th>Transaction ID</th>';
tableHTML += '<th>Hauteur du Bloc</th>';
tableHTML += '<th>Confirmations</th>';
tableHTML += '</tr></thead><tbody>';
hashes.forEach((item) => {
const mempoolUrl = 'https://mempool.4nkweb.com/fr';
const txLink = `${mempoolUrl}/tx/${item.txid}`;
tableHTML += '<tr>';
tableHTML += `<td class="hash-cell">${item.hash}</td>`;
tableHTML += `<td class="txid-cell"><a href="${txLink}" target="_blank" rel="noopener noreferrer" class="txid-link">${item.txid}</a></td>`;
tableHTML += `<td>${item.blockHeight !== null ? item.blockHeight.toLocaleString('fr-FR') : '-'}</td>`;
tableHTML += `<td>${item.confirmations.toLocaleString('fr-FR')}</td>`;
tableHTML += '</tr>';
});
tableHTML += '</tbody></table></div>';
contentDiv.innerHTML = tableHTML;
}
} catch (error) {
console.error('Error loading hash list:', error);
contentDiv.innerHTML = `<div class="error">Erreur lors du chargement de la liste des hash : ${error.message}</div>`;
} finally {
refreshButton.disabled = false;
}
}
function updateLastUpdateTime() {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleString('fr-FR');
}
</script>
</body>
</html>

View File

@ -15,6 +15,18 @@
<a href="https://mempool.4nkweb.com/fr/" target="_blank" rel="noopener noreferrer" class="external-link">
🔗 Explorer Mempool
</a>
<a href="/hash-list" class="external-link">
📋 Liste des Hash
</a>
<a href="/utxo-list" class="external-link">
✅ Liste des UTXO
</a>
<a href="/join-signet" class="external-link">
🔗 Rejoindre le Réseau
</a>
<a href="/api-docs" class="external-link">
📚 Documentation API
</a>
</p>
</header>
@ -122,9 +134,11 @@
<h2>Faucet Bitcoin Ancrage</h2>
<div class="card">
<p>Recevez 50 000 ✅ (0.0005 🛡) sur votre adresse Bitcoin Ancrage</p>
<label for="faucet-api-key">Clé API :</label>
<input type="text" id="faucet-api-key" placeholder="Entrez votre clé API">
<label for="faucet-address">Adresse Bitcoin :</label>
<input type="text" id="faucet-address" placeholder="tb1q..." pattern="^(tb1|bcrt1|2|3)[a-zA-HJ-NP-Z0-9]{25,62}$">
<button onclick="requestFaucet()">Demander des Sats</button>
<button onclick="requestFaucet()">Demander des </button>
<div id="faucet-result" class="result"></div>
</div>
</section>

View File

@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rejoindre le Réseau Signet Custom - Bitcoin Ancrage</title>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script>
// Vérifier que la bibliothèque QRCode est chargée
window.addEventListener('load', function() {
if (typeof QRCode === 'undefined') {
console.error('Bibliothèque QRCode non chargée');
} else {
console.log('Bibliothèque QRCode chargée');
}
});
</script>
<style>
.join-section {
margin-bottom: 40px;
}
.config-section {
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.config-section h2 {
color: var(--primary-color);
margin-bottom: 20px;
font-size: 1.8em;
}
.config-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
margin: 20px 0;
}
.copy-button {
background: var(--primary-color);
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
margin-top: 10px;
transition: background 0.3s;
}
.copy-button:hover {
background: #e0820d;
}
.copy-button:active {
transform: scale(0.98);
}
.payment-section {
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
text-align: center;
}
.payment-section h2 {
color: var(--primary-color);
margin-bottom: 20px;
font-size: 1.8em;
}
.payment-info {
margin: 20px 0;
}
.payment-amount {
font-size: 1.5em;
font-weight: bold;
color: var(--primary-color);
margin: 10px 0;
}
.payment-address {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin: 15px 0;
word-break: break-all;
font-size: 0.9em;
}
.qr-code-container {
margin: 30px 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
#qrcode {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.wallet-section {
background: var(--card-background);
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.wallet-section h2 {
color: var(--primary-color);
margin-bottom: 20px;
font-size: 1.8em;
}
.wallet-checkbox {
display: flex;
align-items: center;
margin: 20px 0;
padding: 15px;
background: #f5f5f5;
border-radius: 5px;
}
.wallet-checkbox input[type="checkbox"] {
width: 20px;
height: 20px;
margin-right: 15px;
cursor: pointer;
}
.wallet-checkbox label {
cursor: pointer;
font-size: 1.1em;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.info-box p {
margin: 5px 0;
}
.success-message {
background: #d4edda;
border-left: 4px solid var(--success-color);
padding: 15px;
margin: 20px 0;
border-radius: 5px;
color: #155724;
display: none;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: var(--primary-color);
text-decoration: none;
font-weight: bold;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← Retour au Dashboard</a>
<header>
<h1>Rejoindre le Réseau Signet Custom</h1>
<p class="subtitle">Configuration et accès au réseau Bitcoin Signet personnalisé</p>
</header>
<main>
<!-- Section Configuration -->
<section class="join-section">
<div class="config-section">
<h2>📋 Configuration bitcoin.conf</h2>
<p>Copiez cette configuration dans votre fichier <code>bitcoin.conf</code> pour rejoindre le réseau Signet custom :</p>
<div class="config-code" id="bitcoin-config">signet=1
txindex=1
blockfilterindex=1
peerblockfilters=1
coinstatsindex=1
dnsseed=0
persistmempool=1
uacomment=CustomSignet
[signet]
daemon=1
listen=1
server=1
discover=1
signetchallenge=5121028b8d4cea1b3d8582babc8405bc618fbbb281c0f64e6561aa85968251931cd0a651ae
rpcbind=0.0.0.0:38332
rpcallowip=0.0.0.0/0
whitelist=0.0.0.0/0
fallbackfee=0.0002
addnode=anchorage.certificator.4nkweb.com:38333</div>
<button class="copy-button" onclick="copyConfig()">📋 Copier la configuration</button>
<div class="info-box">
<p><strong>Note importante :</strong></p>
<p>• Remplacez <code>rpcuser</code> et <code>rpcpassword</code> par vos propres identifiants RPC</p>
<p>• Le port P2P par défaut est <code>38333</code></p>
<p>• Le port RPC par défaut est <code>38332</code></p>
<p>• L'adresse <code>anchorage.certificator.4nkweb.com:38333</code> est le nœud principal du réseau</p>
</div>
</div>
</section>
<!-- Section Paiement -->
<section class="join-section">
<div class="payment-section">
<h2>💳 Accès au Wallet de Mining</h2>
<p>Pour recevoir le wallet de mining et les clés nécessaires pour miner sur le réseau, effectuez un paiement de :</p>
<div class="payment-amount">0,0065 BTC</div>
<p>à l'adresse suivante :</p>
<div class="payment-address" id="payment-address">bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y</div>
<button class="copy-button" onclick="copyAddress()">📋 Copier l'adresse</button>
<div class="qr-code-container">
<div id="qrcode"></div>
</div>
<div class="wallet-checkbox">
<input type="checkbox" id="wallet-request" onchange="updatePaymentMessage()">
<label for="wallet-request">Je souhaite recevoir le wallet de mining après le paiement</label>
</div>
<div class="info-box" id="payment-info">
<p><strong>Instructions :</strong></p>
<p>1. Effectuez le paiement de 0,0065 BTC à l'adresse ci-dessus</p>
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir le wallet de mining</p>
<p>3. Après confirmation du paiement, vous recevrez les informations nécessaires</p>
</div>
<div class="success-message" id="payment-success">
<p><strong>✅ Paiement reçu !</strong></p>
<p>Votre demande a été enregistrée. Vous recevrez le wallet de mining sous peu.</p>
</div>
</div>
</section>
<!-- Section Informations Supplémentaires -->
<section class="join-section">
<div class="wallet-section">
<h2> Informations Supplémentaires</h2>
<div class="info-box">
<p><strong>Qu'est-ce que le wallet de mining ?</strong></p>
<p>Le wallet de mining contient :</p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li>La clé privée (PRIVKEY) nécessaire pour miner des blocs</li>
<li>Les paramètres de configuration pour le mining (NBITS, BLOCKPRODUCTIONDELAY)</li>
<li>Les instructions complètes pour configurer votre nœud en mode mining</li>
</ul>
</div>
<div class="info-box">
<p><strong>Que se passe-t-il après le paiement ?</strong></p>
<p>Une fois le paiement confirmé (généralement après 1 confirmation), vous recevrez par email :</p>
<ul style="margin-left: 20px; margin-top: 10px;">
<li>Les fichiers de configuration complets</li>
<li>La clé privée du signet (si vous avez coché la case)</li>
<li>Les instructions détaillées pour démarrer votre nœud</li>
</ul>
</div>
<div class="info-box">
<p><strong>Besoin d'aide ?</strong></p>
<p>Pour toute question, consultez la documentation complète dans le dépôt GitHub ou contactez l'équipe.</p>
</div>
</div>
</section>
</main>
<footer>
<p>Bitcoin Ancrage Dashboard - Équipe 4NK</p>
</footer>
</div>
<script>
const PAYMENT_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y';
const PAYMENT_AMOUNT = 0.0065;
// Générer le QR code au chargement de la page
function initQRCode() {
// Attendre que le DOM soit prêt et que la bibliothèque soit chargée
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', generateQRCode);
} else {
// Si le DOM est déjà chargé, attendre un peu pour la bibliothèque
setTimeout(generateQRCode, 100);
}
}
function generateQRCode() {
const qrElement = document.getElementById('qrcode');
if (!qrElement) {
console.error('Élément QR code non trouvé');
return;
}
// Vérifier que la bibliothèque QRCode est disponible
if (typeof QRCode === 'undefined') {
console.error('Bibliothèque QRCode non disponible, réessai...');
setTimeout(generateQRCode, 500);
return;
}
const paymentURI = `bitcoin:${PAYMENT_ADDRESS}?amount=${PAYMENT_AMOUNT}`;
// Vider l'élément avant de générer le QR code
qrElement.innerHTML = '';
// Utiliser toCanvas avec l'élément directement (QRCode créera le canvas)
QRCode.toCanvas(qrElement, paymentURI, {
width: 300,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
}, (error) => {
if (error) {
console.error('Erreur lors de la génération du QR code:', error);
qrElement.innerHTML = '<p style="color: red; padding: 20px;">Erreur lors de la génération du QR code. Veuillez recharger la page.</p>';
} else {
console.log('QR code généré avec succès');
}
});
}
// Initialiser le QR code
initQRCode();
function copyConfig() {
const configText = document.getElementById('bitcoin-config')?.textContent || '';
navigator.clipboard.writeText(configText).then(() => {
const button = event?.target;
if (button) {
const originalText = button.textContent;
button.textContent = '✅ Copié !';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
}).catch(err => {
console.error('Erreur lors de la copie:', err);
alert('Erreur lors de la copie. Veuillez sélectionner et copier manuellement.');
});
}
function copyAddress() {
navigator.clipboard.writeText(PAYMENT_ADDRESS).then(() => {
const button = event?.target;
if (button) {
const originalText = button.textContent;
button.textContent = '✅ Copié !';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
}).catch(err => {
console.error('Erreur lors de la copie:', err);
alert('Erreur lors de la copie. Veuillez sélectionner et copier manuellement.');
});
}
function updatePaymentMessage() {
const checkbox = document.getElementById('wallet-request');
const paymentInfo = document.getElementById('payment-info');
if (checkbox && paymentInfo) {
if (checkbox.checked) {
paymentInfo.innerHTML = `
<p><strong>Instructions :</strong></p>
<p>1. Effectuez le paiement de 0,0065 BTC à l'adresse ci-dessus</p>
<p>2. ✅ Vous recevrez le wallet de mining après confirmation du paiement</p>
<p>3. Les informations vous seront envoyées par email</p>
`;
} else {
paymentInfo.innerHTML = `
<p><strong>Instructions :</strong></p>
<p>1. Effectuez le paiement de 0,0065 BTC à l'adresse ci-dessus</p>
<p>2. Cochez la case ci-dessus si vous souhaitez recevoir le wallet de mining</p>
<p>3. Après confirmation du paiement, vous recevrez les informations nécessaires</p>
`;
}
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,539 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Liste des UTXO - Bitcoin Ancrage Dashboard</title>
<link rel="stylesheet" href="styles.css">
<style>
.utxo-list-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header-section {
margin-bottom: 30px;
}
.header-section h1 {
margin-bottom: 10px;
}
.header-section .back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
}
.header-section .back-link:hover {
text-decoration: underline;
}
.info-section {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.info-section p {
margin: 5px 0;
}
.category-section {
margin-bottom: 40px;
}
.category-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 5px 5px 0 0;
margin-bottom: 0;
}
.category-header.bloc-rewards {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.category-header.ancrages {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.category-header.changes {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.category-header.fees {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.category-header h2 {
margin: 0;
font-size: 1.5em;
}
.category-stats {
background: rgba(255, 255, 255, 0.2);
padding: 10px;
margin-top: 10px;
border-radius: 3px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.category-stats span {
margin-right: 20px;
}
.table-container {
overflow-x: auto;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f8f9fa;
color: #333;
font-weight: bold;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f5f5;
}
.txid-cell {
font-family: monospace;
font-size: 0.9em;
}
.txid-link {
color: #007bff;
text-decoration: none;
}
.txid-link:hover {
text-decoration: underline;
}
.address-cell {
font-family: monospace;
font-size: 0.9em;
word-break: break-all;
}
.amount-cell {
text-align: right;
font-weight: bold;
}
.loading {
text-align: center;
padding: 40px;
font-size: 1.2em;
color: #666;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.refresh-button {
background: #28a745;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
margin-top: 10px;
}
.refresh-button:hover {
background: #218838;
}
.refresh-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.total-amount {
font-size: 1.2em;
font-weight: bold;
color: #28a745;
}
.empty-message {
padding: 20px;
text-align: center;
color: #666;
background: #f8f9fa;
border-radius: 0 0 5px 5px;
}
.status-spent {
color: #dc3545;
font-weight: bold;
}
.status-locked {
color: #ffc107;
font-weight: bold;
}
.status-available {
color: #28a745;
font-weight: bold;
}
.navigation-bar {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.navigation-bar a {
padding: 10px 20px;
background: white;
color: #007bff;
text-decoration: none;
border-radius: 5px;
border: 2px solid #007bff;
transition: all 0.3s;
font-weight: bold;
}
.navigation-bar a:hover {
background: #007bff;
color: white;
}
.category-section {
scroll-margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="utxo-list-container">
<div class="header-section">
<a href="/" class="back-link">← Retour au dashboard</a>
<h1>Liste des UTXO</h1>
<div class="info-section">
<p><strong>Total d'UTXO :</strong> <span id="utxo-count">-</span></p>
<p><strong>Capacité d'ancrage restante :</strong> <span id="available-for-anchor">-</span> ancrages</p>
<p><strong>Montant total :</strong> <span id="total-amount" class="total-amount">-</span></p>
<p><strong>Dernière mise à jour :</strong> <span id="last-update">-</span></p>
<button class="refresh-button" onclick="loadUtxoList()">Actualiser</button>
<a href="/api/utxo/list.txt" download="utxo_list.txt" style="margin-left: 10px; color: #007bff; text-decoration: none;">📥 Télécharger le fichier texte</a>
</div>
</div>
<div id="navigation" class="navigation-bar" style="display: none;">
<a href="#bloc-rewards">💰 Bloc Rewards</a>
<a href="#ancrages">🔗 Ancrages</a>
<a href="#changes">🔄 Changes</a>
<a href="#fees">💸 Frais</a>
</div>
<div id="content">
<div class="loading">Chargement des UTXO...</div>
</div>
</div>
</div>
<script>
const API_BASE_URL = window.location.origin;
// Charger la liste au chargement de la page
document.addEventListener('DOMContentLoaded', () => {
loadUtxoList();
});
function renderTable(utxos, categoryName, categoryLabel) {
if (utxos.length === 0) {
return `
<div class="category-section" id="${categoryName}">
<div class="category-header ${categoryName}">
<h2>${categoryLabel}</h2>
<div class="category-stats">
<span><strong>Nombre :</strong> 0</span>
<span><strong>Montant total :</strong> 0 🛡</span>
</div>
</div>
<div class="empty-message">Aucun UTXO dans cette catégorie.</div>
</div>
`;
}
const totalAmount = utxos.reduce((sum, utxo) => sum + utxo.amount, 0);
const totalSats = Math.round(totalAmount * 100000000);
const isAnchors = categoryName === 'ancrages';
const isBlocRewards = categoryName === 'bloc-rewards';
const isChanges = categoryName === 'changes';
// Pour les changes, compter les changes d'ancrage
let anchorChangesCount = 0;
let anchorChangesAmount = 0;
if (isChanges) {
const anchorChanges = utxos.filter(utxo => utxo.isAnchorChange);
anchorChangesCount = anchorChanges.length;
anchorChangesAmount = anchorChanges.reduce((sum, utxo) => sum + utxo.amount, 0);
}
let tableHTML = `
<div class="category-section" id="${categoryName}">
<div class="category-header ${categoryName}">
<h2>${categoryLabel}</h2>
<div class="category-stats">
<span><strong>Nombre :</strong> ${utxos.length.toLocaleString('fr-FR')}</span>
<span><strong>Montant total :</strong> ${formatBTC(totalAmount)} (${totalSats.toLocaleString('fr-FR')} ✅)</span>
${isChanges && anchorChangesCount > 0 ? `<span><strong>Changes d'ancrage :</strong> ${anchorChangesCount} (${formatBTC(anchorChangesAmount)})</span>` : ''}
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Transaction ID</th>
<th>Vout</th>
<th>Adresse</th>
`;
if (isAnchors) {
tableHTML += `
<th>Numéro de bloc</th>
<th style="text-align: right;">Montant (✅)</th>
<th>Confirmations</th>
<th>Statut</th>
`;
} else if (isBlocRewards) {
tableHTML += `
<th style="text-align: right;">Montant (🛡)</th>
<th>Date</th>
<th>Confirmations</th>
<th>Statut</th>
`;
} else {
// Pour la section changes, ajouter une colonne pour indiquer si c'est un change d'ancrage
if (categoryName === 'changes') {
tableHTML += `
<th style="text-align: right;">Montant (🛡)</th>
<th style="text-align: right;">Montant (✅)</th>
<th>Confirmations</th>
<th>Type</th>
<th>Statut</th>
`;
} else {
tableHTML += `
<th style="text-align: right;">Montant (🛡)</th>
<th style="text-align: right;">Montant (✅)</th>
<th>Confirmations</th>
<th>Statut</th>
`;
}
}
tableHTML += `
</tr>
</thead>
<tbody>
`;
utxos.forEach((utxo) => {
const mempoolUrl = 'https://mempool.4nkweb.com/fr';
const txLink = `${mempoolUrl}/tx/${utxo.txid}`;
const amountSats = Math.round(utxo.amount * 100000000);
const amountBTC = Math.round(utxo.amount);
tableHTML += '<tr>';
tableHTML += `<td class="txid-cell"><a href="${txLink}" target="_blank" rel="noopener noreferrer" class="txid-link">${utxo.txid}</a></td>`;
tableHTML += `<td>${utxo.vout}</td>`;
tableHTML += `<td class="address-cell">${utxo.address || '-'}</td>`;
if (isAnchors) {
tableHTML += `<td>${utxo.blockHeight !== null && utxo.blockHeight !== undefined ? utxo.blockHeight.toLocaleString('fr-FR') : '-'}</td>`;
tableHTML += `<td class="amount-cell">${amountSats.toLocaleString('fr-FR')} ✅</td>`;
} else if (isBlocRewards) {
tableHTML += `<td class="amount-cell">${amountBTC.toLocaleString('fr-FR')} 🛡</td>`;
if (utxo.blockTime) {
const date = new Date(utxo.blockTime * 1000);
tableHTML += `<td>${date.toLocaleString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>`;
} else {
tableHTML += '<td>-</td>';
}
} else {
tableHTML += `<td class="amount-cell">${amountBTC.toLocaleString('fr-FR')} 🛡</td>`;
tableHTML += `<td class="amount-cell">${amountSats.toLocaleString('fr-FR')} ✅</td>`;
}
tableHTML += `<td>${utxo.confirmations.toLocaleString('fr-FR')}</td>`;
// Colonne Type (uniquement pour les changes)
if (categoryName === 'changes') {
const changeType = utxo.isAnchorChange ? '🔗 Transaction d\'ancrage' : '🔄 Transaction normale';
tableHTML += `<td>${changeType}</td>`;
}
// Colonne Statut
let statusText = '';
let statusClass = '';
if (utxo.isSpentOnchain) {
statusText = 'Dépensé onchain';
statusClass = 'status-spent';
} else if (utxo.isLockedInMutex) {
statusText = 'Verrouillé';
statusClass = 'status-locked';
} else {
statusText = 'Disponible';
statusClass = 'status-available';
}
tableHTML += `<td class="${statusClass}">${statusText}</td>`;
tableHTML += '</tr>';
});
tableHTML += `
</tbody>
</table>
</div>
</div>
`;
return tableHTML;
}
async function loadUtxoList() {
const contentDiv = document.getElementById('content');
const refreshButton = document.querySelector('.refresh-button');
refreshButton.disabled = true;
contentDiv.innerHTML = '<div class="loading">Chargement des UTXO...</div>';
try {
const response = await fetch(`${API_BASE_URL}/api/utxo/list`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const counts = data.counts || {};
const blocRewards = data.blocRewards || [];
const anchors = data.anchors || [];
const changes = data.changes || [];
const fees = data.fees || [];
document.getElementById('utxo-count').textContent = counts.total.toLocaleString('fr-FR');
document.getElementById('available-for-anchor').textContent = (counts.availableForAnchor || 0).toLocaleString('fr-FR');
// Calculer le montant total
const totalAmount = blocRewards.reduce((sum, utxo) => sum + utxo.amount, 0) +
anchors.reduce((sum, utxo) => sum + utxo.amount, 0) +
changes.reduce((sum, utxo) => sum + utxo.amount, 0);
document.getElementById('total-amount').textContent = formatBTC(totalAmount);
updateLastUpdateTime();
// Afficher la barre de navigation
document.getElementById('navigation').style.display = 'flex';
// Afficher les 4 listes
let html = '';
html += renderTable(blocRewards, 'bloc-rewards', '💰 Bloc Rewards (Récompenses de minage)');
html += renderTable(anchors, 'ancrages', '🔗 Ancrages (Transactions d\'ancrage)');
html += renderTable(changes, 'changes', '🔄 Changes (Monnaie de retour)');
html += renderFeesTable(fees, 'fees', '💸 Frais (Transactions d\'ancrage)');
contentDiv.innerHTML = html;
} catch (error) {
console.error('Error loading UTXO list:', error);
contentDiv.innerHTML = `<div class="error">Erreur lors du chargement de la liste des UTXO : ${error.message}</div>`;
} finally {
refreshButton.disabled = false;
}
}
function formatBTC(btc) {
if (btc === 0) return '0 🛡';
// Arrondir sans décimales
const roundedBTC = Math.round(btc);
if (roundedBTC === 0 && btc > 0) {
// Si arrondi à 0 mais qu'il y a un montant, afficher en sats
const sats = Math.round(btc * 100000000);
return `${sats.toLocaleString('fr-FR')} ✅`;
}
return `${roundedBTC.toLocaleString('fr-FR')} 🛡`;
}
function renderFeesTable(fees, categoryName, categoryLabel) {
if (fees.length === 0) {
return `
<div class="category-section" id="${categoryName}">
<div class="category-header ${categoryName}">
<h2>${categoryLabel}</h2>
<div class="category-stats">
<span><strong>Nombre :</strong> 0</span>
<span><strong>Total des frais :</strong> 0 ✅</span>
</div>
</div>
<div class="empty-message">Aucune transaction avec frais onchain enregistrée.</div>
</div>
`;
}
const totalFees = fees.reduce((sum, fee) => sum + fee.fee, 0);
const totalFeesSats = Math.round(totalFees * 100000000);
let tableHTML = `
<div class="category-section" id="${categoryName}">
<div class="category-header ${categoryName}">
<h2>${categoryLabel}</h2>
<div class="category-stats">
<span><strong>Nombre :</strong> ${fees.length.toLocaleString('fr-FR')}</span>
<span><strong>Total des frais :</strong> ${formatBTC(totalFees)} (${totalFeesSats.toLocaleString('fr-FR')} ✅)</span>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Transaction ID</th>
<th>Frais (BTC)</th>
<th>Frais (✅)</th>
<th>Change Address</th>
<th>Change Amount</th>
<th>Bloc</th>
<th>Date</th>
<th>Confirmations</th>
</tr>
</thead>
<tbody>
`;
fees.forEach((fee) => {
const mempoolUrl = 'https://mempool.4nkweb.com/fr';
const txLink = `${mempoolUrl}/tx/${fee.txid}`;
const feeSats = fee.fee_sats || Math.round(fee.fee * 100000000);
tableHTML += '<tr>';
tableHTML += `<td class="txid-cell"><a href="${txLink}" target="_blank" rel="noopener noreferrer" class="txid-link">${fee.txid}</a></td>`;
tableHTML += `<td class="amount-cell">${fee.fee.toFixed(8)}</td>`;
tableHTML += `<td class="amount-cell">${feeSats.toLocaleString('fr-FR')} ✅</td>`;
tableHTML += `<td class="address-cell">${fee.changeAddress || '-'}</td>`;
tableHTML += `<td class="amount-cell">${fee.changeAmount ? formatBTC(fee.changeAmount) : '-'}</td>`;
tableHTML += `<td>${fee.blockHeight !== null && fee.blockHeight !== undefined ? fee.blockHeight.toLocaleString('fr-FR') : '-'}</td>`;
if (fee.blockTime) {
const date = new Date(fee.blockTime * 1000);
tableHTML += `<td>${date.toLocaleString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>`;
} else {
tableHTML += '<td>-</td>';
}
tableHTML += `<td>${fee.confirmations.toLocaleString('fr-FR')}</td>`;
tableHTML += '</tr>';
});
tableHTML += `
</tbody>
</table>
</div>
</div>
`;
return tableHTML;
}
function updateLastUpdateTime() {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleString('fr-FR');
}
</script>
</body>
</html>

View File

@ -9,6 +9,7 @@ import { logger } from './logger.js';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Buffer } from 'buffer';
class BitcoinRPC {
constructor() {
@ -143,6 +144,594 @@ class BitcoinRPC {
}
}
/**
* Obtient la liste des hash ancrés avec leurs transactions
* Utilise un fichier de cache hash_list.txt pour éviter de tout recompter
* Format du cache: <date>;<hauteur du dernier bloc>;<hash du dernier bloc>
* Format du fichier de sortie: <hash>;<txid>;<block_height>;<confirmations>
* @returns {Promise<Array<Object>>} Liste des hash avec leurs transactions
*/
async getHashList() {
try {
const blockchainInfo = await this.client.getBlockchainInfo();
const currentHeight = blockchainInfo.blocks;
const currentBlockHash = blockchainInfo.bestblockhash;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const cachePath = join(__dirname, '../../hash_list_cache.txt');
const outputPath = join(__dirname, '../../hash_list.txt');
let startHeight = 0;
const hashList = [];
// Lire le cache si il existe
if (existsSync(cachePath)) {
try {
const cacheContent = readFileSync(cachePath, 'utf8').trim();
const parts = cacheContent.split(';');
if (parts.length === 3) {
const cachedHeight = parseInt(parts[1], 10);
const cachedHash = parts[2];
if (cachedHeight >= 0 && cachedHeight <= currentHeight) {
try {
const cachedBlockHash = await this.client.getBlockHash(cachedHeight);
if (cachedBlockHash === cachedHash) {
startHeight = cachedHeight + 1;
// Charger les hash existants depuis le fichier de sortie
if (existsSync(outputPath)) {
const existingContent = readFileSync(outputPath, 'utf8').trim();
const lines = existingContent.split('\n');
for (const line of lines) {
if (line.trim()) {
const [hash, txid, blockHeight, confirmations] = line.split(';');
if (hash && txid) {
hashList.push({
hash,
txid,
blockHeight: blockHeight ? parseInt(blockHeight, 10) : null,
confirmations: confirmations ? parseInt(confirmations, 10) : 0,
});
}
}
}
}
logger.info('Hash list cache loaded', {
cachedHeight,
cachedCount: hashList.length,
startHeight,
currentHeight,
});
} else {
logger.warn('Hash list cache invalid: block hash mismatch', {
cachedHeight,
cachedHash,
actualHash: cachedBlockHash,
});
}
} catch (error) {
logger.warn('Error verifying cached block hash', { error: error.message });
}
}
}
} catch (error) {
logger.warn('Error reading hash list cache', { error: error.message });
}
}
// Parcourir les blocs depuis startHeight jusqu'à currentHeight
if (startHeight <= currentHeight) {
logger.info('Collecting hash list 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)) {
// Extraire le hash depuis le script
const hashStart = scriptHex.indexOf(anchorPrefix) + anchorPrefix.length;
const hashHex = scriptHex.substring(hashStart, hashStart + 64);
if (/^[0-9a-fA-F]{64}$/.test(hashHex)) {
const confirmations = currentHeight - height + 1;
hashList.push({
hash: hashHex.toLowerCase(),
txid: tx.txid,
blockHeight: height,
confirmations,
});
}
break; // Un seul hash par transaction
}
}
}
} catch (error) {
logger.debug('Error checking transaction for hash', { txid: tx.txid, error: error.message });
}
}
}
// Mettre à jour le cache tous les 100 blocs
if (height % 100 === 0 || height === currentHeight) {
const now = new Date().toISOString();
const cacheContent = `${now};${height};${blockHash}`;
writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie
const outputLines = hashList.map((item) =>
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0}`
);
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
logger.debug('Hash list cache updated', { height, count: hashList.length });
}
} catch (error) {
logger.debug('Error checking block for hashes', { height, error: error.message });
}
}
// Mettre à jour le cache final
const now = new Date().toISOString();
const cacheContent = `${now};${currentHeight};${currentBlockHash}`;
writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie final
const outputLines = hashList.map((item) =>
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0}`
);
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
logger.info('Hash list saved', { currentHeight, count: hashList.length });
}
return hashList;
} catch (error) {
logger.error('Error getting hash list', { error: error.message });
throw new Error(`Failed to get hash list: ${error.message}`);
}
}
/**
* Obtient la liste des UTXO avec leurs montants, catégorisés en 3 types :
* - bloc_rewards : UTXO provenant de transactions coinbase (minage)
* - ancrages : UTXO provenant de transactions d'ancrage
* - changes : UTXO provenant d'autres transactions (monnaie de retour)
* Utilise un fichier de cache utxo_list_cache.txt pour éviter de tout recompter
* Format du cache: <date>
* Format du fichier de sortie: <category>;<txid>;<vout>;<address>;<amount>;<confirmations>
* @returns {Promise<Object>} Objet avec 3 listes : blocRewards, anchors, changes
*/
async getUtxoList() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const cachePath = join(__dirname, '../../utxo_list_cache.txt');
const outputPath = join(__dirname, '../../utxo_list.txt');
// Obtenir les UTXO depuis le wallet
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');
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: [0], // Inclure les non confirmés
}),
});
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 || [];
const blocRewards = [];
const anchors = [];
const changes = [];
const fees = []; // Liste des transactions avec leurs frais onchain
logger.info('Categorizing UTXOs', { total: unspent.length });
// Récupérer les UTXO 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}`);
}
logger.debug('Locked UTXOs retrieved', { count: lockedUtxos.size });
}
} catch (error) {
logger.debug('Error getting locked UTXOs from anchor API', { error: error.message });
}
// Catégoriser chaque UTXO
for (const utxo of unspent) {
try {
// Obtenir la transaction source pour déterminer sa catégorie
const rawTxResponse = await fetch(rpcUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${auth}`,
},
body: JSON.stringify({
jsonrpc: '1.0',
id: 'getrawtransaction',
method: 'getrawtransaction',
params: [utxo.txid, true],
}),
});
if (!rawTxResponse.ok) {
throw new Error(`HTTP error fetching transaction: ${rawTxResponse.status}`);
}
const rawTxResult = await rawTxResponse.json();
if (rawTxResult.error) {
throw new Error(`RPC error: ${rawTxResult.error.message}`);
}
const rawTx = rawTxResult.result;
// Vérifier si l'UTXO est dépensé onchain
let isSpentOnchain = false;
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é
isSpentOnchain = txOutResult.result === null;
}
} catch (error) {
logger.debug('Error checking if UTXO is spent', { txid: utxo.txid, vout: utxo.vout, error: error.message });
}
// Vérifier si l'UTXO est verrouillé dans le mutex de l'API d'ancrage
const utxoKey = `${utxo.txid}:${utxo.vout}`;
const isLockedInMutex = lockedUtxos.has(utxoKey);
const utxoItem = {
txid: utxo.txid,
vout: utxo.vout,
address: utxo.address || '',
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
isSpentOnchain,
isLockedInMutex,
};
// Vérifier si c'est une transaction coinbase (récompense de minage)
// Une transaction coinbase doit avoir exactement un input avec le champ coinbase défini
const isCoinbase = rawTx.vin && rawTx.vin.length === 1 &&
rawTx.vin[0].coinbase !== undefined &&
rawTx.vin[0].coinbase !== null;
if (isCoinbase) {
utxoItem.category = 'bloc_rewards';
blocRewards.push(utxoItem);
continue;
}
// Vérifier si c'est une transaction d'ancrage (contient OP_RETURN avec "ANCHOR:")
let isAnchorTx = false;
let blockHeight = null;
// Obtenir la hauteur du bloc et le blocktime si la transaction est confirmée
let blockTime = null;
if (utxo.confirmations > 0) {
try {
const txInfo = await this.client.getTransaction(utxo.txid);
blockHeight = txInfo.blockheight || null;
blockTime = txInfo.blocktime || null;
} catch (error) {
logger.debug('Error getting transaction block info', { txid: utxo.txid, error: error.message });
}
}
utxoItem.blockHeight = blockHeight;
utxoItem.blockTime = blockTime;
// Extraire les métadonnées onchain (change et frais) depuis l'OP_RETURN
let onchainChangeAddress = null;
let onchainChangeAmount = null;
let onchainFeeAmount = null;
if (rawTx.vout) {
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)) {
isAnchorTx = true;
// Extraire les métadonnées depuis l'OP_RETURN
// Format: "ANCHOR:" + hash (32 bytes) + "|CHANGE:<address>:<amount_sats>|FEE:<amount_sats>"
try {
// Le script hex contient les données encodées
// "ANCHOR:" en hex = "414e43484f523a"
// Le hash suit (64 caractères hex = 32 bytes)
// Puis "|" en hex = "7c"
// Puis les métadonnées en UTF-8
const anchorPrefixHex = Buffer.from('ANCHOR:', 'utf8').toString('hex');
const hashLengthHex = 64; // 64 caractères hex pour 32 bytes
const separatorHex = Buffer.from('|', 'utf8').toString('hex');
// Trouver la position de "ANCHOR:" dans le script hex
const anchorPos = scriptHex.indexOf(anchorPrefixHex);
if (anchorPos !== -1) {
// Position après "ANCHOR:" + hash (64 caractères hex)
const afterHashPos = anchorPos + anchorPrefixHex.length + hashLengthHex;
// Chercher le séparateur "|" après le hash
const separatorPos = scriptHex.indexOf(separatorHex, afterHashPos);
if (separatorPos !== -1) {
// Extraire les métadonnées après le séparateur
const metadataHex = scriptHex.substring(separatorPos + separatorHex.length);
// Convertir de hex à UTF-8
const metadataBuffer = Buffer.from(metadataHex, 'hex');
const metadataString = metadataBuffer.toString('utf8');
// Parser les métadonnées: "CHANGE:<address>:<amount_sats>|FEE:<amount_sats>"
const parts = metadataString.split('|');
for (const part of parts) {
if (part.startsWith('CHANGE:')) {
const changeData = part.substring(7); // Enlever "CHANGE:"
const changeParts = changeData.split(':');
if (changeParts.length === 2 && changeParts[0] !== 'none') {
onchainChangeAddress = changeParts[0];
onchainChangeAmount = parseInt(changeParts[1], 10) / 100000000; // Convertir sats en BTC
}
} else if (part.startsWith('FEE:')) {
const feeData = part.substring(4); // Enlever "FEE:"
onchainFeeAmount = parseInt(feeData, 10) / 100000000; // Convertir sats en BTC
}
}
}
}
} catch (error) {
logger.debug('Error parsing OP_RETURN metadata', { txid: utxo.txid, error: error.message });
}
break;
}
}
}
}
// Si c'est une transaction d'ancrage avec des frais onchain, les stocker
if (isAnchorTx && onchainFeeAmount !== null) {
// Vérifier si cette transaction n'est pas déjà dans la liste des frais
const existingFee = fees.find(f => f.txid === utxo.txid);
if (!existingFee) {
fees.push({
txid: utxo.txid,
fee: onchainFeeAmount,
fee_sats: Math.round(onchainFeeAmount * 100000000),
changeAddress: onchainChangeAddress || null,
changeAmount: onchainChangeAmount || null,
blockHeight: blockHeight,
blockTime: blockTime,
confirmations: utxo.confirmations || 0,
});
}
}
if (isAnchorTx) {
// Dans une transaction d'ancrage, distinguer les outputs d'ancrage/provisionnement du change
// Les transactions d'ancrage créent :
// - 1 output OP_RETURN (non dépensable, généralement le premier output)
// - 1 output d'ancrage de 2500 sats (0.000025 BTC)
// - 7 outputs de provisionnement de 2500 sats chacun
// - 1 output de change (seulement si change > 0.00001 BTC = 1000 sats)
const utxoAmountSats = Math.round(utxo.amount * 100000000);
const anchorAmountSats = 2500; // Montant standard des outputs d'ancrage/provisionnement
const minChangeSats = 1000; // Change minimum créé (0.00001 BTC)
// Identifier précisément le type d'output en analysant la transaction
let isAnchorOutput = false;
let isChangeOutput = false;
if (rawTx.vout && rawTx.vout[utxo.vout]) {
const output = rawTx.vout[utxo.vout];
// Vérifier si c'est un output OP_RETURN (non dépensable)
if (output.scriptPubKey && output.scriptPubKey.type === 'nulldata') {
// C'est l'OP_RETURN, on l'ignore (non dépensable)
continue; // Ne pas ajouter cet UTXO car OP_RETURN n'est pas dépensable
}
// Si le montant correspond à un output d'ancrage/provisionnement (2500 sats)
if (Math.abs(utxoAmountSats - anchorAmountSats) <= 1) {
isAnchorOutput = true;
} else if (utxoAmountSats >= minChangeSats) {
// Le change dans une transaction d'ancrage est > 1000 sats et différent de 2500 sats
isChangeOutput = true;
}
}
if (isAnchorOutput) {
utxoItem.category = 'ancrages';
anchors.push(utxoItem);
} else if (isChangeOutput) {
// C'est le change de la transaction d'ancrage
utxoItem.category = 'changes';
utxoItem.isAnchorChange = true; // Marquer comme change d'une transaction d'ancrage
// Ajouter les métadonnées onchain si disponibles
if (onchainChangeAddress && onchainChangeAmount !== null) {
utxoItem.onchainChangeAddress = onchainChangeAddress;
utxoItem.onchainChangeAmount = onchainChangeAmount;
}
if (onchainFeeAmount !== null) {
utxoItem.onchainFeeAmount = onchainFeeAmount;
}
changes.push(utxoItem);
} else {
// Montant très petit (< 1000 sats), probablement du dust
// Classer comme change quand même (peu probable dans une transaction d'ancrage)
utxoItem.category = 'changes';
utxoItem.isAnchorChange = true;
changes.push(utxoItem);
}
} else {
// Transaction normale (non-ancrage, non-coinbase) = change
// Cela inclut toutes les transactions qui ne sont pas des coinbase et qui ne contiennent pas d'OP_RETURN avec "ANCHOR:"
// Ces UTXO proviennent de transactions normales (paiements, etc.)
utxoItem.category = 'changes';
utxoItem.isAnchorChange = false; // Change d'une transaction normale
changes.push(utxoItem);
}
} catch (error) {
// En cas d'erreur, classer comme change par défaut
logger.debug('Error categorizing UTXO', { txid: utxo.txid, error: error.message });
changes.push({
txid: utxo.txid,
vout: utxo.vout,
address: utxo.address || '',
amount: utxo.amount,
confirmations: utxo.confirmations || 0,
category: 'changes',
});
}
}
// Trier chaque catégorie par montant décroissant
blocRewards.sort((a, b) => b.amount - a.amount);
anchors.sort((a, b) => b.amount - a.amount);
// Pour les changes, trier d'abord par type (ancrage en premier), puis par montant décroissant
changes.sort((a, b) => {
// Les changes d'ancrage en premier
if (a.isAnchorChange && !b.isAnchorChange) return -1;
if (!a.isAnchorChange && b.isAnchorChange) return 1;
// Puis par montant décroissant
return b.amount - a.amount;
});
// Calculer le nombre d'UTXO disponibles pour l'ancrage (> 2000 sats 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
).length;
// Mettre à jour le cache
const now = new Date().toISOString();
writeFileSync(cachePath, now, 'utf8');
// Écrire le fichier de sortie avec toutes les catégories
const outputLines = allUtxos.map((item) =>
`${item.category};${item.txid};${item.vout};${item.address};${item.amount};${item.confirmations}`
);
writeFileSync(outputPath, outputLines.join('\n'), 'utf8');
// Analyser la distribution pour comprendre pourquoi il y a si peu de changes
const anchorTxChanges = changes.filter(utxo => {
// Vérifier si cet UTXO provient d'une transaction d'ancrage
// (on ne peut pas le vérifier directement ici, mais on peut analyser les montants)
const utxoAmountSats = Math.round(utxo.amount * 100000000);
return utxoAmountSats > 2500; // Change d'une transaction d'ancrage serait > 2500 sats
});
const normalTxChanges = changes.length - anchorTxChanges.length;
// Trier les frais par blockHeight décroissant (plus récent en premier)
fees.sort((a, b) => {
if (a.blockHeight === null && b.blockHeight === null) return 0;
if (a.blockHeight === null) return 1;
if (b.blockHeight === null) return -1;
return b.blockHeight - a.blockHeight;
});
logger.info('UTXO list saved', {
blocRewards: blocRewards.length,
anchors: anchors.length,
changes: changes.length,
changesFromAnchorTx: anchorTxChanges.length,
changesFromNormalTx: normalTxChanges,
fees: fees.length,
total: allUtxos.length,
availableForAnchor,
});
return {
blocRewards,
anchors,
changes,
fees,
total: allUtxos.length,
availableForAnchor,
};
} catch (error) {
logger.error('Error getting UTXO list', { error: error.message });
throw new Error(`Failed to get UTXO list: ${error.message}`);
}
}
/**
* 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

View File

@ -130,9 +130,6 @@ app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Servir les fichiers statiques
app.use(express.static(join(__dirname, '../public')));
// Middleware de logging
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
@ -142,11 +139,35 @@ app.use((req, res, next) => {
next();
});
// Routes spécifiques AVANT le middleware static pour éviter les conflits
// Route pour la page principale
app.get('/', (req, res) => {
res.sendFile(join(__dirname, '../public/index.html'));
});
// Route pour la page de liste des hash
app.get('/hash-list', (req, res) => {
res.sendFile(join(__dirname, '../public/hash-list.html'));
});
// Route pour la page de liste des UTXO
app.get('/utxo-list', (req, res) => {
res.sendFile(join(__dirname, '../public/utxo-list.html'));
});
// Route pour la page de rejoindre le réseau Signet
app.get('/join-signet', (req, res) => {
res.sendFile(join(__dirname, '../public/join-signet.html'));
});
// Route pour la page de documentation de l'API
app.get('/api-docs', (req, res) => {
res.sendFile(join(__dirname, '../public/api-docs.html'));
});
// Servir les fichiers statiques (après les routes spécifiques)
app.use(express.static(join(__dirname, '../public')));
// API Routes
app.get('/api/blockchain/info', async (req, res) => {
try {
@ -198,6 +219,79 @@ app.get('/api/anchor/count', async (req, res) => {
}
});
// Route pour obtenir la liste des hash (fichier texte)
app.get('/api/hash/list', async (req, res) => {
try {
const hashList = await bitcoinRPC.getHashList();
res.json({ hashes: hashList, count: hashList.length });
} catch (error) {
logger.error('Error getting hash list', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Route pour servir le fichier texte des hash
app.get('/api/hash/list.txt', async (req, res) => {
try {
const { readFileSync, existsSync } = await import('fs');
const hashListPath = join(__dirname, '../../hash_list.txt');
if (existsSync(hashListPath)) {
const content = readFileSync(hashListPath, 'utf8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(content);
} else {
res.status(404).send('Hash list file not found');
}
} catch (error) {
logger.error('Error serving hash list file', { error: error.message });
res.status(500).send('Error reading hash list file');
}
});
// Route pour obtenir la liste des UTXO (fichier texte)
app.get('/api/utxo/list', async (req, res) => {
try {
const utxoData = await bitcoinRPC.getUtxoList();
res.json({
blocRewards: utxoData.blocRewards,
anchors: utxoData.anchors,
changes: utxoData.changes,
fees: utxoData.fees || [],
counts: {
blocRewards: utxoData.blocRewards.length,
anchors: utxoData.anchors.length,
changes: utxoData.changes.length,
fees: (utxoData.fees || []).length,
total: utxoData.total,
availableForAnchor: utxoData.availableForAnchor || 0,
},
});
} catch (error) {
logger.error('Error getting UTXO list', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Route pour servir le fichier texte des UTXO
app.get('/api/utxo/list.txt', async (req, res) => {
try {
const { readFileSync, existsSync } = await import('fs');
const utxoListPath = join(__dirname, '../../utxo_list.txt');
if (existsSync(utxoListPath)) {
const content = readFileSync(utxoListPath, 'utf8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(content);
} else {
res.status(404).send('UTXO list file not found');
}
} catch (error) {
logger.error('Error serving UTXO list file', { error: error.message });
res.status(500).send('Error reading UTXO list file');
}
});
app.get('/api/mining/difficulty', async (req, res) => {
try {
const blockchainInfo = await bitcoinRPC.getBlockchainInfo();
@ -460,13 +554,36 @@ app.use((req, res) => {
});
});
// Initialisation des fichiers au démarrage
async function initializeFiles() {
try {
logger.info('Initializing hash and UTXO lists...');
await Promise.all([
bitcoinRPC.getHashList().catch((error) => {
logger.warn('Error initializing hash list', { error: error.message });
}),
bitcoinRPC.getUtxoList().catch((error) => {
logger.warn('Error initializing UTXO list', { error: error.message });
}),
]);
logger.info('Hash and UTXO lists initialized');
} catch (error) {
logger.error('Error initializing files', { error: error.message });
}
}
// Démarrage du serveur
const server = app.listen(PORT, HOST, () => {
const server = app.listen(PORT, HOST, async () => {
logger.info(`Dashboard Bitcoin Signet démarré`, {
host: HOST,
port: PORT,
environment: process.env.NODE_ENV || 'production',
});
// Initialiser les fichiers en arrière-plan
initializeFiles().catch((error) => {
logger.error('Error during file initialization', { error: error.message });
});
});
// Gestion de l'arrêt propre

8564
utxo_list.txt Normal file

File diff suppressed because it is too large Load Diff

1
utxo_list_cache.txt Normal file
View File

@ -0,0 +1 @@
2026-01-25T12:52:49.162Z