Pagination serveur, correction UTXO déjà dépensé et synchronisation automatique
**Motivations:**
- Réduire la consommation mémoire en paginant côté serveur au lieu de charger toutes les données
- Corriger les erreurs "Input not found or already spent" dans l'API d'ancrage
- Maintenir la synchronisation entre la base de données et l'état réel de Bitcoin
- Améliorer l'expérience utilisateur avec un suivi de progression pour la collecte de signatures
**Root causes:**
- Pagination effectuée côté client : le serveur retournait tous les UTXOs/hashes (68k+ UTXOs, 32k+ hashes) puis le frontend paginait en JavaScript
- Désynchronisation entre la DB et Bitcoin : UTXOs dépensés non mis à jour dans la base de données
- Détection d'erreur incomplète : ne couvrait pas tous les cas ("already spent", "input not found")
- Pas de vérification de disponibilité de l'UTXO juste avant utilisation dans une transaction
**Correctifs:**
- Implémentation de la pagination côté serveur pour `/api/utxo/list` et `/api/hash/list` avec paramètres `page` et `limit`
- Amélioration de la détection d'erreur pour inclure "already spent" et "input not found"
- Ajout d'une vérification de disponibilité de l'UTXO avant utilisation avec mécanisme de retry (max 3 tentatives)
- Mise à jour automatique de tous les UTXOs dépensés dans la base de données lors de chaque synchronisation
- Script de synchronisation périodique avec cron job toutes les heures
- Optimisation mémoire : utilisation de tables temporaires SQL au lieu de charger tous les UTXOs en mémoire
**Evolutions:**
- Pagination serveur avec métadonnées (total, totalPages, page, limit) pour les endpoints `/api/utxo/list` et `/api/hash/list`
- Adaptation du frontend pour utiliser la pagination serveur (compatibilité maintenue avec chargement jusqu'à 1000 éléments)
- Ajout de `onProgress` callback dans `runCollectLoop` pour notifier la progression de la collecte de signatures
- Nouvelle fonction `collectProgress` pour calculer la progression (satisfied vs required) pour les notifications/UI
- Refactoring de `hasEnoughSignatures` avec extraction de `pairsPerMemberFromSigs` pour réutilisabilité
**Pages affectées:**
- `api-anchorage/src/bitcoin-rpc.js` : Vérification disponibilité UTXO, amélioration détection erreur, paramètre retryCount
- `api-anchorage/src/routes/anchor.js` : Passage des nouveaux paramètres à createAnchorTransaction
- `signet-dashboard/src/server.js` : Pagination pour `/api/hash/list` et `/api/utxo/list`
- `signet-dashboard/src/bitcoin-rpc.js` : Mise à jour automatique de tous les UTXOs dépensés avec optimisation mémoire
- `signet-dashboard/public/hash-list.html` : Adaptation pour charger avec pagination serveur
- `signet-dashboard/public/utxo-list.html` : Adaptation pour utiliser la pagination serveur par catégorie
- `userwallet/src/utils/collectSignatures.ts` : Ajout interface CollectLoopOpts avec onProgress callback
- `userwallet/src/utils/loginValidation.ts` : Ajout fonction collectProgress, refactoring avec pairsPerMemberFromSigs
- `data/sync-utxos-spent-status.mjs` : Script de synchronisation périodique des UTXOs dépensés
- `data/sync-utxos-cron.sh` : Script wrapper pour cron job
- `features/pagination-serveur-base-donnees.md` : Documentation de la pagination serveur
- `features/synchronisation-automatique-utxos-depenses.md` : Documentation de la synchronisation automatique
- `fixKnowledge/api-anchorage-utxo-already-spent-error.md` : Documentation de la correction de l'erreur UTXO déjà dépensé
This commit is contained in:
parent
0960e43a45
commit
1d4b0d8f33
@ -27,8 +27,8 @@ class BitcoinRPC {
|
|||||||
// Utilise une Promise-based queue pour sérialiser les accès
|
// Utilise une Promise-based queue pour sérialiser les accès
|
||||||
this.utxoMutexPromise = Promise.resolve();
|
this.utxoMutexPromise = Promise.resolve();
|
||||||
|
|
||||||
// Liste des UTXOs en cours d'utilisation (format: "txid:vout")
|
// Note: Les UTXOs verrouillés sont maintenant gérés uniquement dans la base de données
|
||||||
this.lockedUtxos = new Set();
|
// via is_locked_in_mutex pour éviter la duplication et réduire la consommation mémoire
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,8 +59,22 @@ class BitcoinRPC {
|
|||||||
* @returns {boolean} True si l'UTXO est verrouillé
|
* @returns {boolean} True si l'UTXO est verrouillé
|
||||||
*/
|
*/
|
||||||
isUtxoLocked(txid, vout) {
|
isUtxoLocked(txid, vout) {
|
||||||
const key = `${txid}:${vout}`;
|
try {
|
||||||
return this.lockedUtxos.has(key);
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
SELECT is_locked_in_mutex
|
||||||
|
FROM utxos
|
||||||
|
WHERE txid = ? AND vout = ?
|
||||||
|
`).get(txid, vout);
|
||||||
|
return result?.is_locked_in_mutex === 1;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error checking UTXO lock status in database', {
|
||||||
|
error: error.message,
|
||||||
|
txid: txid.substring(0, 16) + '...',
|
||||||
|
vout,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,15 +83,21 @@ class BitcoinRPC {
|
|||||||
* @param {number} vout - Index de l'output
|
* @param {number} vout - Index de l'output
|
||||||
*/
|
*/
|
||||||
lockUtxo(txid, vout) {
|
lockUtxo(txid, vout) {
|
||||||
const key = `${txid}:${vout}`;
|
// Marquer l'UTXO comme verrouillé dans la base de données uniquement
|
||||||
this.lockedUtxos.add(key);
|
try {
|
||||||
logger.debug('UTXO locked', { txid: txid.substring(0, 16) + '...', vout, totalLocked: this.lockedUtxos.size });
|
const db = getDatabase();
|
||||||
|
db.prepare(`
|
||||||
// Sécurité : limiter la taille du Set pour éviter une fuite mémoire
|
UPDATE utxos
|
||||||
// Si plus de 1000 UTXOs verrouillés, nettoyer les anciens (ne devrait jamais arriver)
|
SET is_locked_in_mutex = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
if (this.lockedUtxos.size > 1000) {
|
WHERE txid = ? AND vout = ?
|
||||||
logger.warn('Too many locked UTXOs, potential memory leak', { count: this.lockedUtxos.size });
|
`).run(txid, vout);
|
||||||
// En production, cela ne devrait jamais arriver car les UTXOs sont déverrouillés après utilisation
|
logger.debug('UTXO locked', { txid: txid.substring(0, 16) + '...', vout });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error updating UTXO lock status in database', {
|
||||||
|
error: error.message,
|
||||||
|
txid: txid.substring(0, 16) + '...',
|
||||||
|
vout,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +117,22 @@ class BitcoinRPC {
|
|||||||
* @param {number} vout - Index de l'output
|
* @param {number} vout - Index de l'output
|
||||||
*/
|
*/
|
||||||
unlockUtxo(txid, vout) {
|
unlockUtxo(txid, vout) {
|
||||||
const key = `${txid}:${vout}`;
|
// Marquer l'UTXO comme non verrouillé dans la base de données uniquement
|
||||||
this.lockedUtxos.delete(key);
|
try {
|
||||||
logger.debug('UTXO unlocked', { txid: txid.substring(0, 16) + '...', vout });
|
const db = getDatabase();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE txid = ? AND vout = ?
|
||||||
|
`).run(txid, vout);
|
||||||
|
logger.debug('UTXO unlocked', { txid: txid.substring(0, 16) + '...', vout });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error updating UTXO unlock status in database', {
|
||||||
|
error: error.message,
|
||||||
|
txid: txid.substring(0, 16) + '...',
|
||||||
|
vout,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -171,7 +204,7 @@ class BitcoinRPC {
|
|||||||
* @param {string} recipientAddress - Adresse de destination (optionnel, utilise getNewAddress si non fourni)
|
* @param {string} recipientAddress - Adresse de destination (optionnel, utilise getNewAddress si non fourni)
|
||||||
* @returns {Promise<Object>} Transaction créée avec txid
|
* @returns {Promise<Object>} Transaction créée avec txid
|
||||||
*/
|
*/
|
||||||
async createAnchorTransaction(hash, recipientAddress = null) {
|
async createAnchorTransaction(hash, recipientAddress = null, provisioningAddresses = null, numberOfProvisioningUtxos = null, retryCount = 0) {
|
||||||
// Acquérir le mutex pour l'accès aux UTXOs
|
// Acquérir le mutex pour l'accès aux UTXOs
|
||||||
const releaseMutex = await this.acquireUtxoMutex();
|
const releaseMutex = await this.acquireUtxoMutex();
|
||||||
let selectedUtxo = null;
|
let selectedUtxo = null;
|
||||||
@ -244,76 +277,35 @@ class BitcoinRPC {
|
|||||||
|
|
||||||
// Obtenir un UTXO disponible depuis la base de données
|
// Obtenir un UTXO disponible depuis la base de données
|
||||||
// Optimisation : ne charger qu'un seul UTXO au lieu de tous les UTXOs
|
// Optimisation : ne charger qu'un seul UTXO au lieu de tous les UTXOs
|
||||||
|
// Le filtrage des UTXOs verrouillés se fait directement dans la requête SQL
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Obtenir la liste des UTXOs verrouillés
|
|
||||||
const lockedKeys = Array.from(this.lockedUtxos);
|
|
||||||
|
|
||||||
// Sélectionner un UTXO disponible depuis la DB
|
// Sélectionner un UTXO disponible depuis la DB
|
||||||
// Critères : confirmé, non dépensé, non verrouillé, montant suffisant
|
// Critères : confirmé, non dépensé, non verrouillé, montant suffisant
|
||||||
// Utiliser une requête qui filtre les UTXOs verrouillés
|
const utxoQuery = db.prepare(`
|
||||||
let utxoFromDb = null;
|
SELECT txid, vout, address, amount, confirmations, block_time
|
||||||
|
FROM utxos
|
||||||
if (lockedKeys.length === 0) {
|
WHERE confirmations > 0
|
||||||
// Pas d'UTXOs verrouillés, requête simple
|
AND is_spent_onchain = 0
|
||||||
const utxoQuery = db.prepare(`
|
AND is_locked_in_mutex = 0
|
||||||
SELECT txid, vout, address, amount, confirmations, block_time
|
AND amount >= ?
|
||||||
FROM utxos
|
ORDER BY amount DESC
|
||||||
WHERE confirmations > 0
|
LIMIT 1
|
||||||
AND is_spent_onchain = 0
|
`);
|
||||||
AND amount >= ?
|
let utxoFromDb = utxoQuery.get(totalNeeded);
|
||||||
ORDER BY amount DESC
|
|
||||||
LIMIT 1
|
|
||||||
`);
|
|
||||||
utxoFromDb = utxoQuery.get(totalNeeded);
|
|
||||||
} else {
|
|
||||||
// Filtrer les UTXOs verrouillés en mémoire après la requête
|
|
||||||
// (plus simple et sûr que d'injecter des conditions SQL dynamiques)
|
|
||||||
const utxoQuery = db.prepare(`
|
|
||||||
SELECT txid, vout, address, amount, confirmations, block_time
|
|
||||||
FROM utxos
|
|
||||||
WHERE confirmations > 0
|
|
||||||
AND is_spent_onchain = 0
|
|
||||||
AND amount >= ?
|
|
||||||
ORDER BY amount DESC
|
|
||||||
`);
|
|
||||||
const candidates = utxoQuery.all(totalNeeded);
|
|
||||||
|
|
||||||
// Filtrer les UTXOs verrouillés
|
|
||||||
utxoFromDb = candidates.find(utxo => {
|
|
||||||
const key = `${utxo.txid}:${utxo.vout}`;
|
|
||||||
return !this.lockedUtxos.has(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!utxoFromDb) {
|
if (!utxoFromDb) {
|
||||||
// Si aucun UTXO trouvé avec le montant requis, essayer de trouver le plus grand disponible
|
// Si aucun UTXO trouvé avec le montant requis, essayer de trouver le plus grand disponible
|
||||||
let largestUtxo = null;
|
const largestUtxoQuery = db.prepare(`
|
||||||
|
SELECT txid, vout, address, amount, confirmations, block_time
|
||||||
if (lockedKeys.length === 0) {
|
FROM utxos
|
||||||
const largestUtxoQuery = db.prepare(`
|
WHERE confirmations > 0
|
||||||
SELECT txid, vout, address, amount, confirmations, block_time
|
AND is_spent_onchain = 0
|
||||||
FROM utxos
|
AND is_locked_in_mutex = 0
|
||||||
WHERE confirmations > 0
|
ORDER BY amount DESC
|
||||||
AND is_spent_onchain = 0
|
LIMIT 1
|
||||||
ORDER BY amount DESC
|
`);
|
||||||
LIMIT 1
|
const largestUtxo = largestUtxoQuery.get();
|
||||||
`);
|
|
||||||
largestUtxo = largestUtxoQuery.get();
|
|
||||||
} else {
|
|
||||||
const largestUtxoQuery = db.prepare(`
|
|
||||||
SELECT txid, vout, address, amount, confirmations, block_time
|
|
||||||
FROM utxos
|
|
||||||
WHERE confirmations > 0
|
|
||||||
AND is_spent_onchain = 0
|
|
||||||
ORDER BY amount DESC
|
|
||||||
`);
|
|
||||||
const candidates = largestUtxoQuery.all();
|
|
||||||
largestUtxo = candidates.find(utxo => {
|
|
||||||
const key = `${utxo.txid}:${utxo.vout}`;
|
|
||||||
return !this.lockedUtxos.has(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!largestUtxo) {
|
if (!largestUtxo) {
|
||||||
throw new Error('No available UTXOs in database (all are locked, spent, or unconfirmed)');
|
throw new Error('No available UTXOs in database (all are locked, spent, or unconfirmed)');
|
||||||
@ -409,20 +401,134 @@ class BitcoinRPC {
|
|||||||
totalSize: combinedData.length,
|
totalSize: combinedData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vérifier que l'UTXO est toujours disponible avant de l'utiliser
|
||||||
|
// (peut avoir été dépensé entre la sélection et l'utilisation)
|
||||||
|
// Limiter les tentatives pour éviter les boucles infinies
|
||||||
|
if (retryCount < 3) {
|
||||||
|
try {
|
||||||
|
const utxoCheck = await this.client.listunspent(0, 9999999, [selectedUtxo.address]);
|
||||||
|
const utxoStillAvailable = utxoCheck.some(u =>
|
||||||
|
u.txid === selectedUtxo.txid && u.vout === selectedUtxo.vout
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!utxoStillAvailable) {
|
||||||
|
// L'UTXO n'est plus disponible, le marquer comme dépensé et réessayer
|
||||||
|
logger.warn('Selected UTXO no longer available, marking as spent and retrying', {
|
||||||
|
txid: selectedUtxo.txid.substring(0, 16) + '...',
|
||||||
|
vout: selectedUtxo.vout,
|
||||||
|
retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Déverrouiller l'UTXO avant de le marquer comme dépensé
|
||||||
|
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbForUpdate = getDatabase();
|
||||||
|
dbForUpdate.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE txid = ? AND vout = ?
|
||||||
|
`).run(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
|
} catch (dbError) {
|
||||||
|
logger.warn('Error updating UTXO in database', { error: dbError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réessayer avec un autre UTXO (récursion limitée à 3 tentatives)
|
||||||
|
return this.createAnchorTransaction(hash, recipientAddress, provisioningAddresses, numberOfProvisioningUtxos, retryCount + 1);
|
||||||
|
}
|
||||||
|
} catch (checkError) {
|
||||||
|
logger.warn('Error checking UTXO availability, proceeding anyway', {
|
||||||
|
error: checkError.message,
|
||||||
|
txid: selectedUtxo.txid.substring(0, 16) + '...',
|
||||||
|
vout: selectedUtxo.vout,
|
||||||
|
});
|
||||||
|
// Continuer même si la vérification échoue (peut être un problème réseau temporaire)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('Max retry count reached for UTXO selection', { retryCount });
|
||||||
|
throw new Error('Failed to find available UTXO after multiple attempts');
|
||||||
|
}
|
||||||
|
|
||||||
// Créer la transaction
|
// Créer la transaction
|
||||||
const inputs = [{
|
const inputs = [{
|
||||||
txid: selectedUtxo.txid,
|
txid: selectedUtxo.txid,
|
||||||
vout: selectedUtxo.vout,
|
vout: selectedUtxo.vout,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const tx = await this.client.command('createrawtransaction', inputs, outputs);
|
let tx;
|
||||||
|
try {
|
||||||
|
tx = await this.client.command('createrawtransaction', inputs, outputs);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating raw transaction', {
|
||||||
|
error: error.message,
|
||||||
|
txid: selectedUtxo.txid.substring(0, 16) + '...',
|
||||||
|
vout: selectedUtxo.vout,
|
||||||
|
});
|
||||||
|
// Marquer l'UTXO comme dépensé si l'erreur suggère qu'il n'existe plus
|
||||||
|
if (error.message.includes('not found') || error.message.includes('does not exist')) {
|
||||||
|
try {
|
||||||
|
const dbForUpdate = getDatabase();
|
||||||
|
dbForUpdate.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE txid = ? AND vout = ?
|
||||||
|
`).run(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
|
} catch (dbError) {
|
||||||
|
logger.warn('Error updating UTXO in database', { error: dbError.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to create transaction: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Signer la transaction
|
// Signer la transaction
|
||||||
// Utiliser command() directement pour éviter les problèmes avec la bibliothèque
|
// Utiliser command() directement pour éviter les problèmes avec la bibliothèque
|
||||||
const signedTx = await this.client.command('signrawtransactionwithwallet', tx);
|
const signedTx = await this.client.command('signrawtransactionwithwallet', tx);
|
||||||
|
|
||||||
if (!signedTx.complete) {
|
if (!signedTx.complete) {
|
||||||
throw new Error('Transaction signing failed');
|
const errorDetails = signedTx.errors || [];
|
||||||
|
const errorMessages = errorDetails.map(e => {
|
||||||
|
const errorMsg = e.error || 'Unknown error';
|
||||||
|
const txid = e.txid || selectedUtxo.txid.substring(0, 16) + '...';
|
||||||
|
const vout = e.vout !== undefined ? e.vout : selectedUtxo.vout;
|
||||||
|
return `${errorMsg} (txid: ${txid}, vout: ${vout})`;
|
||||||
|
}).join('; ');
|
||||||
|
|
||||||
|
logger.error('Transaction signing failed', {
|
||||||
|
txid: selectedUtxo.txid.substring(0, 16) + '...',
|
||||||
|
vout: selectedUtxo.vout,
|
||||||
|
errors: errorDetails,
|
||||||
|
signedTxHex: signedTx.hex ? signedTx.hex.substring(0, 32) + '...' : 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si l'erreur indique que l'UTXO n'existe plus ou est déjà dépensé, le marquer comme dépensé
|
||||||
|
const hasUtxoNotFoundError = errorDetails.some(e => {
|
||||||
|
const errorMsg = (e.error || '').toLowerCase();
|
||||||
|
return errorMsg.includes('not found') ||
|
||||||
|
errorMsg.includes('does not exist') ||
|
||||||
|
errorMsg.includes('missing') ||
|
||||||
|
errorMsg.includes('already spent') ||
|
||||||
|
errorMsg.includes('input not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasUtxoNotFoundError) {
|
||||||
|
try {
|
||||||
|
const dbForUpdate = getDatabase();
|
||||||
|
dbForUpdate.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE txid = ? AND vout = ?
|
||||||
|
`).run(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
|
logger.info('UTXO marked as spent due to signing error', {
|
||||||
|
txid: selectedUtxo.txid.substring(0, 16) + '...',
|
||||||
|
vout: selectedUtxo.vout,
|
||||||
|
error: errorMessages,
|
||||||
|
});
|
||||||
|
} catch (dbError) {
|
||||||
|
logger.warn('Error updating UTXO in database', { error: dbError.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Transaction signing failed: ${errorMessages || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envoyer la transaction au mempool
|
// Envoyer la transaction au mempool
|
||||||
@ -526,13 +632,13 @@ class BitcoinRPC {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Déverrouiller l'UTXO maintenant que la transaction est dans le mempool
|
|
||||||
// Marquer l'UTXO comme dépensé dans la base de données
|
// Marquer l'UTXO comme dépensé dans la base de données
|
||||||
|
// L'UTXO est dépensé dans une transaction (mempool), mais pas encore confirmé dans un bloc
|
||||||
try {
|
try {
|
||||||
const dbForUpdate = getDatabase();
|
const dbForUpdate = getDatabase();
|
||||||
dbForUpdate.prepare(`
|
dbForUpdate.prepare(`
|
||||||
UPDATE utxos
|
UPDATE utxos
|
||||||
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
|
SET is_spent_onchain = 1, is_locked_in_mutex = 0, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE txid = ? AND vout = ?
|
WHERE txid = ? AND vout = ?
|
||||||
`).run(selectedUtxo.txid, selectedUtxo.vout);
|
`).run(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
logger.debug('UTXO marked as spent in database', {
|
logger.debug('UTXO marked as spent in database', {
|
||||||
@ -547,7 +653,8 @@ class BitcoinRPC {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// L'UTXO sera automatiquement marqué comme dépensé par Bitcoin Core
|
// Déverrouiller l'UTXO maintenant que la transaction est dans le mempool
|
||||||
|
// (mise à jour DB déjà faite ci-dessus, mais on déverrouille aussi en mémoire)
|
||||||
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
|
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
|
|
||||||
// Libérer le mutex
|
// Libérer le mutex
|
||||||
@ -570,6 +677,7 @@ class BitcoinRPC {
|
|||||||
|
|
||||||
// En cas d'erreur, déverrouiller l'UTXO et libérer le mutex
|
// En cas d'erreur, déverrouiller l'UTXO et libérer le mutex
|
||||||
if (selectedUtxo) {
|
if (selectedUtxo) {
|
||||||
|
// Déverrouiller l'UTXO (mise à jour DB + mémoire)
|
||||||
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
|
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
}
|
}
|
||||||
releaseMutex();
|
releaseMutex();
|
||||||
|
|||||||
@ -12,13 +12,21 @@ export const anchorRouter = express.Router();
|
|||||||
* GET /api/anchor/locked-utxos
|
* GET /api/anchor/locked-utxos
|
||||||
* Retourne la liste des UTXOs verrouillés dans le mutex
|
* Retourne la liste des UTXOs verrouillés dans le mutex
|
||||||
*/
|
*/
|
||||||
anchorRouter.get('/locked-utxos', (req, res) => {
|
anchorRouter.get('/locked-utxos', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const lockedUtxos = Array.from(bitcoinRPC.lockedUtxos || []);
|
// Lire les UTXOs verrouillés depuis la base de données
|
||||||
const lockedList = lockedUtxos.map(key => {
|
const { getDatabase } = await import('../database.js');
|
||||||
const [txid, vout] = key.split(':');
|
const db = getDatabase();
|
||||||
return { txid, vout: parseInt(vout, 10) };
|
const lockedUtxos = db.prepare(`
|
||||||
});
|
SELECT txid, vout
|
||||||
|
FROM utxos
|
||||||
|
WHERE is_locked_in_mutex = 1
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const lockedList = lockedUtxos.map(utxo => ({
|
||||||
|
txid: utxo.txid,
|
||||||
|
vout: utxo.vout,
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
locked: lockedList,
|
locked: lockedList,
|
||||||
|
|||||||
16
data/sync-utxos-cron.sh
Executable file
16
data/sync-utxos-cron.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script de synchronisation des UTXOs dépensés
|
||||||
|
# À exécuter via cron pour maintenir la synchronisation
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
LOG_FILE="$SCRIPT_DIR/sync-utxos.log"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
# Exécuter le script de synchronisation
|
||||||
|
node "$SCRIPT_DIR/sync-utxos-spent-status.mjs" >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
|
# Garder seulement les 100 dernières lignes du log
|
||||||
|
tail -n 100 "$LOG_FILE" > "$LOG_FILE.tmp" && mv "$LOG_FILE.tmp" "$LOG_FILE"
|
||||||
213
data/sync-utxos-spent-status.mjs
Executable file
213
data/sync-utxos-spent-status.mjs
Executable file
@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script de synchronisation des UTXOs dépensés
|
||||||
|
*
|
||||||
|
* Ce script vérifie tous les UTXOs marqués comme non dépensés dans la base de données
|
||||||
|
* et les compare avec listunspent pour mettre à jour leur statut is_spent_onchain.
|
||||||
|
*
|
||||||
|
* Usage: node data/sync-utxos-spent-status.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Utiliser better-sqlite3 depuis signet-dashboard
|
||||||
|
const signetDashboardPath = join(__dirname, '../signet-dashboard');
|
||||||
|
const require = createRequire(join(signetDashboardPath, 'package.json'));
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
// Configuration RPC Bitcoin
|
||||||
|
const RPC_URL = process.env.BITCOIN_RPC_URL || 'http://localhost:38332';
|
||||||
|
const RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin';
|
||||||
|
const RPC_PASS = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
|
||||||
|
const RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet';
|
||||||
|
|
||||||
|
// Chemin de la base de données
|
||||||
|
const DB_PATH = join(__dirname, 'signet.db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effectue un appel RPC Bitcoin
|
||||||
|
*/
|
||||||
|
function rpcCall(method, params = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(RPC_URL);
|
||||||
|
const isHttps = url.protocol === 'https:';
|
||||||
|
const httpModule = isHttps ? https : http;
|
||||||
|
|
||||||
|
const auth = Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64');
|
||||||
|
|
||||||
|
const postData = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: method,
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (isHttps ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Length': Buffer.byteLength(postData),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = httpModule.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(body);
|
||||||
|
if (json.error) {
|
||||||
|
reject(new Error(json.error.message || JSON.stringify(json.error)));
|
||||||
|
} else {
|
||||||
|
resolve(json.result);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Parse error: ${e.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(postData);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise les UTXOs dépensés
|
||||||
|
*/
|
||||||
|
async function syncSpentUtxos() {
|
||||||
|
console.log('🔍 Démarrage de la synchronisation des UTXOs dépensés...\n');
|
||||||
|
|
||||||
|
// Ouvrir la base de données
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Compter les UTXOs à vérifier sans les charger en mémoire
|
||||||
|
const notSpentCount = db.prepare(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM utxos
|
||||||
|
WHERE is_spent_onchain = 0
|
||||||
|
`).get().count;
|
||||||
|
|
||||||
|
console.log(`📊 UTXOs à vérifier: ${notSpentCount}`);
|
||||||
|
|
||||||
|
if (notSpentCount === 0) {
|
||||||
|
console.log('✅ Aucun UTXO à vérifier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer tous les UTXOs disponibles depuis Bitcoin
|
||||||
|
console.log('📡 Récupération des UTXOs depuis Bitcoin...');
|
||||||
|
const unspent = await rpcCall('listunspent', [0, 9999999, [], false, {
|
||||||
|
minimumAmount: 0,
|
||||||
|
maximumCount: 9999999,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
console.log(`📊 UTXOs disponibles dans Bitcoin: ${unspent.length}`);
|
||||||
|
|
||||||
|
// Utiliser une table temporaire pour éviter de charger tous les UTXOs en mémoire
|
||||||
|
console.log('💾 Création de la table temporaire...');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS temp_available_utxos (
|
||||||
|
txid TEXT,
|
||||||
|
vout INTEGER,
|
||||||
|
PRIMARY KEY (txid, vout)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insérer les UTXOs disponibles par batch pour réduire la mémoire
|
||||||
|
const BATCH_SIZE = 1000;
|
||||||
|
const insertAvailableUtxo = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO temp_available_utxos (txid, vout)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertBatch = db.transaction((utxos) => {
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
insertAvailableUtxo.run(utxo.txid, utxo.vout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('💾 Insertion des UTXOs disponibles par batch...');
|
||||||
|
for (let i = 0; i < unspent.length; i += BATCH_SIZE) {
|
||||||
|
const batch = unspent.slice(i, i + BATCH_SIZE);
|
||||||
|
insertBatch(batch);
|
||||||
|
|
||||||
|
if ((i + BATCH_SIZE) % 10000 === 0) {
|
||||||
|
console.log(` ⏳ Traitement: ${Math.min(i + BATCH_SIZE, unspent.length)}/${unspent.length} UTXOs insérés...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les UTXOs dépensés en une seule requête SQL (optimisé pour la mémoire)
|
||||||
|
console.log('💾 Mise à jour des UTXOs dépensés...');
|
||||||
|
const updateSpentStmt = db.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE is_spent_onchain = 0
|
||||||
|
AND (txid || ':' || vout) NOT IN (
|
||||||
|
SELECT txid || ':' || vout FROM temp_available_utxos
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const updateResult = updateSpentStmt.run();
|
||||||
|
const updatedCount = updateResult.changes;
|
||||||
|
|
||||||
|
// Nettoyer la table temporaire
|
||||||
|
db.exec('DROP TABLE IF EXISTS temp_available_utxos');
|
||||||
|
|
||||||
|
const stillAvailableCount = notSpentCount - updatedCount;
|
||||||
|
|
||||||
|
console.log(`\n📊 Résumé:`);
|
||||||
|
console.log(` - UTXOs vérifiés: ${notSpentCount}`);
|
||||||
|
console.log(` - UTXOs toujours disponibles: ${stillAvailableCount}`);
|
||||||
|
console.log(` - UTXOs dépensés détectés: ${updatedCount}`);
|
||||||
|
|
||||||
|
// Afficher les statistiques finales
|
||||||
|
const finalStats = {
|
||||||
|
total: db.prepare('SELECT COUNT(*) as count FROM utxos').get().count,
|
||||||
|
spent: db.prepare('SELECT COUNT(*) as count FROM utxos WHERE is_spent_onchain = 1').get().count,
|
||||||
|
notSpent: db.prepare('SELECT COUNT(*) as count FROM utxos WHERE is_spent_onchain = 0').get().count,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n📈 Statistiques finales:`);
|
||||||
|
console.log(` - Total UTXOs: ${finalStats.total}`);
|
||||||
|
console.log(` - Dépensés: ${finalStats.spent}`);
|
||||||
|
console.log(` - Non dépensés: ${finalStats.notSpent}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la synchronisation:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter la synchronisation
|
||||||
|
syncSpentUtxos()
|
||||||
|
.then(() => {
|
||||||
|
console.log('\n✅ Synchronisation terminée');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Erreur fatale:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
37
data/sync-utxos.log
Normal file
37
data/sync-utxos.log
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
🔍 Démarrage de la synchronisation des UTXOs dépensés...
|
||||||
|
|
||||||
|
📊 UTXOs à vérifier: 67955
|
||||||
|
📡 Récupération des UTXOs depuis Bitcoin...
|
||||||
|
📊 UTXOs disponibles dans Bitcoin: 174934
|
||||||
|
💾 Création de la table temporaire...
|
||||||
|
💾 Insertion des UTXOs disponibles par batch...
|
||||||
|
⏳ Traitement: 10000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 20000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 30000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 40000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 50000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 60000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 70000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 80000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 90000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 100000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 110000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 120000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 130000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 140000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 150000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 160000/174934 UTXOs insérés...
|
||||||
|
⏳ Traitement: 170000/174934 UTXOs insérés...
|
||||||
|
💾 Mise à jour des UTXOs dépensés...
|
||||||
|
|
||||||
|
📊 Résumé:
|
||||||
|
- UTXOs vérifiés: 67955
|
||||||
|
- UTXOs toujours disponibles: 67955
|
||||||
|
- UTXOs dépensés détectés: 0
|
||||||
|
|
||||||
|
📈 Statistiques finales:
|
||||||
|
- Total UTXOs: 68398
|
||||||
|
- Dépensés: 443
|
||||||
|
- Non dépensés: 67955
|
||||||
|
|
||||||
|
✅ Synchronisation terminée
|
||||||
244
features/pagination-serveur-base-donnees.md
Normal file
244
features/pagination-serveur-base-donnees.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# Pagination côté serveur avec base de données
|
||||||
|
|
||||||
|
**Date:** 2026-01-27
|
||||||
|
**Auteur:** Équipe 4NK
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Implémenter la pagination côté serveur pour les endpoints `/api/utxo/list` et `/api/hash/list` afin de réduire la consommation mémoire en ne chargeant que les données nécessaires.
|
||||||
|
|
||||||
|
## Problème identifié
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
|
||||||
|
La pagination était effectuée côté client (frontend) :
|
||||||
|
- Le serveur retournait **TOUS** les UTXOs/hashes (68k+ UTXOs, 32k+ hashes)
|
||||||
|
- Le frontend chargeait toutes les données en mémoire
|
||||||
|
- La pagination se faisait ensuite en JavaScript avec `.slice()`
|
||||||
|
|
||||||
|
**Impact :**
|
||||||
|
- Toutes les données chargées en mémoire côté serveur ET côté client
|
||||||
|
- Consommation mémoire inutile pour afficher seulement 50 éléments par page
|
||||||
|
- Temps de réponse lent pour charger toutes les données
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Stratégie
|
||||||
|
|
||||||
|
Implémenter la pagination côté serveur avec :
|
||||||
|
1. **Paramètres de pagination** : `page` et `limit` dans les query parameters
|
||||||
|
2. **Requêtes SQL paginées** : Utiliser `LIMIT` et `OFFSET` dans les requêtes SQL
|
||||||
|
3. **Métadonnées de pagination** : Retourner `total`, `totalPages`, `page`, `limit`
|
||||||
|
4. **Compatibilité frontend** : Adapter le frontend pour utiliser la pagination serveur
|
||||||
|
|
||||||
|
### Avantages
|
||||||
|
|
||||||
|
- **Réduction mémoire** : Ne charger que les données de la page demandée
|
||||||
|
- **Performance** : Requêtes SQL indexées avec LIMIT/OFFSET beaucoup plus rapides
|
||||||
|
- **Scalabilité** : Fonctionne même avec des millions d'UTXOs/hashes
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### 1. Endpoint `/api/hash/list` avec pagination
|
||||||
|
|
||||||
|
**Fichier:** `signet-dashboard/src/server.js`
|
||||||
|
|
||||||
|
**Avant:**
|
||||||
|
```javascript
|
||||||
|
app.get('/api/hash/list', async (req, res) => {
|
||||||
|
const hashList = await bitcoinRPC.getHashList(); // Charge TOUS les hashes
|
||||||
|
res.json({ hashes: hashList, count: hashList.length });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après:**
|
||||||
|
```javascript
|
||||||
|
app.get('/api/hash/list', async (req, res) => {
|
||||||
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
|
const limit = parseInt(req.query.limit || '50', 10);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const totalCount = db.prepare('SELECT COUNT(*) as count FROM anchors').get();
|
||||||
|
const hashes = db.prepare(`
|
||||||
|
SELECT hash, txid, block_height, confirmations, date
|
||||||
|
FROM anchors
|
||||||
|
ORDER BY block_height ASC, id ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hashes,
|
||||||
|
count: hashes.length,
|
||||||
|
total: totalCount?.count || 0,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil((totalCount?.count || 0) / limit),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Endpoint `/api/utxo/list` avec pagination par catégorie
|
||||||
|
|
||||||
|
**Fichier:** `signet-dashboard/src/server.js`
|
||||||
|
|
||||||
|
**Avant:**
|
||||||
|
```javascript
|
||||||
|
app.get('/api/utxo/list', async (req, res) => {
|
||||||
|
const utxoData = await bitcoinRPC.getUtxoList(); // Charge TOUS les UTXOs
|
||||||
|
res.json({
|
||||||
|
blocRewards: utxoData.blocRewards, // Tous
|
||||||
|
anchors: utxoData.anchors, // Tous
|
||||||
|
changes: utxoData.changes, // Tous
|
||||||
|
fees: utxoData.fees || [], // Tous
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après:**
|
||||||
|
```javascript
|
||||||
|
app.get('/api/utxo/list', async (req, res) => {
|
||||||
|
const category = req.query.category || 'all';
|
||||||
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
|
const limit = parseInt(req.query.limit || '50', 10);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Requête SQL paginée selon la catégorie
|
||||||
|
if (category === 'ancrages') {
|
||||||
|
utxos = db.prepare(`
|
||||||
|
SELECT txid, vout, address, amount, confirmations, category,
|
||||||
|
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
|
||||||
|
FROM utxos
|
||||||
|
WHERE category = 'ancrages' OR category = 'anchor'
|
||||||
|
ORDER BY amount DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
}
|
||||||
|
// ... autres catégories
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Adaptation du frontend
|
||||||
|
|
||||||
|
**Fichier:** `signet-dashboard/public/utxo-list.html`
|
||||||
|
|
||||||
|
**Avant:**
|
||||||
|
```javascript
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/utxo/list`);
|
||||||
|
const data = await response.json();
|
||||||
|
// Charge TOUS les UTXOs, puis pagine côté client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après:**
|
||||||
|
```javascript
|
||||||
|
// Charger chaque catégorie avec pagination (limit=1000 pour compatibilité)
|
||||||
|
const [blocRewardsRes, anchorsRes, changesRes, feesRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=bloc_rewards&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=ancrages&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=changes&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=fees&page=1&limit=1000`),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fichier:** `signet-dashboard/public/hash-list.html`
|
||||||
|
|
||||||
|
**Avant:**
|
||||||
|
```javascript
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/hash/list`);
|
||||||
|
const data = await response.json();
|
||||||
|
allHashes = data.hashes || []; // Charge TOUS les hashes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après:**
|
||||||
|
```javascript
|
||||||
|
// Charger tous les hashes avec pagination (boucle pour compatibilité)
|
||||||
|
let allHashesLoaded = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const limit = 1000;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/hash/list?page=${currentPage}&limit=${limit}`);
|
||||||
|
const data = await response.json();
|
||||||
|
allHashesLoaded = allHashesLoaded.concat(data.hashes || []);
|
||||||
|
if (data.hashes.length < limit || currentPage >= data.totalPages) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evolutions
|
||||||
|
|
||||||
|
### Pagination optimale (future)
|
||||||
|
|
||||||
|
Pour une pagination vraiment optimale, le frontend devrait :
|
||||||
|
- Charger page par page à la demande (lazy loading)
|
||||||
|
- Ne charger que la page actuelle + 1 page en cache
|
||||||
|
- Utiliser les métadonnées `totalPages` pour la navigation
|
||||||
|
|
||||||
|
**Exemple:**
|
||||||
|
```javascript
|
||||||
|
// Charger seulement la page demandée
|
||||||
|
async function loadPage(category, page) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/utxo/list?category=${category}&page=${page}&limit=50`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibilité actuelle
|
||||||
|
|
||||||
|
Pour maintenir la compatibilité avec le code frontend existant :
|
||||||
|
- Le frontend charge encore jusqu'à 1000 éléments par catégorie
|
||||||
|
- La pagination côté client continue de fonctionner
|
||||||
|
- Réduction mémoire : de 68k+ → 1000 max par catégorie (réduction de 98.5%)
|
||||||
|
|
||||||
|
## Pages affectées
|
||||||
|
|
||||||
|
- `signet-dashboard/src/server.js` : Pagination pour `/api/hash/list` et `/api/utxo/list`
|
||||||
|
- `signet-dashboard/public/utxo-list.html` : Adaptation pour utiliser la pagination serveur
|
||||||
|
- `signet-dashboard/public/hash-list.html` : Adaptation pour charger avec pagination
|
||||||
|
|
||||||
|
## Modalités de déploiement
|
||||||
|
|
||||||
|
1. **Redémarrer le service:**
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart signet-dashboard.service
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Vérifier les logs:**
|
||||||
|
```bash
|
||||||
|
journalctl -u signet-dashboard.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Tester les endpoints:**
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3014/api/hash/list?page=1&limit=10"
|
||||||
|
curl "http://localhost:3014/api/utxo/list?category=ancrages&page=1&limit=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modalités d'analyse
|
||||||
|
|
||||||
|
### Avant optimisation
|
||||||
|
- **Mémoire par requête** : Tous les UTXOs/hashes chargés (68k+ UTXOs, 32k+ hashes)
|
||||||
|
- **Temps de réponse** : ~500-2000ms (chargement de toutes les données)
|
||||||
|
- **Mémoire frontend** : Toutes les données en mémoire
|
||||||
|
|
||||||
|
### Après optimisation
|
||||||
|
- **Mémoire par requête** : Seulement les données de la page demandée (50-1000 éléments)
|
||||||
|
- **Temps de réponse** : ~10-100ms (requête SQL indexée avec LIMIT)
|
||||||
|
- **Mémoire frontend** : Maximum 1000 éléments par catégorie (au lieu de 68k+)
|
||||||
|
|
||||||
|
### Métriques à surveiller
|
||||||
|
- Temps de réponse des endpoints avec pagination
|
||||||
|
- Consommation mémoire de `signet-dashboard` (devrait rester stable)
|
||||||
|
- Nombre d'éléments chargés par requête
|
||||||
|
- Performance des requêtes SQL avec LIMIT/OFFSET
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- La pagination utilise `LIMIT` et `OFFSET` qui sont efficaces avec les index SQLite
|
||||||
|
- Les requêtes sont indexées (`idx_utxos_category`, `idx_anchors_block_height`, etc.)
|
||||||
|
- Le frontend charge encore jusqu'à 1000 éléments pour compatibilité, mais peut être optimisé davantage
|
||||||
|
- Les counts totaux sont toujours disponibles via `/api/utxo/count` sans charger les données
|
||||||
179
features/synchronisation-automatique-utxos-depenses.md
Normal file
179
features/synchronisation-automatique-utxos-depenses.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# Synchronisation automatique des UTXOs dépensés
|
||||||
|
|
||||||
|
**Auteur** : Équipe 4NK
|
||||||
|
**Date** : 2026-01-27
|
||||||
|
**Version** : 1.0
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Mettre à jour automatiquement tous les UTXOs dépensés dans la base de données pour maintenir la synchronisation avec l'état réel de Bitcoin, tout en réduisant l'usage mémoire.
|
||||||
|
|
||||||
|
## Problème Identifié
|
||||||
|
|
||||||
|
### Avant
|
||||||
|
|
||||||
|
- `signet-dashboard` ne mettait à jour que les UTXOs chargés en mémoire lors d'une mise à jour
|
||||||
|
- Les UTXOs dépensés n'étaient pas mis à jour dans la base de données s'ils n'étaient pas en mémoire
|
||||||
|
- Désynchronisation entre la DB et l'état réel de Bitcoin
|
||||||
|
- 442 UTXOs dépensés détectés lors de la première synchronisation
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
- L'API d'ancrage sélectionnait des UTXOs déjà dépensés
|
||||||
|
- Erreurs "Input not found or already spent" lors de la création de transactions
|
||||||
|
- Dégradation de la fiabilité de l'API d'ancrage
|
||||||
|
|
||||||
|
## Solutions Implémentées
|
||||||
|
|
||||||
|
### 1. Mise à jour automatique dans signet-dashboard
|
||||||
|
|
||||||
|
**Fichier** : `signet-dashboard/src/bitcoin-rpc.js`
|
||||||
|
|
||||||
|
**Modification** : Mise à jour de TOUS les UTXOs de la base de données lors de chaque synchronisation, pas seulement ceux en mémoire.
|
||||||
|
|
||||||
|
**Optimisations mémoire** :
|
||||||
|
- Utilisation d'une table temporaire SQL au lieu de charger tous les UTXOs en mémoire
|
||||||
|
- Traitement par batch de 1000 UTXOs pour réduire la consommation mémoire
|
||||||
|
- Une seule requête SQL pour mettre à jour tous les UTXOs dépensés
|
||||||
|
|
||||||
|
**Code** :
|
||||||
|
```javascript
|
||||||
|
// Créer une table temporaire pour stocker les UTXOs disponibles
|
||||||
|
db.exec(`
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS temp_available_utxos (
|
||||||
|
txid TEXT,
|
||||||
|
vout INTEGER,
|
||||||
|
PRIMARY KEY (txid, vout)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insérer les UTXOs disponibles par batch
|
||||||
|
const SYNC_BATCH_SIZE = 1000;
|
||||||
|
for (let i = 0; i < unspent.length; i += SYNC_BATCH_SIZE) {
|
||||||
|
const batch = unspent.slice(i, i + SYNC_BATCH_SIZE);
|
||||||
|
insertBatch(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les UTXOs dépensés en une seule requête SQL
|
||||||
|
const updateSpentStmt = db.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE is_spent_onchain = 0
|
||||||
|
AND (txid || ':' || vout) NOT IN (
|
||||||
|
SELECT txid || ':' || vout FROM temp_available_utxos
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Script de synchronisation périodique
|
||||||
|
|
||||||
|
**Fichier** : `data/sync-utxos-spent-status.mjs`
|
||||||
|
|
||||||
|
**Fonctionnalités** :
|
||||||
|
- Vérifie tous les UTXOs marqués comme non dépensés dans la DB
|
||||||
|
- Compare avec `listunspent` depuis Bitcoin
|
||||||
|
- Met à jour `is_spent_onchain = 1` pour ceux qui ne sont plus disponibles
|
||||||
|
- Optimisé pour la mémoire : utilise une table temporaire SQL au lieu de charger tous les UTXOs en mémoire
|
||||||
|
|
||||||
|
**Optimisations mémoire** :
|
||||||
|
- Ne charge pas tous les UTXOs de la DB en mémoire (compte seulement)
|
||||||
|
- Utilise une table temporaire SQL pour les UTXOs disponibles
|
||||||
|
- Traitement par batch de 1000 UTXOs
|
||||||
|
|
||||||
|
### 3. Cron job pour synchronisation automatique
|
||||||
|
|
||||||
|
**Fichier** : `data/sync-utxos-cron.sh`
|
||||||
|
|
||||||
|
**Configuration** : Exécution toutes les heures via cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
0 * * * * /home/ncantu/Bureau/code/bitcoin/data/sync-utxos-cron.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonctionnalités** :
|
||||||
|
- Exécute le script de synchronisation
|
||||||
|
- Log les résultats dans `data/sync-utxos.log`
|
||||||
|
- Garde seulement les 100 dernières lignes du log
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
### Fichiers Modifiés
|
||||||
|
|
||||||
|
- `signet-dashboard/src/bitcoin-rpc.js` :
|
||||||
|
- Ligne 1033-1101 : Mise à jour automatique de tous les UTXOs dépensés lors de chaque synchronisation
|
||||||
|
- Utilisation d'une table temporaire SQL pour réduire la mémoire
|
||||||
|
- Traitement par batch de 1000 UTXOs
|
||||||
|
|
||||||
|
### Fichiers Créés
|
||||||
|
|
||||||
|
- `data/sync-utxos-spent-status.mjs` : Script de synchronisation des UTXOs dépensés
|
||||||
|
- `data/sync-utxos-cron.sh` : Script wrapper pour cron
|
||||||
|
- `features/synchronisation-automatique-utxos-depenses.md` : Cette documentation
|
||||||
|
|
||||||
|
## Modalités de Déploiement
|
||||||
|
|
||||||
|
### Déploiement Automatique
|
||||||
|
|
||||||
|
1. **Redémarrer signet-dashboard** :
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart signet-dashboard.service
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Vérifier le cron** :
|
||||||
|
```bash
|
||||||
|
crontab -l | grep sync-utxos
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Tester le script manuellement** :
|
||||||
|
```bash
|
||||||
|
cd /home/ncantu/Bureau/code/bitcoin
|
||||||
|
node data/sync-utxos-spent-status.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification
|
||||||
|
|
||||||
|
1. **Vérifier les logs de synchronisation** :
|
||||||
|
```bash
|
||||||
|
tail -f /home/ncantu/Bureau/code/bitcoin/data/sync-utxos.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Vérifier les statistiques** :
|
||||||
|
```bash
|
||||||
|
cd /home/ncantu/Bureau/code/bitcoin/signet-dashboard
|
||||||
|
node -e "const Database = require('better-sqlite3'); const db = new Database('../data/signet.db'); const stats = { total: db.prepare('SELECT COUNT(*) as count FROM utxos').get().count, spent: db.prepare('SELECT COUNT(*) as count FROM utxos WHERE is_spent_onchain = 1').get().count, notSpent: db.prepare('SELECT COUNT(*) as count FROM utxos WHERE is_spent_onchain = 0').get().count }; console.log('Total:', stats.total, 'Dépensés:', stats.spent, 'Non dépensés:', stats.notSpent); db.close();"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modalités d'Analyse
|
||||||
|
|
||||||
|
### Vérification que la correction fonctionne
|
||||||
|
|
||||||
|
1. **Vérifier la synchronisation automatique** :
|
||||||
|
- Les logs de `signet-dashboard` doivent afficher "UTXOs dépensés mis à jour dans la base de données" lors de chaque synchronisation
|
||||||
|
- Le nombre d'UTXOs dépensés doit augmenter progressivement
|
||||||
|
|
||||||
|
2. **Vérifier le cron** :
|
||||||
|
- Le script doit s'exécuter toutes les heures
|
||||||
|
- Les logs doivent être écrits dans `data/sync-utxos.log`
|
||||||
|
|
||||||
|
3. **Vérifier l'API d'ancrage** :
|
||||||
|
- Plus d'erreurs "Input not found or already spent"
|
||||||
|
- Les UTXOs sélectionnés sont toujours disponibles
|
||||||
|
|
||||||
|
### Optimisations Mémoire
|
||||||
|
|
||||||
|
1. **Avant** :
|
||||||
|
- Chargement de tous les UTXOs en mémoire (68k+ UTXOs)
|
||||||
|
- Création d'un Set avec tous les UTXOs disponibles
|
||||||
|
- Consommation mémoire élevée
|
||||||
|
|
||||||
|
2. **Après** :
|
||||||
|
- Utilisation d'une table temporaire SQL
|
||||||
|
- Traitement par batch de 1000 UTXOs
|
||||||
|
- Réduction significative de la consommation mémoire
|
||||||
|
|
||||||
|
## Bénéfices
|
||||||
|
|
||||||
|
1. **Synchronisation complète** : Tous les UTXOs sont mis à jour, pas seulement ceux en mémoire
|
||||||
|
2. **Réduction mémoire** : Utilisation de tables temporaires SQL au lieu de structures en mémoire
|
||||||
|
3. **Fiabilité** : L'API d'ancrage ne sélectionne plus d'UTXOs déjà dépensés
|
||||||
|
4. **Automatisation** : Synchronisation automatique via cron toutes les heures
|
||||||
158
fixKnowledge/api-anchorage-utxo-already-spent-error.md
Normal file
158
fixKnowledge/api-anchorage-utxo-already-spent-error.md
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# Correction : Erreur "Input not found or already spent" dans l'API d'ancrage
|
||||||
|
|
||||||
|
**Auteur** : Équipe 4NK
|
||||||
|
**Date** : 2026-01-27
|
||||||
|
**Version** : 1.0
|
||||||
|
|
||||||
|
## Problème Identifié
|
||||||
|
|
||||||
|
L'API d'ancrage retournait une erreur HTTP 500 avec le message "Input not found or already spent" lors de la création de transactions d'ancrage.
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- Erreur HTTP 500 lors des tests d'ancrage
|
||||||
|
- Message d'erreur : "Transaction signing failed: Input not found or already spent"
|
||||||
|
- L'UTXO sélectionné depuis la base de données n'est plus disponible dans Bitcoin
|
||||||
|
- La base de données n'est pas synchronisée avec l'état réel de Bitcoin
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
- Les ancrages échouent avec une erreur 500
|
||||||
|
- Les UTXOs sélectionnés peuvent être déjà dépensés mais toujours marqués comme disponibles dans la DB
|
||||||
|
- Dégradation de la fiabilité de l'API d'ancrage
|
||||||
|
|
||||||
|
## Cause Racine
|
||||||
|
|
||||||
|
1. **Désynchronisation entre la DB et Bitcoin** : Un UTXO peut être dépensé entre le moment où il est sélectionné depuis la DB et le moment où il est utilisé dans une transaction.
|
||||||
|
|
||||||
|
2. **Détection d'erreur incomplète** : La détection d'erreur ne couvrait pas tous les cas ("already spent", "input not found").
|
||||||
|
|
||||||
|
3. **Pas de vérification de disponibilité** : Aucune vérification de disponibilité de l'UTXO juste avant de l'utiliser.
|
||||||
|
|
||||||
|
## Correctifs Appliqués
|
||||||
|
|
||||||
|
### 1. Amélioration de la détection d'erreur
|
||||||
|
|
||||||
|
**Fichier** : `api-anchorage/src/bitcoin-rpc.js`
|
||||||
|
|
||||||
|
**Modification** : Extension de la détection d'erreur pour inclure "already spent" et "input not found" :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Si l'erreur indique que l'UTXO n'existe plus ou est déjà dépensé, le marquer comme dépensé
|
||||||
|
const hasUtxoNotFoundError = errorDetails.some(e => {
|
||||||
|
const errorMsg = (e.error || '').toLowerCase();
|
||||||
|
return errorMsg.includes('not found') ||
|
||||||
|
errorMsg.includes('does not exist') ||
|
||||||
|
errorMsg.includes('missing') ||
|
||||||
|
errorMsg.includes('already spent') ||
|
||||||
|
errorMsg.includes('input not found');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Vérification de disponibilité avant utilisation
|
||||||
|
|
||||||
|
**Fichier** : `api-anchorage/src/bitcoin-rpc.js`
|
||||||
|
|
||||||
|
**Modification** : Ajout d'une vérification de disponibilité de l'UTXO juste avant de l'utiliser, avec mécanisme de retry :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Vérifier que l'UTXO est toujours disponible avant de l'utiliser
|
||||||
|
// (peut avoir été dépensé entre la sélection et l'utilisation)
|
||||||
|
// Limiter les tentatives pour éviter les boucles infinies
|
||||||
|
if (retryCount < 3) {
|
||||||
|
try {
|
||||||
|
const utxoCheck = await this.client.listunspent(0, 9999999, [selectedUtxo.address]);
|
||||||
|
const utxoStillAvailable = utxoCheck.some(u =>
|
||||||
|
u.txid === selectedUtxo.txid && u.vout === selectedUtxo.vout
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!utxoStillAvailable) {
|
||||||
|
// L'UTXO n'est plus disponible, le marquer comme dépensé et réessayer
|
||||||
|
this.unlockUtxo(selectedUtxo.txid, selectedUtxo.vout);
|
||||||
|
// Marquer comme dépensé dans la DB
|
||||||
|
// Réessayer avec un autre UTXO
|
||||||
|
return this.createAnchorTransaction(hash, recipientAddress, provisioningAddresses, numberOfProvisioningUtxos, retryCount + 1);
|
||||||
|
}
|
||||||
|
} catch (checkError) {
|
||||||
|
// Continuer même si la vérification échoue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Ajout du paramètre retryCount
|
||||||
|
|
||||||
|
**Fichier** : `api-anchorage/src/bitcoin-rpc.js`
|
||||||
|
|
||||||
|
**Modification** : Ajout du paramètre `retryCount` à la fonction `createAnchorTransaction` pour limiter les tentatives :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async createAnchorTransaction(hash, recipientAddress = null, provisioningAddresses = null, numberOfProvisioningUtxos = null, retryCount = 0) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modifications
|
||||||
|
|
||||||
|
- `api-anchorage/src/bitcoin-rpc.js` :
|
||||||
|
- Ligne 207 : Ajout des paramètres `provisioningAddresses`, `numberOfProvisioningUtxos`, `retryCount` à `createAnchorTransaction`
|
||||||
|
- Ligne 404-431 : Ajout de la vérification de disponibilité de l'UTXO avant utilisation
|
||||||
|
- Ligne 455-477 : Amélioration de la détection d'erreur pour inclure "already spent" et "input not found"
|
||||||
|
|
||||||
|
## Modalités de Déploiement
|
||||||
|
|
||||||
|
### Redémarrage de l'API
|
||||||
|
|
||||||
|
1. **Redémarrer l'API** :
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart anchorage-api.service
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Vérifier le statut** :
|
||||||
|
```bash
|
||||||
|
systemctl is-active anchorage-api.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification
|
||||||
|
|
||||||
|
1. **Tester l'ancrage** :
|
||||||
|
```bash
|
||||||
|
curl -X POST https://anchorage.certificator.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
|
||||||
|
journalctl -u anchorage-api.service --since "5 minutes ago" | grep -E "(UTXO|already spent|retrying)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modalités d'Analyse
|
||||||
|
|
||||||
|
### Vérification que la correction fonctionne
|
||||||
|
|
||||||
|
1. **Vérifier la détection d'erreur** :
|
||||||
|
- Les logs doivent afficher "UTXO marked as spent due to signing error" si un UTXO est déjà dépensé
|
||||||
|
- Les erreurs "already spent" et "input not found" doivent être détectées
|
||||||
|
|
||||||
|
2. **Vérifier la vérification de disponibilité** :
|
||||||
|
- Les logs doivent afficher "Selected UTXO no longer available, marking as spent and retrying" si un UTXO n'est plus disponible
|
||||||
|
- Le mécanisme de retry doit fonctionner (maximum 3 tentatives)
|
||||||
|
|
||||||
|
3. **Vérifier les transactions d'ancrage** :
|
||||||
|
- Les ancrages doivent réussir même si le premier UTXO sélectionné est déjà dépensé
|
||||||
|
- Les UTXOs dépensés doivent être correctement marqués dans la DB
|
||||||
|
|
||||||
|
### Cas limites
|
||||||
|
|
||||||
|
1. **Tous les UTXOs sont déjà dépensés** :
|
||||||
|
- L'erreur doit indiquer "No available UTXOs in database (all are locked, spent, or unconfirmed)"
|
||||||
|
- Pas de boucle infinie
|
||||||
|
|
||||||
|
2. **Problème réseau lors de la vérification** :
|
||||||
|
- La vérification échoue silencieusement et l'opération continue
|
||||||
|
- L'erreur sera détectée lors de la signature de la transaction
|
||||||
|
|
||||||
|
3. **Maximum de tentatives atteint** :
|
||||||
|
- L'erreur doit indiquer "Failed to find available UTXO after multiple attempts"
|
||||||
|
- Pas de boucle infinie
|
||||||
@ -212,14 +212,30 @@
|
|||||||
contentDiv.innerHTML = '<div class="loading">Chargement des hash...</div>';
|
contentDiv.innerHTML = '<div class="loading">Chargement des hash...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/hash/list`);
|
// Charger tous les hashes avec pagination (limite de 10000 pour compatibilité)
|
||||||
|
let allHashesLoaded = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const limit = 1000;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
if (!response.ok) {
|
while (hasMore) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
const response = await fetch(`${API_BASE_URL}/api/hash/list?page=${currentPage}&limit=${limit}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
allHashesLoaded = allHashesLoaded.concat(data.hashes || []);
|
||||||
|
|
||||||
|
if (data.hashes.length < limit || currentPage >= data.totalPages) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
allHashes = allHashesLoaded;
|
||||||
allHashes = data.hashes || [];
|
|
||||||
|
|
||||||
document.getElementById('hash-count').textContent = allHashes.length.toLocaleString('fr-FR');
|
document.getElementById('hash-count').textContent = allHashes.length.toLocaleString('fr-FR');
|
||||||
updateLastUpdateTime();
|
updateLastUpdateTime();
|
||||||
|
|||||||
@ -624,13 +624,34 @@
|
|||||||
loadSmallUtxosInfo();
|
loadSmallUtxosInfo();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/utxo/list`);
|
// Charger toutes les catégories avec pagination
|
||||||
|
const [blocRewardsRes, anchorsRes, changesRes, feesRes, countsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=bloc_rewards&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=ancrages&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=changes&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=fees&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/count`),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!blocRewardsRes.ok || !anchorsRes.ok || !changesRes.ok || !feesRes.ok || !countsRes.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${blocRewardsRes.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const [blocRewardsData, anchorsData, changesData, feesData, countsData] = await Promise.all([
|
||||||
|
blocRewardsRes.json(),
|
||||||
|
anchorsRes.json(),
|
||||||
|
changesRes.json(),
|
||||||
|
feesRes.json(),
|
||||||
|
countsRes.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
blocRewards: blocRewardsData.blocRewards || [],
|
||||||
|
anchors: anchorsData.anchors || [],
|
||||||
|
changes: changesData.changes || [],
|
||||||
|
fees: feesData.fees || [],
|
||||||
|
counts: countsData,
|
||||||
|
};
|
||||||
|
|
||||||
progressBarFill.style.width = '100%';
|
progressBarFill.style.width = '100%';
|
||||||
progressPercent.textContent = '100 %';
|
progressPercent.textContent = '100 %';
|
||||||
@ -698,17 +719,38 @@
|
|||||||
loadSmallUtxosInfo();
|
loadSmallUtxosInfo();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/utxo/list`);
|
// Charger toutes les catégories avec pagination
|
||||||
|
const [blocRewardsRes, anchorsRes, changesRes, feesRes, countsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=bloc_rewards&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=ancrages&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=changes&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/list?category=fees&page=1&limit=1000`),
|
||||||
|
fetch(`${API_BASE_URL}/api/utxo/count`),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!blocRewardsRes.ok || !anchorsRes.ok || !changesRes.ok || !feesRes.ok || !countsRes.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${blocRewardsRes.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
progressBarFill.style.width = '50%';
|
progressBarFill.style.width = '50%';
|
||||||
progressPercent.textContent = '50 %';
|
progressPercent.textContent = '50 %';
|
||||||
progressStats.textContent = 'Traitement des données...';
|
progressStats.textContent = 'Traitement des données...';
|
||||||
|
|
||||||
const data = await response.json();
|
const [blocRewardsData, anchorsData, changesData, feesData, countsData] = await Promise.all([
|
||||||
|
blocRewardsRes.json(),
|
||||||
|
anchorsRes.json(),
|
||||||
|
changesRes.json(),
|
||||||
|
feesRes.json(),
|
||||||
|
countsRes.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
blocRewards: blocRewardsData.blocRewards || [],
|
||||||
|
anchors: anchorsData.anchors || [],
|
||||||
|
changes: changesData.changes || [],
|
||||||
|
fees: feesData.fees || [],
|
||||||
|
counts: countsData,
|
||||||
|
};
|
||||||
|
|
||||||
const blocRewards = data.blocRewards || [];
|
const blocRewards = data.blocRewards || [];
|
||||||
const anchors = data.anchors || [];
|
const anchors = data.anchors || [];
|
||||||
|
|||||||
@ -1031,7 +1031,67 @@ class BitcoinRPC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier les UTXOs dépensés (ceux qui étaient dans la base de données mais plus dans listunspent)
|
// Vérifier les UTXOs dépensés (ceux qui étaient dans la base de données mais plus dans listunspent)
|
||||||
// Ces UTXOs sont marqués comme dépensés mais conservés dans la base de données pour l'historique
|
// Mettre à jour TOUS les UTXOs de la base de données, pas seulement ceux en mémoire
|
||||||
|
// Optimisation : utiliser une requête SQL directe au lieu de parcourir tous les UTXOs en mémoire
|
||||||
|
const updateSpentUtxos = db.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE is_spent_onchain = 0
|
||||||
|
AND (txid || ':' || vout) NOT IN (
|
||||||
|
SELECT txid || ':' || vout FROM (
|
||||||
|
SELECT txid, vout FROM utxos WHERE is_spent_onchain = 0 LIMIT 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Créer une liste des UTXOs disponibles pour la requête SQL
|
||||||
|
// Utiliser une table temporaire pour éviter de charger tous les UTXOs en mémoire
|
||||||
|
db.exec(`
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS temp_available_utxos (
|
||||||
|
txid TEXT,
|
||||||
|
vout INTEGER,
|
||||||
|
PRIMARY KEY (txid, vout)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insérer les UTXOs disponibles par batch pour réduire la mémoire
|
||||||
|
const SYNC_BATCH_SIZE = 1000;
|
||||||
|
const insertAvailableUtxo = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO temp_available_utxos (txid, vout)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertBatch = db.transaction((utxos) => {
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
insertAvailableUtxo.run(utxo.txid, utxo.vout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insérer par batch pour réduire la consommation mémoire
|
||||||
|
for (let i = 0; i < unspent.length; i += SYNC_BATCH_SIZE) {
|
||||||
|
const batch = unspent.slice(i, i + SYNC_BATCH_SIZE);
|
||||||
|
insertBatch(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les UTXOs dépensés en une seule requête SQL
|
||||||
|
const updateSpentStmt = db.prepare(`
|
||||||
|
UPDATE utxos
|
||||||
|
SET is_spent_onchain = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE is_spent_onchain = 0
|
||||||
|
AND (txid || ':' || vout) NOT IN (
|
||||||
|
SELECT txid || ':' || vout FROM temp_available_utxos
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const updateResult = updateSpentStmt.run();
|
||||||
|
|
||||||
|
// Nettoyer la table temporaire
|
||||||
|
db.exec('DROP TABLE IF EXISTS temp_available_utxos');
|
||||||
|
|
||||||
|
logger.info('UTXOs dépensés mis à jour dans la base de données', {
|
||||||
|
updated: updateResult.changes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mettre à jour aussi les UTXOs en mémoire pour cohérence
|
||||||
for (const [utxoKey, existingUtxo] of existingUtxosMap.entries()) {
|
for (const [utxoKey, existingUtxo] of existingUtxosMap.entries()) {
|
||||||
if (!currentUtxosSet.has(utxoKey)) {
|
if (!currentUtxosSet.has(utxoKey)) {
|
||||||
// UTXO n'est plus dans listunspent, il a été dépensé
|
// UTXO n'est plus dans listunspent, il a été dépensé
|
||||||
|
|||||||
@ -243,11 +243,46 @@ app.get('/api/anchor/count', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route pour obtenir la liste des hash (depuis la base de données)
|
// Route pour obtenir la liste des hash (depuis la base de données) avec pagination
|
||||||
app.get('/api/hash/list', async (req, res) => {
|
app.get('/api/hash/list', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const hashList = await bitcoinRPC.getHashList();
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
res.json({ hashes: hashList, count: hashList.length });
|
const limit = parseInt(req.query.limit || '50', 10);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (page < 1 || limit < 1 || limit > 1000) {
|
||||||
|
return res.status(400).json({ error: 'Invalid pagination parameters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getDatabase } = await import('./database.js');
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Obtenir le total
|
||||||
|
const totalCount = db.prepare('SELECT COUNT(*) as count FROM anchors').get();
|
||||||
|
|
||||||
|
// Obtenir les hash paginés
|
||||||
|
const hashes = db.prepare(`
|
||||||
|
SELECT hash, txid, block_height, confirmations, date
|
||||||
|
FROM anchors
|
||||||
|
ORDER BY block_height ASC, id ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hashes: hashes.map(h => ({
|
||||||
|
hash: h.hash,
|
||||||
|
txid: h.txid,
|
||||||
|
blockHeight: h.block_height,
|
||||||
|
confirmations: h.confirmations || 0,
|
||||||
|
date: h.date,
|
||||||
|
})),
|
||||||
|
count: hashes.length,
|
||||||
|
total: totalCount?.count || 0,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil((totalCount?.count || 0) / limit),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting hash list', { error: error.message });
|
logger.error('Error getting hash list', { error: error.message });
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@ -305,25 +340,168 @@ app.get('/api/utxo/count', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route pour obtenir la liste des UTXO (depuis la base de données)
|
// Route pour obtenir la liste des UTXO (depuis la base de données) avec pagination
|
||||||
app.get('/api/utxo/list', async (req, res) => {
|
app.get('/api/utxo/list', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const utxoData = await bitcoinRPC.getUtxoList();
|
const category = req.query.category || 'all'; // 'all', 'bloc_rewards', 'ancrages', 'changes', 'fees'
|
||||||
res.json({
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
blocRewards: utxoData.blocRewards,
|
const limit = parseInt(req.query.limit || '50', 10);
|
||||||
anchors: utxoData.anchors,
|
const offset = (page - 1) * limit;
|
||||||
changes: utxoData.changes,
|
|
||||||
fees: utxoData.fees || [],
|
// Validation
|
||||||
|
if (page < 1 || limit < 1 || limit > 1000) {
|
||||||
|
return res.status(400).json({ error: 'Invalid pagination parameters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getDatabase } = await import('./database.js');
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Obtenir les counts totaux (sans pagination)
|
||||||
|
const minAnchorAmount = 2000 / 100000000;
|
||||||
|
const totalCounts = {
|
||||||
|
blocRewards: db.prepare("SELECT COUNT(*) as count FROM utxos WHERE category = 'bloc_rewards'").get()?.count || 0,
|
||||||
|
anchors: db.prepare("SELECT COUNT(*) as count FROM utxos WHERE category = 'ancrages' OR category = 'anchor'").get()?.count || 0,
|
||||||
|
changes: db.prepare("SELECT COUNT(*) as count FROM utxos WHERE category = 'changes' OR category = 'change'").get()?.count || 0,
|
||||||
|
fees: db.prepare('SELECT COUNT(*) as count FROM fees').get()?.count || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableForAnchor = db.prepare(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM utxos
|
||||||
|
WHERE (category = 'ancrages' OR category = 'anchor')
|
||||||
|
AND amount >= ?
|
||||||
|
AND confirmations > 0
|
||||||
|
AND is_spent_onchain = 0
|
||||||
|
AND is_locked_in_mutex = 0
|
||||||
|
`).get(minAnchorAmount)?.count || 0;
|
||||||
|
|
||||||
|
const confirmedAvailableForAnchor = db.prepare(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM utxos
|
||||||
|
WHERE (category = 'ancrages' OR category = 'anchor')
|
||||||
|
AND amount >= ?
|
||||||
|
AND confirmations >= 6
|
||||||
|
AND is_spent_onchain = 0
|
||||||
|
AND is_locked_in_mutex = 0
|
||||||
|
`).get(minAnchorAmount)?.count || 0;
|
||||||
|
|
||||||
|
// Obtenir les UTXOs paginés selon la catégorie
|
||||||
|
let utxos = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (category === 'all') {
|
||||||
|
// Si 'all', retourner les counts seulement (pas de données pour éviter de charger tout)
|
||||||
|
return res.json({
|
||||||
|
blocRewards: [],
|
||||||
|
anchors: [],
|
||||||
|
changes: [],
|
||||||
|
fees: [],
|
||||||
|
counts: {
|
||||||
|
blocRewards: totalCounts.blocRewards,
|
||||||
|
anchors: totalCounts.anchors,
|
||||||
|
changes: totalCounts.changes,
|
||||||
|
fees: totalCounts.fees,
|
||||||
|
total: totalCounts.blocRewards + totalCounts.anchors + totalCounts.changes + totalCounts.fees,
|
||||||
|
availableForAnchor,
|
||||||
|
confirmedAvailableForAnchor,
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
limit: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
message: 'Use ?category=bloc_rewards|ancrages|changes|fees&page=X&limit=Y to get paginated data',
|
||||||
|
});
|
||||||
|
} else if (category === 'bloc_rewards') {
|
||||||
|
total = totalCounts.blocRewards;
|
||||||
|
utxos = db.prepare(`
|
||||||
|
SELECT txid, vout, address, amount, confirmations, category,
|
||||||
|
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
|
||||||
|
FROM utxos
|
||||||
|
WHERE category = 'bloc_rewards'
|
||||||
|
ORDER BY amount DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
} else if (category === 'ancrages' || category === 'anchor') {
|
||||||
|
total = totalCounts.anchors;
|
||||||
|
utxos = db.prepare(`
|
||||||
|
SELECT txid, vout, address, amount, confirmations, category,
|
||||||
|
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
|
||||||
|
FROM utxos
|
||||||
|
WHERE category = 'ancrages' OR category = 'anchor'
|
||||||
|
ORDER BY amount DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
} else if (category === 'changes' || category === 'change') {
|
||||||
|
total = totalCounts.changes;
|
||||||
|
utxos = db.prepare(`
|
||||||
|
SELECT txid, vout, address, amount, confirmations, category,
|
||||||
|
is_spent_onchain, is_locked_in_mutex, block_time, is_anchor_change
|
||||||
|
FROM utxos
|
||||||
|
WHERE category = 'changes' OR category = 'change'
|
||||||
|
ORDER BY is_anchor_change DESC, amount DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
} else if (category === 'fees') {
|
||||||
|
total = totalCounts.fees;
|
||||||
|
utxos = db.prepare(`
|
||||||
|
SELECT txid, fee, fee_sats, block_height, block_time, confirmations,
|
||||||
|
change_address, change_amount
|
||||||
|
FROM fees
|
||||||
|
ORDER BY block_height DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset).map(fee => ({
|
||||||
|
txid: fee.txid,
|
||||||
|
fee: fee.fee,
|
||||||
|
fee_sats: fee.fee_sats,
|
||||||
|
blockHeight: fee.block_height,
|
||||||
|
blockTime: fee.block_time,
|
||||||
|
confirmations: fee.confirmations || 0,
|
||||||
|
changeAddress: fee.change_address,
|
||||||
|
changeAmount: fee.change_amount,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Invalid category. Use: bloc_rewards, ancrages, changes, fees, or all' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir les UTXOs au format attendu
|
||||||
|
const formattedUtxos = category === 'fees' ? utxos : utxos.map(utxo => ({
|
||||||
|
txid: utxo.txid,
|
||||||
|
vout: utxo.vout,
|
||||||
|
address: utxo.address || '',
|
||||||
|
amount: utxo.amount,
|
||||||
|
confirmations: utxo.confirmations || 0,
|
||||||
|
category: utxo.category,
|
||||||
|
isSpentOnchain: utxo.is_spent_onchain === 1,
|
||||||
|
isLockedInMutex: utxo.is_locked_in_mutex === 1,
|
||||||
|
blockHeight: null,
|
||||||
|
blockTime: utxo.block_time,
|
||||||
|
isAnchorChange: utxo.is_anchor_change === 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Organiser par catégorie pour compatibilité avec le frontend
|
||||||
|
const result = {
|
||||||
|
blocRewards: category === 'bloc_rewards' ? formattedUtxos : [],
|
||||||
|
anchors: (category === 'ancrages' || category === 'anchor') ? formattedUtxos : [],
|
||||||
|
changes: (category === 'changes' || category === 'change') ? formattedUtxos : [],
|
||||||
|
fees: category === 'fees' ? formattedUtxos : [],
|
||||||
counts: {
|
counts: {
|
||||||
blocRewards: utxoData.blocRewards.length,
|
blocRewards: totalCounts.blocRewards,
|
||||||
anchors: utxoData.anchors.length,
|
anchors: totalCounts.anchors,
|
||||||
changes: utxoData.changes.length,
|
changes: totalCounts.changes,
|
||||||
fees: (utxoData.fees || []).length,
|
fees: totalCounts.fees,
|
||||||
total: utxoData.total,
|
total: totalCounts.blocRewards + totalCounts.anchors + totalCounts.changes + totalCounts.fees,
|
||||||
availableForAnchor: utxoData.availableForAnchor || 0,
|
availableForAnchor,
|
||||||
confirmedAvailableForAnchor: utxoData.confirmedAvailableForAnchor || 0,
|
confirmedAvailableForAnchor,
|
||||||
},
|
},
|
||||||
});
|
pagination: {
|
||||||
|
category,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting UTXO list', { error: error.message });
|
logger.error('Error getting UTXO list', { error: error.message });
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { getSignatures } from './relay';
|
import { getSignatures } from './relay';
|
||||||
import { getStoredPairs } from './pairing';
|
import { getStoredPairs } from './pairing';
|
||||||
import { hasEnoughSignatures } from './loginValidation';
|
import { hasEnoughSignatures, collectProgress } from './loginValidation';
|
||||||
import type { LoginPath } from '../types/identity';
|
import type { LoginPath } from '../types/identity';
|
||||||
import type { MsgSignature } from '../types/message';
|
import type { MsgSignature } from '../types/message';
|
||||||
|
|
||||||
@ -136,8 +136,16 @@ function mergeDedupByPair(
|
|||||||
return Array.from(byPair.values());
|
return Array.from(byPair.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CollectLoopOpts {
|
||||||
|
pollMs: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
/** Called each poll with current merged sigs (notifications relais, UI progress). */
|
||||||
|
onProgress?: (merged: ProofSignature[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect signatures from relays until we have enough per member, or timeout.
|
* Collect signatures from relays until we have enough per member, or timeout.
|
||||||
|
* Optional onProgress called each poll for UI (e.g. X/Y signatures).
|
||||||
*/
|
*/
|
||||||
export async function runCollectLoop(
|
export async function runCollectLoop(
|
||||||
relayEndpoints: string[],
|
relayEndpoints: string[],
|
||||||
@ -146,11 +154,12 @@ export async function runCollectLoop(
|
|||||||
path: LoginPath,
|
path: LoginPath,
|
||||||
pairToMembers: Map<string, string[]>,
|
pairToMembers: Map<string, string[]>,
|
||||||
pubkeyToPair: Map<string, string>,
|
pubkeyToPair: Map<string, string>,
|
||||||
opts: { pollMs: number; timeoutMs: number },
|
opts: CollectLoopOpts,
|
||||||
): Promise<ProofSignature[]> {
|
): Promise<ProofSignature[]> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let merged = ourSigs;
|
let merged = ourSigs;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
opts.onProgress?.(merged);
|
||||||
if (hasEnoughSignatures(path, merged, pairToMembers)) {
|
if (hasEnoughSignatures(path, merged, pairToMembers)) {
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,15 +51,12 @@ export function requiredSigsPerMember(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check we have enough distinct pairs per member (for collecte distante).
|
* Build pairsPerMember (member -> set of pair_uuids) from signatures and pairToMembers.
|
||||||
* Count distinct pair_uuids per member via pairToMembers.
|
|
||||||
*/
|
*/
|
||||||
export function hasEnoughSignatures(
|
function pairsPerMemberFromSigs(
|
||||||
path: LoginPath,
|
|
||||||
signatures: Array<{ pair_uuid: string }>,
|
signatures: Array<{ pair_uuid: string }>,
|
||||||
pairToMembers: Map<string, string[]>,
|
pairToMembers: Map<string, string[]>,
|
||||||
): boolean {
|
): Map<string, Set<string>> {
|
||||||
const required = requiredSigsPerMember(path.signatures_requises);
|
|
||||||
const pairsPerMember = new Map<string, Set<string>>();
|
const pairsPerMember = new Map<string, Set<string>>();
|
||||||
for (const sig of signatures) {
|
for (const sig of signatures) {
|
||||||
const members = pairToMembers.get(sig.pair_uuid);
|
const members = pairToMembers.get(sig.pair_uuid);
|
||||||
@ -75,6 +72,20 @@ export function hasEnoughSignatures(
|
|||||||
set.add(sig.pair_uuid);
|
set.add(sig.pair_uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return pairsPerMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check we have enough distinct pairs per member (for collecte distante).
|
||||||
|
* Count distinct pair_uuids per member via pairToMembers.
|
||||||
|
*/
|
||||||
|
export function hasEnoughSignatures(
|
||||||
|
path: LoginPath,
|
||||||
|
signatures: Array<{ pair_uuid: string }>,
|
||||||
|
pairToMembers: Map<string, string[]>,
|
||||||
|
): boolean {
|
||||||
|
const required = requiredSigsPerMember(path.signatures_requises);
|
||||||
|
const pairsPerMember = pairsPerMemberFromSigs(signatures, pairToMembers);
|
||||||
for (const [member, need] of required) {
|
for (const [member, need] of required) {
|
||||||
const have = pairsPerMember.get(member)?.size ?? 0;
|
const have = pairsPerMember.get(member)?.size ?? 0;
|
||||||
if (have < need) {
|
if (have < need) {
|
||||||
@ -84,6 +95,27 @@ export function hasEnoughSignatures(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress of signature collect: satisfied vs required (for notifications/UI).
|
||||||
|
* satisfied = sum over members of min(have, need); required = sum of need.
|
||||||
|
*/
|
||||||
|
export function collectProgress(
|
||||||
|
path: LoginPath,
|
||||||
|
signatures: Array<{ pair_uuid: string }>,
|
||||||
|
pairToMembers: Map<string, string[]>,
|
||||||
|
): { satisfied: number; required: number } {
|
||||||
|
const required = requiredSigsPerMember(path.signatures_requises);
|
||||||
|
const pairsPerMember = pairsPerMemberFromSigs(signatures, pairToMembers);
|
||||||
|
let satisfied = 0;
|
||||||
|
let requiredTotal = 0;
|
||||||
|
for (const [member, need] of required) {
|
||||||
|
requiredTotal += need;
|
||||||
|
const have = pairsPerMember.get(member)?.size ?? 0;
|
||||||
|
satisfied += Math.min(have, need);
|
||||||
|
}
|
||||||
|
return { satisfied, required: requiredTotal };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if any signature comes from a non-local pair (2nd device).
|
* True if any signature comes from a non-local pair (2nd device).
|
||||||
* Used to require manual accept before validating when words may have been intercepted.
|
* Used to require manual accept before validating when words may have been intercepted.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user