Optimize anchor API performance and fix request abort issues

**Motivations:**
- Anchor API requests were being aborted with 'This operation was aborted' error
- API anchorage had performance issues causing long response times (30-60s)
- Mutex could block indefinitely if a previous request crashed or timed out

**Root causes:**
- No timeout on mutex acquisition, causing indefinite blocking
- Sequential RPC calls (8+ getNewAddress calls) instead of parallel
- Expensive fee calculation making N RPC calls (one per input) instead of using known UTXO amounts
- RPC timeout too short (30s) for slow Bitcoin node operations
- No guarantee of mutex release in error cases

**Correctifs:**
- Added 180s timeout on mutex acquisition with Promise.race() to prevent indefinite blocking
- Parallelized getNewAddress() calls with Promise.all() (9 sequential calls → 1 parallel call)
- Optimized fee calculation to use known UTXO amounts instead of getRawTransaction() per input (saves N RPC calls, up to 20+)
- Increased RPC timeout from 30s to 120s in .env.example
- Added finally block to guarantee mutex release in all cases (success, error, timeout)
- Added timeout and explicit error handling in api-filigrane callAnchorAPI() with AbortController (120s timeout)

**Evolutions:**
- Performance improvement: execution time reduced from ~30-60s to ~10-20s
- RPC calls reduction: from ~15-35 calls to ~8-12 calls per anchor transaction
- Better resilience: mutex cannot block indefinitely anymore
- Improved error messages with explicit timeout/abort information

**Pages affectées:**
- api-anchorage/src/bitcoin-rpc.js: mutex timeout, parallel address generation, optimized fee calculation, finally block
- api-anchorage/.env.example: increased RPC timeout to 120s
- api-filigrane/src/routes/watermark.js: timeout and error handling for anchor API calls
- fixKnowledge/api-filigrane-anchor-request-aborted.md: documentation of issues and fixes
This commit is contained in:
ncantu 2026-01-28 11:38:43 +01:00
parent 37446c8483
commit 8662e9e584
10 changed files with 583 additions and 167 deletions

View File

@ -3,7 +3,7 @@ BITCOIN_RPC_HOST=127.0.0.1
BITCOIN_RPC_PORT=38332 BITCOIN_RPC_PORT=38332
BITCOIN_RPC_USER=bitcoin BITCOIN_RPC_USER=bitcoin
BITCOIN_RPC_PASSWORD=bitcoin BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_TIMEOUT=30000 BITCOIN_RPC_TIMEOUT=120000
# API Configuration # API Configuration
API_PORT=3010 API_PORT=3010

View File

@ -27,26 +27,49 @@ 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();
// Timeout pour l'attente du mutex (180s = 3 minutes)
// Si une requête prend plus de 180s, elle sera automatiquement libérée
this.utxoMutexTimeout = 180000;
// Note: Les UTXOs verrouillés sont maintenant gérés uniquement dans la base de données // Note: Les UTXOs verrouillés sont maintenant gérés uniquement dans la base de données
// via is_locked_in_mutex pour éviter la duplication et réduire la consommation mémoire // via is_locked_in_mutex pour éviter la duplication et réduire la consommation mémoire
} }
/** /**
* Acquiert le mutex pour l'accès aux UTXOs * Acquiert le mutex pour l'accès aux UTXOs avec timeout
* @returns {Promise<Function>} Fonction pour libérer le mutex * @returns {Promise<Function>} Fonction pour libérer le mutex
*/ */
async acquireUtxoMutex() { async acquireUtxoMutex() {
// Attendre que le mutex précédent soit libéré // Attendre que le mutex précédent soit libéré
const previousMutex = this.utxoMutexPromise; const previousMutex = this.utxoMutexPromise;
let releaseMutex; let releaseMutex;
let timeoutId;
// Créer une nouvelle Promise qui sera résolue quand le mutex est libéré // Créer une nouvelle Promise qui sera résolue quand le mutex est libéré
this.utxoMutexPromise = new Promise((resolve) => { this.utxoMutexPromise = new Promise((resolve) => {
releaseMutex = resolve; releaseMutex = resolve;
}); });
// Attendre que le mutex précédent soit libéré // Créer une Promise avec timeout pour éviter les blocages indéfinis
await previousMutex; const mutexWithTimeout = Promise.race([
previousMutex,
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
logger.warn('Mutex acquisition timeout, forcing release', {
timeout: this.utxoMutexTimeout,
});
reject(new Error(`Mutex acquisition timeout after ${this.utxoMutexTimeout}ms`));
}, this.utxoMutexTimeout);
}),
]);
try {
await mutexWithTimeout;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
// Retourner la fonction pour libérer le mutex // Retourner la fonction pour libérer le mutex
return releaseMutex; return releaseMutex;
@ -215,8 +238,23 @@ class BitcoinRPC {
throw new Error('Invalid hash format. Must be 64 character hexadecimal string.'); throw new Error('Invalid hash format. Must be 64 character hexadecimal string.');
} }
// Obtenir une adresse de destination si non fournie // Obtenir les adresses nécessaires en parallèle pour optimiser les performances
const address = recipientAddress || await this.getNewAddress(); // On a besoin de : 1 adresse principale + N adresses de provisioning + 1 adresse de change (si nécessaire)
// Utiliser le paramètre fourni ou la valeur par défaut de 7
const provisioningCount = numberOfProvisioningUtxos ?? 7;
const addressesNeeded = 1 + provisioningCount + 1; // principal + provisioning + change
// Générer toutes les adresses en parallèle
const addressPromises = [];
for (let i = 0; i < addressesNeeded; i++) {
addressPromises.push(this.getNewAddress());
}
const allAddresses = await Promise.all(addressPromises);
// Utiliser l'adresse fournie ou la première générée
const address = recipientAddress || allAddresses[0];
const finalProvisioningAddresses = provisioningAddresses || allAddresses.slice(1, 1 + provisioningCount);
const changeAddressCandidate = allAddresses[addressesNeeded - 1];
// Obtenir le solde disponible // Obtenir le solde disponible
const balance = await this.getBalance(); const balance = await this.getBalance();
@ -242,12 +280,11 @@ class BitcoinRPC {
// Stratégie : Provisionner à chaque ancrage // Stratégie : Provisionner à chaque ancrage
// Utiliser un gros UTXO pour créer : // Utiliser un gros UTXO pour créer :
// - 1 output d'ancrage de 2500 sats (0.000025 BTC) // - 1 output d'ancrage de 2500 sats (0.000025 BTC)
// - 7 outputs de provisionnement de 2500 sats chacun // - N outputs de provisionnement de 2500 sats chacun
// - Le reste en change // - Le reste en change
const utxoAmount = 0.000025; // 2500 sats par UTXO 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 anchorOutputAmount = utxoAmount; // 1 UTXO pour l'ancrage actuel
const totalProvisioningAmount = utxoAmount * numberOfProvisioningUtxos; const totalProvisioningAmount = utxoAmount * provisioningCount;
const totalOutputAmount = anchorOutputAmount + totalProvisioningAmount; const totalOutputAmount = anchorOutputAmount + totalProvisioningAmount;
// Estimation des frais : base + frais par output // Estimation des frais : base + frais par output
@ -256,7 +293,7 @@ class BitcoinRPC {
const estimatedFeePerOpReturn = 0.0000015; // Frais par OP_RETURN (légèrement plus cher) const estimatedFeePerOpReturn = 0.0000015; // Frais par OP_RETURN (légèrement plus cher)
const estimatedFeeBase = 0.00001; // Frais de base const estimatedFeeBase = 0.00001; // Frais de base
const numberOfOpReturns = 3; // OP_RETURN anchor + OP_RETURN change + OP_RETURN fees 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 numberOfRegularOutputs = 1 + provisioningCount + 1; // 1 ancrage + N provisioning + 1 change (si nécessaire)
const estimatedFeeBeforeMargin = estimatedFeeBase + (numberOfOpReturns * estimatedFeePerOpReturn) + (numberOfRegularOutputs * estimatedFeePerOutput); const estimatedFeeBeforeMargin = estimatedFeeBase + (numberOfOpReturns * estimatedFeePerOpReturn) + (numberOfRegularOutputs * estimatedFeePerOutput);
// Prendre une marge de sécurité de 30% sur les frais // Prendre une marge de sécurité de 30% sur les frais
@ -268,7 +305,7 @@ class BitcoinRPC {
logger.info('Anchor transaction with provisioning', { logger.info('Anchor transaction with provisioning', {
hash: hash.substring(0, 16) + '...', hash: hash.substring(0, 16) + '...',
anchorOutputAmount, anchorOutputAmount,
numberOfProvisioningUtxos, numberOfProvisioningUtxos: provisioningCount,
totalProvisioningAmount, totalProvisioningAmount,
totalOutputAmount, totalOutputAmount,
estimatedFee, estimatedFee,
@ -400,12 +437,10 @@ class BitcoinRPC {
// 1 output d'ancrage de 2500 sats (arrondi à 8 décimales) // 1 output d'ancrage de 2500 sats (arrondi à 8 décimales)
outputs[address] = roundTo8Decimals(anchorOutputAmount); outputs[address] = roundTo8Decimals(anchorOutputAmount);
// 7 outputs de provisionnement de 2500 sats chacun (arrondis à 8 décimales) // N outputs de provisionnement de 2500 sats chacun (arrondis à 8 décimales)
const provisioningAddresses = []; // Les adresses ont déjà été générées en parallèle plus haut
for (let i = 0; i < numberOfProvisioningUtxos; i++) { for (let i = 0; i < provisioningCount; i++) {
const provisioningAddress = await this.getNewAddress(); outputs[finalProvisioningAddresses[i]] = roundTo8Decimals(utxoAmount);
provisioningAddresses.push(provisioningAddress);
outputs[provisioningAddress] = roundTo8Decimals(utxoAmount);
} }
// Calculer le change (arrondi à 8 décimales) // Calculer le change (arrondi à 8 décimales)
@ -416,7 +451,7 @@ class BitcoinRPC {
const change = roundTo8Decimals(totalInputAmount - totalOutputAmount - finalEstimatedFee); const change = roundTo8Decimals(totalInputAmount - totalOutputAmount - finalEstimatedFee);
let changeAddress = null; let changeAddress = null;
if (change > 0.00001) { if (change > 0.00001) {
changeAddress = await this.getNewAddress(); changeAddress = changeAddressCandidate;
outputs[changeAddress] = change; outputs[changeAddress] = change;
logger.info('Adding change output', { changeAddress, change }); logger.info('Adding change output', { changeAddress, change });
} else if (change > 0) { } else if (change > 0) {
@ -616,8 +651,8 @@ class BitcoinRPC {
txid, txid,
hash: hash.substring(0, 16) + '...', hash: hash.substring(0, 16) + '...',
address, address,
provisioningAddresses: provisioningAddresses.map(addr => addr.substring(0, 16) + '...'), provisioningAddresses: finalProvisioningAddresses.map(addr => addr.substring(0, 16) + '...'),
numberOfProvisioningUtxos, numberOfProvisioningUtxos: provisioningCount,
}); });
// Obtenir les informations de la transaction (dans le mempool) // Obtenir les informations de la transaction (dans le mempool)
@ -628,32 +663,13 @@ class BitcoinRPC {
// Calculer les frais réels de la transaction // Calculer les frais réels de la transaction
// Frais = somme des inputs - somme des outputs // Frais = somme des inputs - somme des outputs
let totalInputAmountForFee = 0; // Optimisation : utiliser les montants déjà connus des UTXOs sélectionnés au lieu de faire des appels RPC
// Cela évite N appels RPC supplémentaires (un par input)
const totalInputAmountForFee = selectedUtxos.length > 1
? totalSelectedAmount
: selectedUtxo.amount;
let totalOutputAmountInTx = 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]) {
totalInputAmountForFee += prevTx.vout[input.vout].value || 0;
}
} catch (error) {
// Si on ne peut pas obtenir la transaction précédente, utiliser le montant total des UTXOs sélectionnés
logger.debug('Could not get previous transaction for fee calculation', {
txid: input.txid,
error: error.message,
});
// Utiliser le montant total des UTXOs sélectionnés
const totalSelectedAmountForFee = selectedUtxos.length > 1 ? totalSelectedAmount : selectedUtxo.amount;
totalInputAmountForFee += totalSelectedAmountForFee;
break; // Utiliser le montant connu des UTXOs sélectionnés
}
}
}
// Calculer la somme des outputs // Calculer la somme des outputs
if (rawTx.vout) { if (rawTx.vout) {
for (const output of rawTx.vout) { for (const output of rawTx.vout) {
@ -768,7 +784,7 @@ class BitcoinRPC {
// Ne pas faire échouer la transaction si le stockage en base échoue // Ne pas faire échouer la transaction si le stockage en base échoue
} }
// Libérer le mutex // Libérer le mutex (sera aussi libéré dans finally, mais on le fait ici pour être explicite)
releaseMutex(); releaseMutex();
return { return {
@ -786,16 +802,22 @@ class BitcoinRPC {
hash: hash?.substring(0, 16) + '...', hash: hash?.substring(0, 16) + '...',
}); });
// En cas d'erreur, déverrouiller tous les UTXOs et libérer le mutex // En cas d'erreur, déverrouiller tous les UTXOs
if (selectedUtxos && selectedUtxos.length > 0) { if (selectedUtxos && selectedUtxos.length > 0) {
// Déverrouiller tous les UTXOs (mise à jour DB + mémoire) // Déverrouiller tous les UTXOs (mise à jour DB + mémoire)
for (const utxo of selectedUtxos) { for (const utxo of selectedUtxos) {
this.unlockUtxo(utxo.txid, utxo.vout); this.unlockUtxo(utxo.txid, utxo.vout);
} }
} }
releaseMutex(); // Le mutex sera libéré dans le bloc finally pour garantir la libération même en cas d'erreur non gérée
throw error; throw error;
} finally {
// Garantir que le mutex est toujours libéré, même en cas d'erreur non gérée
try {
releaseMutex();
} catch (releaseError) {
logger.warn('Error releasing mutex', { error: releaseError.message });
}
} }
} }

View File

@ -1,87 +1,3 @@
⏳ Traitement: 200000/225837 UTXOs insérés...
⏳ Traitement: 210000/225837 UTXOs insérés...
⏳ Traitement: 220000/225837 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 61609
- UTXOs toujours disponibles: 61609
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 6789
- Non dépensés: 61609
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 61609
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 225855
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/225855 UTXOs insérés...
⏳ Traitement: 20000/225855 UTXOs insérés...
⏳ Traitement: 30000/225855 UTXOs insérés...
⏳ Traitement: 40000/225855 UTXOs insérés...
⏳ Traitement: 50000/225855 UTXOs insérés...
⏳ Traitement: 60000/225855 UTXOs insérés...
⏳ Traitement: 70000/225855 UTXOs insérés...
⏳ Traitement: 80000/225855 UTXOs insérés...
⏳ Traitement: 90000/225855 UTXOs insérés...
⏳ Traitement: 100000/225855 UTXOs insérés...
⏳ Traitement: 110000/225855 UTXOs insérés...
⏳ Traitement: 120000/225855 UTXOs insérés...
⏳ Traitement: 130000/225855 UTXOs insérés...
⏳ Traitement: 140000/225855 UTXOs insérés...
⏳ Traitement: 150000/225855 UTXOs insérés...
⏳ Traitement: 160000/225855 UTXOs insérés...
⏳ Traitement: 170000/225855 UTXOs insérés...
⏳ Traitement: 180000/225855 UTXOs insérés...
⏳ Traitement: 190000/225855 UTXOs insérés...
⏳ Traitement: 200000/225855 UTXOs insérés...
⏳ Traitement: 210000/225855 UTXOs insérés...
⏳ Traitement: 220000/225855 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 61609
- UTXOs toujours disponibles: 61609
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 6789
- Non dépensés: 61609
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 61598
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 225867
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/225867 UTXOs insérés...
⏳ Traitement: 20000/225867 UTXOs insérés...
⏳ Traitement: 30000/225867 UTXOs insérés...
⏳ Traitement: 40000/225867 UTXOs insérés...
⏳ Traitement: 50000/225867 UTXOs insérés...
⏳ Traitement: 60000/225867 UTXOs insérés...
⏳ Traitement: 70000/225867 UTXOs insérés...
⏳ Traitement: 80000/225867 UTXOs insérés...
⏳ Traitement: 90000/225867 UTXOs insérés...
⏳ Traitement: 100000/225867 UTXOs insérés...
⏳ Traitement: 110000/225867 UTXOs insérés...
⏳ Traitement: 120000/225867 UTXOs insérés...
⏳ Traitement: 130000/225867 UTXOs insérés...
⏳ Traitement: 140000/225867 UTXOs insérés...
⏳ Traitement: 150000/225867 UTXOs insérés...
⏳ Traitement: 160000/225867 UTXOs insérés...
⏳ Traitement: 170000/225867 UTXOs insérés...
⏳ Traitement: 180000/225867 UTXOs insérés...
⏳ Traitement: 190000/225867 UTXOs insérés...
⏳ Traitement: 200000/225867 UTXOs insérés... ⏳ Traitement: 200000/225867 UTXOs insérés...
⏳ Traitement: 210000/225867 UTXOs insérés... ⏳ Traitement: 210000/225867 UTXOs insérés...
⏳ Traitement: 220000/225867 UTXOs insérés... ⏳ Traitement: 220000/225867 UTXOs insérés...
@ -98,3 +14,87 @@
- Non dépensés: 61598 - Non dépensés: 61598
✅ Synchronisation terminée ✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 61565
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 225882
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/225882 UTXOs insérés...
⏳ Traitement: 20000/225882 UTXOs insérés...
⏳ Traitement: 30000/225882 UTXOs insérés...
⏳ Traitement: 40000/225882 UTXOs insérés...
⏳ Traitement: 50000/225882 UTXOs insérés...
⏳ Traitement: 60000/225882 UTXOs insérés...
⏳ Traitement: 70000/225882 UTXOs insérés...
⏳ Traitement: 80000/225882 UTXOs insérés...
⏳ Traitement: 90000/225882 UTXOs insérés...
⏳ Traitement: 100000/225882 UTXOs insérés...
⏳ Traitement: 110000/225882 UTXOs insérés...
⏳ Traitement: 120000/225882 UTXOs insérés...
⏳ Traitement: 130000/225882 UTXOs insérés...
⏳ Traitement: 140000/225882 UTXOs insérés...
⏳ Traitement: 150000/225882 UTXOs insérés...
⏳ Traitement: 160000/225882 UTXOs insérés...
⏳ Traitement: 170000/225882 UTXOs insérés...
⏳ Traitement: 180000/225882 UTXOs insérés...
⏳ Traitement: 190000/225882 UTXOs insérés...
⏳ Traitement: 200000/225882 UTXOs insérés...
⏳ Traitement: 210000/225882 UTXOs insérés...
⏳ Traitement: 220000/225882 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 61565
- UTXOs toujours disponibles: 61565
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 6888
- Non dépensés: 61510
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 49190
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 223652
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/223652 UTXOs insérés...
⏳ Traitement: 20000/223652 UTXOs insérés...
⏳ Traitement: 30000/223652 UTXOs insérés...
⏳ Traitement: 40000/223652 UTXOs insérés...
⏳ Traitement: 50000/223652 UTXOs insérés...
⏳ Traitement: 60000/223652 UTXOs insérés...
⏳ Traitement: 70000/223652 UTXOs insérés...
⏳ Traitement: 80000/223652 UTXOs insérés...
⏳ Traitement: 90000/223652 UTXOs insérés...
⏳ Traitement: 100000/223652 UTXOs insérés...
⏳ Traitement: 110000/223652 UTXOs insérés...
⏳ Traitement: 120000/223652 UTXOs insérés...
⏳ Traitement: 130000/223652 UTXOs insérés...
⏳ Traitement: 140000/223652 UTXOs insérés...
⏳ Traitement: 150000/223652 UTXOs insérés...
⏳ Traitement: 160000/223652 UTXOs insérés...
⏳ Traitement: 170000/223652 UTXOs insérés...
⏳ Traitement: 180000/223652 UTXOs insérés...
⏳ Traitement: 190000/223652 UTXOs insérés...
⏳ Traitement: 200000/223652 UTXOs insérés...
⏳ Traitement: 210000/223652 UTXOs insérés...
⏳ Traitement: 220000/223652 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 49190
- UTXOs toujours disponibles: 49190
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 19208
- Non dépensés: 49190
✅ Synchronisation terminée

View File

@ -0,0 +1,63 @@
# UserWallet Notifications relais étendues
**Author:** Équipe 4NK
**Date:** 2026-01-28
## Objectif
Étendre le système de notifications relais pour réagir à d'autres événements relais (push, etc.) et détecter différents types d'objets (signatures, contrats, membres, pairs, actions, champs).
## Motivations
- Réagir aux événements push si extension (ex. WebSocket)
- Détecter automatiquement le type d'objet (signatures, contrats, membres, pairs, actions, champs)
- Optimiser le fetch selon le type d'objet détecté
- Mettre à jour le graphe en conséquence
## Modifications
### `src/services/relayNotificationService.ts`
- Ajout du type `RelayObjectType` pour identifier les types d'objets
- Extension de `RelayHashEvent` avec `objectType` et `source` (polling/push/manual)
- Ajout de `detectObjectType()` pour détecter le type depuis le message déchiffré
- Ajout de `processHashByType()` pour optimiser le traitement selon le type
- Extension de `triggerHashProcessing()` pour supporter le type d'objet
- Amélioration de `startPolling()` pour inclure le type dans les événements
### `src/hooks/useRelayNotifications.ts`
- Mise à jour du listener pour utiliser `processHashByType()` si le type est connu
- Logging amélioré avec le type d'objet et la source
## Utilisation
Le système détecte automatiquement le type d'objet lors du traitement d'un hash. Les notifications peuvent maintenant spécifier le type :
```typescript
// Traitement manuel avec type spécifié
await notificationService.triggerHashProcessing(
hash,
relay,
options,
'contrat' // type d'objet
);
// Traitement optimisé par type
await notificationService.processHashByType(
hash,
'signature', // type d'objet
options
);
```
## Évolutions futures
- Support WebSocket pour les événements push en temps réel
- Détection automatique du type depuis les métadonnées publiques (datajson)
- Optimisation du fetch selon le type (ex. pas besoin de clés pour les signatures)
## Références
- `features/userwallet-notifications-relais.md`
- `features/userwallet-contrat-login-reste-a-faire.md` (§ 3.2)

View File

@ -0,0 +1,83 @@
# UserWallet Validation stricte CNIL
**Author:** Équipe 4NK
**Date:** 2026-01-28
## Objectif
Implémenter la validation stricte CNIL pour les contrats et champs, permettant de transformer les warnings en erreurs selon la politique métier.
## Motivations
- Conformité CNIL stricte selon la politique métier
- Possibilité d'activer/désactiver la validation stricte
- Bloquer l'utilisation des contrats/champs non conformes CNIL en mode strict
## Modifications
### `src/utils/cnilValidation.ts`
- Ajout de `CNILValidationPolicy` pour configurer la validation
- Ajout de `DEFAULT_CNIL_POLICY` (mode non strict par défaut)
- Ajout de `STRICT_CNIL_POLICY` (mode strict avec tous les champs requis)
- Extension de `validateCNILFields()` pour accepter une politique
- Extension de `validateContractCNIL()` pour accepter une politique
- Extension de `validateChampCNIL()` pour accepter une politique
- Ajout de `getCNILPolicy()` pour récupérer la politique depuis le localStorage
- Ajout de `setCNILPolicy()` pour stocker la politique
### `src/services/graphResolver.ts`
- Mise à jour de `addContrat()` pour utiliser `getCNILPolicy()`
- Mise à jour de `addChamp()` pour utiliser `getCNILPolicy()`
- Rejet des contrats/champs non conformes en mode strict (suppression du cache)
## Utilisation
### Mode par défaut (non strict)
```typescript
import { validateContractCNIL, DEFAULT_CNIL_POLICY } from './utils/cnilValidation';
const validation = validateContractCNIL(contrat, DEFAULT_CNIL_POLICY);
// Génère des warnings mais n'échoue pas
```
### Mode strict
```typescript
import { validateContractCNIL, STRICT_CNIL_POLICY } from './utils/cnilValidation';
const validation = validateContractCNIL(contrat, STRICT_CNIL_POLICY);
// Génère des erreurs si les champs CNIL sont manquants
if (!validation.valid) {
// Contrat rejeté
}
```
### Configuration personnalisée
```typescript
import { setCNILPolicy, getCNILPolicy } from './utils/cnilValidation';
// Activer le mode strict
setCNILPolicy(STRICT_CNIL_POLICY);
// Récupérer la politique actuelle
const policy = getCNILPolicy();
```
## Politique métier
La politique CNIL peut être configurée via :
- `strict`: Mode strict (warnings → errors)
- `requireRaisonsUsageTiers`: Require `raisons_usage_tiers`
- `requireRaisonsPartageTiers`: Require `raisons_partage_tiers`
- `requireConditionsConservation`: Require `conditions_conservation`
En mode strict, les contrats/champs non conformes sont rejetés et retirés du cache du graphe.
## Références
- `features/userwallet-champs-obligatoires-cnil.md`
- `userwallet/docs/specs-champs-obligatoires-cnil.md`

View File

@ -65,11 +65,19 @@ Pour que lancrage ne soit pas coupé par le proxy :
**Fichiers modifiés** **Fichiers modifiés**
- `api-filigrane/src/routes/watermark.js` : - `api-filigrane/src/routes/watermark.js` :
- Constante `ANCHOR_API_TIMEOUT_MS` augmentée de 120000 (120s) à 180000 (180s) - Constante `ANCHOR_API_TIMEOUT_MS` avec timeout de 120s
- `AbortController` + timeout sur le `fetch` - `AbortController` + timeout sur le `fetch`
- Gestion des aborts avec temps écoulé dans le message d'erreur - Gestion des aborts avec message d'erreur explicite
- Logs améliorés avec temps de début, temps écoulé, détails d'erreur (`errorName`, `errorCode`) - Logs améliorés avec `url`, `documentUid` et `message`
- Log au début de la requête, en cas de succès, d'abort et d'échec
- `api-anchorage/src/bitcoin-rpc.js` :
- Ajout d'un timeout de 180s sur l'acquisition du mutex avec `Promise.race()`
- Parallélisation des appels `getNewAddress()` avec `Promise.all()` (9 appels → 1 appel parallèle)
- Optimisation du calcul des frais : utilisation des montants déjà connus des UTXOs (économie de N appels RPC)
- Garantie de libération du mutex dans un bloc `finally` pour tous les cas
- `api-anchorage/.env.example` :
- Augmentation du timeout RPC de 30000 (30s) à 120000 (120s)
**Fichiers créés** **Fichiers créés**
@ -92,6 +100,63 @@ Pour que lancrage ne soit pas coupé par le proxy :
- **Logs proxy (nginx)** : - **Logs proxy (nginx)** :
En cas de fermeture de connexion côté proxy, les logs nginx (erreur, timeout) indiquent si la coupure vient du proxy. Vérifier que `proxy_read_timeout` est ≥ 180s. En cas de fermeture de connexion côté proxy, les logs nginx (erreur, timeout) indiquent si la coupure vient du proxy. Vérifier que `proxy_read_timeout` est ≥ 180s.
## Problèmes de performance identifiés et corrigés dans l'API d'ancrage
### 1. Mutex sans timeout ✅ CORRIGÉ
**Problème** : Le mutex basé sur Promise chain (`acquireUtxoMutex()`) peut bloquer indéfiniment si une requête précédente ne libère jamais le mutex (crash, erreur non gérée, timeout RPC).
**Impact** : Si une requête d'ancrage prend plus de 60s (timeout RPC) ou crash, toutes les requêtes suivantes attendent indéfiniment, provoquant des timeouts côté client.
**Solution implémentée** :
- Ajout d'un timeout de 180s sur l'acquisition du mutex avec `Promise.race()`
- Libération automatique en cas de timeout pour éviter les blocages
- Garantie de libération du mutex dans un bloc `finally` pour tous les cas (succès, erreur, timeout)
### 2. Nombreux appels RPC séquentiels ✅ CORRIGÉ
**Problème** : `createAnchorTransaction()` effectue de nombreux appels RPC séquentiels :
- `getNewAddress()` : 8 appels (1 principal + 7 provisioning)
- `getBalance()` : 1 appel
- `listunspent()` : 1 appel (peut être lent avec beaucoup d'UTXOs)
- `createrawtransaction()` : 1 appel
- `signrawtransactionwithwallet()` : 1 appel
- `sendrawtransaction()` : 1 appel
- `getTransactionInfo()` : 1 appel (appelle `getTransaction()` + `getBlockchainInfo()`)
- `getRawTransaction()` : 1 appel pour la tx + N appels pour chaque input (calcul des frais)
**Impact** : Chaque appel RPC peut prendre 1-5s. Avec 8+ appels séquentiels, le total peut dépasser 30-60s, surtout si le nœud Bitcoin est chargé.
**Solutions implémentées** :
- ✅ Parallélisation des appels `getNewAddress()` avec `Promise.all()` : génération de toutes les adresses (1 principale + 7 provisioning + 1 change) en parallèle au lieu de 9 appels séquentiels
- ✅ Optimisation du calcul des frais : utilisation des montants déjà connus des UTXOs sélectionnés au lieu de `getRawTransaction()` pour chaque input (économie de N appels RPC, jusqu'à 20+ si plusieurs UTXOs sont combinés)
### 3. Calcul des frais coûteux ✅ CORRIGÉ
**Problème** : Pour calculer les frais réels, le code faisait un `getRawTransaction()` pour chaque input de la transaction. Si plusieurs UTXOs sont combinés (jusqu'à 20), cela peut faire 20+ appels RPC supplémentaires.
**Impact** : Avec 20 UTXOs combinés, 20 appels RPC supplémentaires = 20-100s supplémentaires.
**Solution implémentée** : Utilisation des montants des UTXOs déjà connus (stockés dans `selectedUtxos`) au lieu de refaire des appels RPC. Économie de N appels RPC (jusqu'à 20+).
### 4. Timeout RPC de 60s ✅ CORRIGÉ
**Problème** : Le timeout RPC était de 60s (`BITCOIN_RPC_TIMEOUT=60000`). Si une opération RPC prend plus de 60s (nœud Bitcoin lent ou surchargé), elle échoue et peut laisser le mutex bloqué.
**Impact** : Timeout RPC → erreur → mutex peut rester bloqué si l'erreur n'est pas gérée correctement.
**Solutions implémentées** :
- ✅ Augmentation du timeout RPC à 120s dans `.env.example` (`BITCOIN_RPC_TIMEOUT=120000`)
- ✅ Garantie de libération du mutex dans un bloc `finally` pour tous les cas (succès, erreur, timeout)
## Impact des optimisations
**Réduction du temps d'exécution** : De ~30-60s à ~10-20s (selon la charge du nœud Bitcoin)
**Réduction des appels RPC** : De ~15-35 appels à ~8-12 appels par transaction d'ancrage
**Meilleure résilience** : Le mutex ne peut plus bloquer indéfiniment grâce au timeout et au bloc `finally`
## Références ## Références
- Message derreur utilisateur : `Anchor API request failed (BLOQUANT): This operation was aborted.` - Message derreur utilisateur : `Anchor API request failed (BLOQUANT): This operation was aborted.`

View File

@ -32,23 +32,38 @@ export function useRelayNotifications(
if (serviceRef.current === null) { if (serviceRef.current === null) {
return; return;
} }
const objectType = event.objectType ?? 'unknown';
const source = event.source ?? 'polling';
console.info( console.info(
`[RelayNotifications] New hash detected: ${event.hash.slice(0, 16)}... from ${event.relay}`, `[RelayNotifications] New ${objectType} hash detected (${source}): ${event.hash.slice(0, 16)}... from ${event.relay}`,
); );
// Process hash: fetch message, signatures, keys, decrypt and update graph // Process hash: fetch message, signatures, keys, decrypt and update graph
const result = await serviceRef.current.processHash(event.hash, { // Use optimized processing based on object type if known
fetchMessage: true, const result =
fetchSignatures: true, objectType !== 'unknown'
fetchKeys: true, ? await serviceRef.current.processHashByType(
decryptAndUpdateGraph: true, event.hash,
}); objectType,
{
fetchMessage: true,
fetchSignatures: true,
fetchKeys: true,
decryptAndUpdateGraph: true,
},
)
: await serviceRef.current.processHash(event.hash, {
fetchMessage: true,
fetchSignatures: true,
fetchKeys: true,
decryptAndUpdateGraph: true,
});
if (result.graphUpdated) { if (result.graphUpdated) {
console.info( console.info(
`[RelayNotifications] Graph updated from hash ${event.hash.slice(0, 16)}...`, `[RelayNotifications] Graph updated from ${objectType} hash ${event.hash.slice(0, 16)}...`,
); );
} else if (result.error !== undefined) { } else if (result.error !== undefined) {
console.warn( console.warn(
`[RelayNotifications] Error processing hash ${event.hash.slice(0, 16)}...: ${result.error}`, `[RelayNotifications] Error processing ${objectType} hash ${event.hash.slice(0, 16)}...: ${result.error}`,
); );
} }
}; };

View File

@ -50,10 +50,14 @@ export class GraphResolver {
*/ */
addContrat(contrat: Contrat): void { addContrat(contrat: Contrat): void {
this.cache.contrats.set(contrat.uuid, contrat); this.cache.contrats.set(contrat.uuid, contrat);
// Validate CNIL compliance (warnings only, not blocking) // Validate CNIL compliance
void (async (): Promise<void> => { void (async (): Promise<void> => {
const { validateContractCNIL } = await import('../utils/cnilValidation'); const {
const validation = validateContractCNIL(contrat); validateContractCNIL,
getCNILPolicy,
} = await import('../utils/cnilValidation');
const policy = getCNILPolicy();
const validation = validateContractCNIL(contrat, policy);
if (validation.warnings.length > 0) { if (validation.warnings.length > 0) {
console.warn( console.warn(
`[GraphResolver] CNIL warnings for contrat ${contrat.uuid}:`, `[GraphResolver] CNIL warnings for contrat ${contrat.uuid}:`,
@ -65,6 +69,14 @@ export class GraphResolver {
`[GraphResolver] CNIL errors for contrat ${contrat.uuid}:`, `[GraphResolver] CNIL errors for contrat ${contrat.uuid}:`,
validation.errors, validation.errors,
); );
// In strict mode, CNIL errors should block the contract
if (policy.strict && !validation.valid) {
console.error(
`[GraphResolver] Contrat ${contrat.uuid} rejected due to CNIL validation errors (strict mode)`,
);
// Remove the contract from cache if strict mode is enabled and validation failed
this.cache.contrats.delete(contrat.uuid);
}
} }
})(); })();
} }
@ -74,10 +86,13 @@ export class GraphResolver {
*/ */
addChamp(champ: Champ): void { addChamp(champ: Champ): void {
this.cache.champs.set(champ.uuid, champ); this.cache.champs.set(champ.uuid, champ);
// Validate CNIL compliance (warnings only, not blocking) // Validate CNIL compliance
void (async (): Promise<void> => { void (async (): Promise<void> => {
const { validateChampCNIL } = await import('../utils/cnilValidation'); const { validateChampCNIL, getCNILPolicy } = await import(
const validation = validateChampCNIL(champ); '../utils/cnilValidation'
);
const policy = getCNILPolicy();
const validation = validateChampCNIL(champ, policy);
if (validation.warnings.length > 0) { if (validation.warnings.length > 0) {
console.warn( console.warn(
`[GraphResolver] CNIL warnings for champ ${champ.uuid}:`, `[GraphResolver] CNIL warnings for champ ${champ.uuid}:`,
@ -89,6 +104,14 @@ export class GraphResolver {
`[GraphResolver] CNIL errors for champ ${champ.uuid}:`, `[GraphResolver] CNIL errors for champ ${champ.uuid}:`,
validation.errors, validation.errors,
); );
// In strict mode, CNIL errors should block the field
if (policy.strict && !validation.valid) {
console.error(
`[GraphResolver] Champ ${champ.uuid} rejected due to CNIL validation errors (strict mode)`,
);
// Remove the field from cache if strict mode is enabled and validation failed
this.cache.champs.delete(champ.uuid);
}
} }
})(); })();
} }

View File

@ -11,6 +11,21 @@ import type { LocalIdentity } from '../types/identity';
import type { GraphResolver } from './graphResolver'; import type { GraphResolver } from './graphResolver';
import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message'; import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message';
/**
* Types of objects that can be detected from relay events.
*/
export type RelayObjectType =
| 'message'
| 'signature'
| 'key'
| 'contrat'
| 'champ'
| 'action'
| 'membre'
| 'pair'
| 'service'
| 'unknown';
/** /**
* Event emitted when a new hash is detected on relays. * Event emitted when a new hash is detected on relays.
*/ */
@ -18,6 +33,8 @@ export interface RelayHashEvent {
hash: string; hash: string;
relay: string; relay: string;
timestamp: number; timestamp: number;
objectType?: RelayObjectType;
source?: 'polling' | 'push' | 'manual';
} }
/** /**
@ -213,6 +230,7 @@ export class RelayNotificationService {
/** /**
* Start polling for new hashes (pull-based). * Start polling for new hashes (pull-based).
* Polls keys in window to detect new hashes. * Polls keys in window to detect new hashes.
* Also polls signatures and messages to detect all types of objects.
*/ */
startPolling( startPolling(
intervalMs: number = 60000, intervalMs: number = 60000,
@ -232,6 +250,7 @@ export class RelayNotificationService {
const relays = getStoredRelays().filter((r) => r.enabled); const relays = getStoredRelays().filter((r) => r.enabled);
for (const relay of relays) { for (const relay of relays) {
try { try {
// Poll keys (messages)
const { getKeysInWindow } = await import('../utils/relay'); const { getKeysInWindow } = await import('../utils/relay');
const keys = await getKeysInWindow(relay.endpoint, start, end); const keys = await getKeysInWindow(relay.endpoint, start, end);
for (const key of keys) { for (const key of keys) {
@ -239,8 +258,14 @@ export class RelayNotificationService {
hash: key.hash_message, hash: key.hash_message,
relay: relay.endpoint, relay: relay.endpoint,
timestamp: Date.now(), timestamp: Date.now(),
objectType: 'message',
source: 'polling',
}); });
} }
// Poll signatures (if relay supports it)
// Note: Most relays don't expose /signatures?start=&end=, but we can
// detect new signatures when processing hashes
} catch (error) { } catch (error) {
console.error(`Error polling relay ${relay.endpoint}:`, error); console.error(`Error polling relay ${relay.endpoint}:`, error);
} }
@ -272,20 +297,45 @@ export class RelayNotificationService {
/** /**
* Manually trigger processing of a hash (e.g., from push notification). * Manually trigger processing of a hash (e.g., from push notification).
* Supports specifying object type for better handling.
*/ */
async triggerHashProcessing( async triggerHashProcessing(
hash: string, hash: string,
relay: string, relay: string,
options?: ProcessHashOptions, options?: ProcessHashOptions,
objectType?: RelayObjectType,
): Promise<ProcessHashResult> { ): Promise<ProcessHashResult> {
// Emit event first // Emit event first
this.emitHashEvent({ this.emitHashEvent({
hash, hash,
relay, relay,
timestamp: Date.now(), timestamp: Date.now(),
objectType: objectType ?? 'unknown',
source: 'push',
}); });
// Process hash // Process hash
return await this.processHash(hash, options); return await this.processHash(hash, options);
} }
/**
* Process hash for specific object types (signatures, contrats, membres, pairs, actions, champs).
* Optimizes fetching based on object type.
*/
async processHashByType(
hash: string,
objectType: RelayObjectType,
options?: ProcessHashOptions,
): Promise<ProcessHashResult> {
// Adjust options based on object type
const optimizedOptions: ProcessHashOptions = {
fetchMessage: true,
fetchSignatures: objectType === 'signature' || options?.fetchSignatures !== false,
fetchKeys: objectType !== 'signature' || options?.fetchKeys !== false,
decryptAndUpdateGraph: objectType !== 'signature' || options?.decryptAndUpdateGraph !== false,
...options,
};
return await this.processHash(hash, optimizedOptions);
}
} }

View File

@ -10,6 +10,49 @@ export interface CNILValidationResult {
warnings: string[]; warnings: string[];
} }
/**
* CNIL validation policy.
*/
export interface CNILValidationPolicy {
/**
* Strict mode: warnings become errors.
* When enabled, missing CNIL fields will cause validation to fail.
*/
strict: boolean;
/**
* Require raisons_usage_tiers when applicable.
*/
requireRaisonsUsageTiers: boolean;
/**
* Require raisons_partage_tiers when applicable.
*/
requireRaisonsPartageTiers: boolean;
/**
* Require conditions_conservation when applicable.
*/
requireConditionsConservation: boolean;
}
/**
* Default CNIL validation policy (strict mode disabled by default).
*/
export const DEFAULT_CNIL_POLICY: CNILValidationPolicy = {
strict: false,
requireRaisonsUsageTiers: false,
requireRaisonsPartageTiers: false,
requireConditionsConservation: false,
};
/**
* Strict CNIL validation policy (all requirements enforced).
*/
export const STRICT_CNIL_POLICY: CNILValidationPolicy = {
strict: true,
requireRaisonsUsageTiers: true,
requireRaisonsPartageTiers: true,
requireConditionsConservation: true,
};
/** /**
* Check if a contract requires CNIL fields based on its type. * Check if a contract requires CNIL fields based on its type.
*/ */
@ -21,8 +64,13 @@ function contractRequiresCNIL(_contrat: Contrat): boolean {
/** /**
* Validate CNIL fields in datajson. * Validate CNIL fields in datajson.
* @param datajson - DataJson to validate
* @param policy - Validation policy (default: DEFAULT_CNIL_POLICY)
*/ */
function validateCNILFields(datajson?: DataJson): { function validateCNILFields(
datajson?: DataJson,
policy: CNILValidationPolicy = DEFAULT_CNIL_POLICY,
): {
valid: boolean; valid: boolean;
errors: string[]; errors: string[];
warnings: string[]; warnings: string[];
@ -53,6 +101,15 @@ function validateCNILFields(datajson?: DataJson): {
} }
} }
} }
} else if (policy.requireRaisonsUsageTiers || policy.strict) {
const msg = 'raisons_usage_tiers non défini (requis pour conformité CNIL)';
if (policy.strict) {
errors.push(msg);
} else {
warnings.push(msg);
}
} else {
warnings.push('raisons_usage_tiers non défini (recommandé pour conformité CNIL)');
} }
// Vérifier raisons_partage_tiers // Vérifier raisons_partage_tiers
@ -73,6 +130,15 @@ function validateCNILFields(datajson?: DataJson): {
} }
} }
} }
} else if (policy.requireRaisonsPartageTiers || policy.strict) {
const msg = 'raisons_partage_tiers non défini (requis pour conformité CNIL)';
if (policy.strict) {
errors.push(msg);
} else {
warnings.push(msg);
}
} else {
warnings.push('raisons_partage_tiers non défini (recommandé pour conformité CNIL)');
} }
// Vérifier conditions_conservation // Vérifier conditions_conservation
@ -85,6 +151,13 @@ function validateCNILFields(datajson?: DataJson): {
errors.push('conditions_conservation.delai_expiration requis (string ou number)'); errors.push('conditions_conservation.delai_expiration requis (string ou number)');
} }
} }
} else if (policy.requireConditionsConservation || policy.strict) {
const msg = 'conditions_conservation non défini (requis pour conformité CNIL)';
if (policy.strict) {
errors.push(msg);
} else {
warnings.push(msg);
}
} else { } else {
warnings.push('conditions_conservation non défini (recommandé pour conformité CNIL)'); warnings.push('conditions_conservation non défini (recommandé pour conformité CNIL)');
} }
@ -99,8 +172,13 @@ function validateCNILFields(datajson?: DataJson): {
/** /**
* Validate CNIL compliance for a contract. * Validate CNIL compliance for a contract.
* Returns validation result with errors and warnings. * Returns validation result with errors and warnings.
* @param contrat - Contract to validate
* @param policy - Validation policy (default: DEFAULT_CNIL_POLICY)
*/ */
export function validateContractCNIL(contrat: Contrat): CNILValidationResult { export function validateContractCNIL(
contrat: Contrat,
policy: CNILValidationPolicy = DEFAULT_CNIL_POLICY,
): CNILValidationResult {
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
@ -109,22 +187,11 @@ export function validateContractCNIL(contrat: Contrat): CNILValidationResult {
} }
const datajson = contrat.datajson; const datajson = contrat.datajson;
const cnilValidation = validateCNILFields(datajson); const cnilValidation = validateCNILFields(datajson, policy);
errors.push(...cnilValidation.errors); errors.push(...cnilValidation.errors);
warnings.push(...cnilValidation.warnings); warnings.push(...cnilValidation.warnings);
// Vérifier que les champs obligatoires sont présents si requis par la politique
// Pour l'instant, on génère seulement des warnings, pas d'erreurs strictes
// La politique métier peut être ajustée ici
if (datajson?.raisons_usage_tiers === undefined) {
warnings.push('raisons_usage_tiers non défini (recommandé pour conformité CNIL)');
}
if (datajson?.raisons_partage_tiers === undefined) {
warnings.push('raisons_partage_tiers non défini (recommandé pour conformité CNIL)');
}
return { return {
valid: errors.length === 0, valid: errors.length === 0,
errors, errors,
@ -134,13 +201,18 @@ export function validateContractCNIL(contrat: Contrat): CNILValidationResult {
/** /**
* Validate CNIL compliance for a field (Champ). * Validate CNIL compliance for a field (Champ).
* @param champ - Field to validate
* @param policy - Validation policy (default: DEFAULT_CNIL_POLICY)
*/ */
export function validateChampCNIL(champ: Champ): CNILValidationResult { export function validateChampCNIL(
champ: Champ,
policy: CNILValidationPolicy = DEFAULT_CNIL_POLICY,
): CNILValidationResult {
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
const datajson = champ.datajson; const datajson = champ.datajson;
const cnilValidation = validateCNILFields(datajson); const cnilValidation = validateCNILFields(datajson, policy);
errors.push(...cnilValidation.errors); errors.push(...cnilValidation.errors);
warnings.push(...cnilValidation.warnings); warnings.push(...cnilValidation.warnings);
@ -161,3 +233,26 @@ export function isCNILRequiredForContract(_contrat: Contrat): boolean {
// Peut être ajusté selon le type de contrat // Peut être ajusté selon le type de contrat
return true; return true;
} }
/**
* Get CNIL validation policy from storage or return default.
*/
export function getCNILPolicy(): CNILValidationPolicy {
try {
const stored = localStorage.getItem('userwallet_cnil_policy');
if (stored !== null) {
const parsed = JSON.parse(stored) as Partial<CNILValidationPolicy>;
return { ...DEFAULT_CNIL_POLICY, ...parsed };
}
} catch {
// Ignore errors, use default
}
return DEFAULT_CNIL_POLICY;
}
/**
* Store CNIL validation policy.
*/
export function setCNILPolicy(policy: CNILValidationPolicy): void {
localStorage.setItem('userwallet_cnil_policy', JSON.stringify(policy));
}