Fix: double déclaration const now, scripts .mjs, /api/utxo/count accepte ancrages

**Motivations:**
- Corriger erreur syntaxe double déclaration const now dans bitcoin-rpc.js
- Scripts batch en .mjs (ES modules) sans dépendance dotenv
- /api/utxo/count doit accepter catégorie ancrages (pluriel) du fichier

**Root causes:**
- const now déclaré deux fois dans même portée (lignes 294 et 299)
- Scripts utilisent dotenv non installé globalement
- /api/utxo/count cherchait seulement 'anchor' mais fichier utilise 'ancrages'

**Correctifs:**
- Supprimer deuxième déclaration const now (ligne 299)
- Scripts .mjs : parser .env manuellement sans dotenv
- /api/utxo/count : accepter 'anchor' OU 'ancrages'

**Evolutions:**
- Aucune

**Pages affectées:**
- signet-dashboard/src/bitcoin-rpc.js
- signet-dashboard/src/server.js
- scripts/complete-utxo-list-blocktime.mjs
- scripts/diagnose-bloc-rewards.mjs
This commit is contained in:
ncantu 2026-01-26 02:06:10 +01:00
parent cad73cb265
commit 0db7a76044
16 changed files with 17587 additions and 17219 deletions

View File

@ -72,5 +72,4 @@ La déduplication par hash évite de relayer deux fois le même message.
## Stockage ## Stockage
Par défaut, le stockage est en mémoire avec sauvegarde optionnelle sur disque. Stockage en mémoire avec persistance sur disque (`{STORAGE_PATH}/messages.json`). Sont persistés : messages, seenHashes, signatures, clés. Sauvegarde à larrêt (SIGINT/SIGTERM) et périodiquement si `SAVE_INTERVAL_SECONDS` > 0. En production, une base de données (SQLite, PostgreSQL, etc.) est recommandée.
En production, utiliser une base de données (SQLite, PostgreSQL, etc.).

View File

@ -0,0 +1,44 @@
# API Relay Persistance signatures/clés et sauvegarde périodique
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
- Persister les signatures et les clés de déchiffrement sur disque (au même titre que les messages et seenHashes), afin quelles survivent au redémarrage du relais.
- Ajouter une sauvegarde périodique configurable pour limiter la perte de données entre deux arrêts propres.
## Impacts
- **Fonctionnels** : Les signatures et clés ne sont plus perdues au redémarrage. Réduction du risque de perte en cas de crash entre deux sauvegardes grâce à la sauvegarde périodique.
- **Techniques** : Extension du fichier `messages.json` (signatures, keys), nouveau timer de sauvegarde, variable denvironnement `SAVE_INTERVAL_SECONDS`. Gestion derreur explicite au chargement (ENOENT vs autres).
## Modifications
### api-relay
- **`src/services/storage.ts`**
- `loadFromDisk` : lecture de `messages.json`. Si `signatures` / `keys` présents, chargement dans les Maps. ENOENT = premier run (démarrage à vide) ; autres erreurs loggées et propagées. Parse JSON avec try/catch, log + rethrow.
- `saveToDisk` : écriture de `messages`, `seenHashes`, `signatures`, `keys` dans `messages.json`. Suppression du try/catch silencieux ; les erreurs remontent.
- `initialize` : plus de catch qui absorbe les erreurs de `loadFromDisk` ; propagation.
- **`src/index.ts`**
- `SAVE_INTERVAL_SECONDS` : env, défaut 300, 0 = désactivé.
- `setInterval` (si > 0) : appel à `storage.saveToDisk()` toutes les N secondes ; `.catch` logue les erreurs.
- Shutdown (SIGINT/SIGTERM) : `clearInterval`, puis `saveToDisk`, puis `process.exit(0)` ou `1` en cas derreur.
### Documentation
- **`userwallet/docs/storage.md`** : Synthèse, structure disque, Persistance, Configuration, Limitations (suppression des anciennes limitations « sigs/clés non persistées », « pas de sauvegarde périodique »).
- **`api-relay/README.md`** : Variable `SAVE_INTERVAL_SECONDS`, section Stockage mise à jour.
## Modalités de déploiement
- Redémarrer le serveur api-relay après déploiement.
- Les relais existants avec un `messages.json` sans `signatures` ni `keys` continuent de fonctionner ; les champs sont optionnels au chargement.
- Pour désactiver la sauvegarde périodique : `SAVE_INTERVAL_SECONDS=0`.
## Modalités danalyse
- Vérifier quaprès POST /signatures et POST /keys, un redémarrage du relais conserve les données (GET /signatures/:hash, GET /keys/:hash).
- Vérifier quen cas derreur disque au chargement (fichier corrompu, permissions), le processus logue et exit avec erreur (pas de démarrage à vide silencieux).
- Vérifier que la sauvegarde périodique écrit bien `messages.json` (timestamp de modification) et que les logs « Periodic save failed » apparaissent en cas déchec.

View File

@ -0,0 +1,45 @@
# UserWallet Export / Import des données
**Author:** Équipe 4NK
**Date:** 2026-01-26
## Objectif
Permettre lexport et limport de toutes les données UserWallet (identité, relais, pairs, cache de hash, keypair et services legacy) pour sauvegarde, migration ou restauration.
## Impacts
- **Fonctionnels** : Export en fichier JSON téléchargeable ; import depuis fichier qui remplace les données locales puis recharge la page.
- **Techniques** : Nouveau module `utils/exportImport.ts`, écran `DataExportImportScreen`, route `/data`, boutons sur laccueil.
## Modifications
### utils/exportImport.ts
- `exportUserWalletData(): string` : lit identity, relays, pairs, hash_cache, keypair, services depuis localStorage, produit un JSON versionné (`version`, `exportedAt`).
- `importUserWalletData(json: string): void` : parse, valide (version, exportedAt, relays, pairs, hash_cache, identity optionnel), écrit en localStorage. Lance `Error` si format invalide.
- Types : `UserWalletExport`, validateurs `isRelayConfig`, `isPairConfig`, `isLocalIdentity`.
### DataExportImportScreen
- Section Export : bouton « Exporter les données » → `exportUserWalletData` puis téléchargement `userwallet-export-{timestamp}.json`.
- Section Import : input file caché, bouton « Choisir un fichier à importer » → lecture du fichier, `importUserWalletData`, puis `window.location.reload`. Affichage des erreurs dimport.
### App & HomeScreen
- Route `/data``DataExportImportScreen`.
- Bouton « Export / Import données » sur laccueil (avec ou sans identité).
### Documentation
- `userwallet/docs/storage.md` : suppression de « Pas de backup / pas dexport-import » ; ajout section « Export / Import » et retrait de la recommandation correspondante.
## Modalités de déploiement
- Rebuild du frontend UserWallet et déploiement des assets. Aucune migration de données.
## Modalités danalyse
- Exporter les données, vérifier que le JSON contient identity, relays, pairs, hash_cache, etc.
- Importer un export valide : vérifier que les données sont bien présentes après rechargement.
- Importer un JSON invalide (mauvais format, champs manquants) : vérifier quune erreur est affichée et que les données locales ne sont pas modifiées.

File diff suppressed because it is too large Load Diff

View File

@ -7,21 +7,32 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const utxoListPath = join(__dirname, '../utxo_list.txt'); const utxoListPath = join(__dirname, '../utxo_list.txt');
// Charger les variables d'environnement // Charger les variables d'environnement depuis .env si disponible
import { config } from 'dotenv'; let envVars = {};
config({ path: join(__dirname, '../signet-dashboard/.env') }); try {
const envPath = join(__dirname, '../signet-dashboard/.env');
if (existsSync(envPath)) {
const envContent = readFileSync(envPath, 'utf8');
for (const line of envContent.split('\n')) {
const match = line.match(/^([^#=]+)=(.*)$/);
if (match) {
envVars[match[1].trim()] = match[2].trim();
}
}
}
} catch (error) {
console.warn('Could not load .env file, using defaults');
}
const BITCOIN_RPC_URL = process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332'; const BITCOIN_RPC_URL = envVars.BITCOIN_RPC_URL || process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
const BITCOIN_RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin'; const BITCOIN_RPC_USER = envVars.BITCOIN_RPC_USER || process.env.BITCOIN_RPC_USER || 'bitcoin';
const BITCOIN_RPC_PASSWORD = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin'; const BITCOIN_RPC_PASSWORD = envVars.BITCOIN_RPC_PASSWORD || process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
const BITCOIN_RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; const BITCOIN_RPC_WALLET = envVars.BITCOIN_RPC_WALLET || process.env.BITCOIN_RPC_WALLET || 'custom_signet';
if (!existsSync(utxoListPath)) { if (!existsSync(utxoListPath)) {
console.error('utxo_list.txt not found'); console.error('utxo_list.txt not found');

View File

@ -7,18 +7,31 @@
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { config } from 'dotenv';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
config({ path: join(__dirname, '../signet-dashboard/.env') }); // Charger les variables d'environnement depuis .env si disponible
let envVars = {};
try {
const envPath = join(__dirname, '../signet-dashboard/.env');
if (existsSync(envPath)) {
const envContent = readFileSync(envPath, 'utf8');
for (const line of envContent.split('\n')) {
const match = line.match(/^([^#=]+)=(.*)$/);
if (match) {
envVars[match[1].trim()] = match[2].trim();
}
}
}
} catch (error) {
console.warn('Could not load .env file, using defaults');
}
const BITCOIN_RPC_URL = process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332'; const BITCOIN_RPC_URL = envVars.BITCOIN_RPC_URL || process.env.BITCOIN_RPC_URL || 'http://127.0.0.1:38332';
const BITCOIN_RPC_USER = process.env.BITCOIN_RPC_USER || 'bitcoin'; const BITCOIN_RPC_USER = envVars.BITCOIN_RPC_USER || process.env.BITCOIN_RPC_USER || 'bitcoin';
const BITCOIN_RPC_PASSWORD = process.env.BITCOIN_RPC_PASSWORD || 'bitcoin'; const BITCOIN_RPC_PASSWORD = envVars.BITCOIN_RPC_PASSWORD || process.env.BITCOIN_RPC_PASSWORD || 'bitcoin';
const BITCOIN_RPC_WALLET = process.env.BITCOIN_RPC_WALLET || 'custom_signet'; const BITCOIN_RPC_WALLET = envVars.BITCOIN_RPC_WALLET || process.env.BITCOIN_RPC_WALLET || 'custom_signet';
const auth = Buffer.from(`${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}`).toString('base64'); const auth = Buffer.from(`${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}`).toString('base64');
const rpcUrl = `${BITCOIN_RPC_URL}/wallet/${BITCOIN_RPC_WALLET}`; const rpcUrl = `${BITCOIN_RPC_URL}/wallet/${BITCOIN_RPC_WALLET}`;

View File

@ -296,7 +296,6 @@ class BitcoinRPC {
writeFileSync(cachePath, cacheContent, 'utf8'); writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie avec date // Écrire le fichier de sortie avec date
const now = new Date().toISOString();
const outputLines = hashList.map((item) => const outputLines = hashList.map((item) =>
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}` `${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
); );
@ -314,7 +313,6 @@ class BitcoinRPC {
writeFileSync(cachePath, cacheContent, 'utf8'); writeFileSync(cachePath, cacheContent, 'utf8');
// Écrire le fichier de sortie final avec date // Écrire le fichier de sortie final avec date
const now = new Date().toISOString();
const outputLines = hashList.map((item) => const outputLines = hashList.map((item) =>
`${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}` `${item.hash};${item.txid};${item.blockHeight || ''};${item.confirmations || 0};${item.date || now}`
); );

View File

@ -306,7 +306,8 @@ app.get('/api/utxo/count', async (req, res) => {
const confirmations = parseInt(parts[4], 10) || 0; const confirmations = parseInt(parts[4], 10) || 0;
// Compter les UTXOs de type anchor avec les critères minimum // Compter les UTXOs de type anchor avec les critères minimum
if (category === 'anchor' && amount >= minAnchorAmount && confirmations > 0) { // Le fichier utilise 'ancrages' (pluriel), pas 'anchor'
if ((category === 'anchor' || category === 'ancrages') && amount >= minAnchorAmount && confirmations > 0) {
anchors++; anchors++;
// Note: On ne peut pas savoir depuis le fichier si l'UTXO est dépensé // Note: On ne peut pas savoir depuis le fichier si l'UTXO est dépensé
// On compte tous les UTXOs avec confirmations > 0 comme disponibles // On compte tous les UTXOs avec confirmations > 0 comme disponibles

View File

@ -161,11 +161,14 @@ Le front utilise **LocalStorage** du navigateur pour toutes les données locales
- **Taille limitée** : LocalStorage a une limite (~5-10MB selon navigateur) - **Taille limitée** : LocalStorage a une limite (~5-10MB selon navigateur)
- **Pas de synchronisation** : Données locales uniquement - **Pas de synchronisation** : Données locales uniquement
- **Sécurité** : Clés privées stockées en clair (à chiffrer avec mot de passe) - **Sécurité** : Clés privées stockées en clair (à chiffrer avec mot de passe)
- **Pas de backup** : Pas de mécanisme d'export/import automatique
### Export / Import
- **Export** : Écran « Export / Import données » (`/data`). Télécharge un JSON (identité, relais, pairs, hash_cache, keypair, services).
- **Import** : Même écran, bouton « Choisir un fichier ». Remplace les données locales puis recharge la page.
### Recommandations ### Recommandations
- Chiffrer les clés privées avec un mot de passe utilisateur - Chiffrer les clés privées avec un mot de passe utilisateur
- Implémenter un mécanisme d'export/import
- Utiliser IndexedDB pour des données plus volumineuses - Utiliser IndexedDB pour des données plus volumineuses
- Implémenter une synchronisation cloud optionnelle (chiffrée) - Implémenter une synchronisation cloud optionnelle (chiffrée)

View File

@ -59,7 +59,8 @@ api-relay/
1. **Stockage** 1. **Stockage**
- Stockage en mémoire des messages, signatures et clés - Stockage en mémoire des messages, signatures et clés
- Sauvegarde optionnelle sur disque (JSON) - Persistance sur disque (`messages.json`) : messages, seenHashes, signatures, clés
- Sauvegarde à larrêt et périodique (voir `SAVE_INTERVAL_SECONDS`)
- Déduplication par hash - Déduplication par hash
- Indexation par hash pour accès rapide - Indexation par hash pour accès rapide
@ -98,7 +99,7 @@ api-relay/
- Gestion du stockage en mémoire - Gestion du stockage en mémoire
- Déduplication par hash - Déduplication par hash
- Méthodes pour messages, signatures et clés - Méthodes pour messages, signatures et clés
- Sauvegarde/chargement depuis disque (optionnel) - Sauvegarde/chargement depuis disque (messages, seenHashes, signatures, clés) ; sauvegarde périodique configurable
#### RelayService #### RelayService
@ -128,6 +129,7 @@ Variables d'environnement :
- `HOST` : Adresse d'écoute (défaut: 0.0.0.0) - `HOST` : Adresse d'écoute (défaut: 0.0.0.0)
- `STORAGE_PATH` : Chemin de stockage (défaut: ./data) - `STORAGE_PATH` : Chemin de stockage (défaut: ./data)
- `PEER_RELAYS` : Liste de relais pairs séparés par virgule - `PEER_RELAYS` : Liste de relais pairs séparés par virgule
- `SAVE_INTERVAL_SECONDS` : Sauvegarde périodique en secondes (défaut: 300, 0 = désactivé)
Exemple : Exemple :

View File

@ -7,6 +7,7 @@ import { PairManagementScreen } from './components/PairManagementScreen';
import { SyncScreen } from './components/SyncScreen'; import { SyncScreen } from './components/SyncScreen';
import { LoginScreen } from './components/LoginScreen'; import { LoginScreen } from './components/LoginScreen';
import { ServiceListScreen } from './components/ServiceListScreen'; import { ServiceListScreen } from './components/ServiceListScreen';
import { DataExportImportScreen } from './components/DataExportImportScreen';
import { useChannel } from './hooks/useChannel'; import { useChannel } from './hooks/useChannel';
import './index.css'; import './index.css';
@ -23,6 +24,7 @@ function AppContent(): JSX.Element {
<Route path="/relay-settings" element={<RelaySettingsScreen />} /> <Route path="/relay-settings" element={<RelaySettingsScreen />} />
<Route path="/sync" element={<SyncScreen />} /> <Route path="/sync" element={<SyncScreen />} />
<Route path="/services" element={<ServiceListScreen />} /> <Route path="/services" element={<ServiceListScreen />} />
<Route path="/data" element={<DataExportImportScreen />} />
</Routes> </Routes>
); );
} }

View File

@ -0,0 +1,99 @@
import { useNavigate } from 'react-router-dom';
import { useRef, useState } from 'react';
import {
exportUserWalletData,
importUserWalletData,
} from '../utils/exportImport';
function downloadExport(json: string): void {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `userwallet-export-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
export function DataExportImportScreen(): JSX.Element {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const handleExport = (): void => {
const json = exportUserWalletData();
downloadExport(json);
};
const handleImportClick = (): void => {
setImportError(null);
fileInputRef.current?.click();
};
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>): void => {
const file = e.target.files?.[0];
e.target.value = '';
if (file === undefined) {
return;
}
const reader = new FileReader();
reader.onload = () => {
const text = reader.result;
if (typeof text !== 'string') {
setImportError('Fichier non lisible');
return;
}
try {
importUserWalletData(text);
window.location.reload();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Erreur import');
}
};
reader.readAsText(file);
};
return (
<main>
<h1>Export / Import des données</h1>
<section aria-labelledby="export-section">
<h2 id="export-section">Export</h2>
<p>
Télécharge un fichier JSON contenant l&apos;identité, les relais, les
pairs et le cache de hash.
</p>
<button type="button" onClick={handleExport}>
Exporter les données
</button>
</section>
<section aria-labelledby="import-section">
<h2 id="import-section">Import</h2>
<p>
Remplace toutes les données locales par le contenu du fichier. La
page sera rechargée après import.
</p>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
aria-hidden="true"
tabIndex={-1}
onChange={handleImportFile}
/>
<button type="button" onClick={handleImportClick}>
Choisir un fichier à importer
</button>
{importError !== null && (
<p role="alert" style={{ color: 'var(--color-error)' }}>
{importError}
</p>
)}
</section>
<p>
<button type="button" onClick={() => navigate('/')}>
Retour à l&apos;accueil
</button>
</p>
</main>
);
}

View File

@ -30,6 +30,9 @@ export function HomeScreen(): JSX.Element {
<button onClick={() => navigate('/import-identity')}> <button onClick={() => navigate('/import-identity')}>
Importer une identité Importer une identité
</button> </button>
<button onClick={() => navigate('/data')}>
Export / Import données
</button>
</div> </div>
</main> </main>
); );
@ -90,6 +93,9 @@ export function HomeScreen(): JSX.Element {
</button> </button>
<button onClick={() => navigate('/sync')}>Synchroniser maintenant</button> <button onClick={() => navigate('/sync')}>Synchroniser maintenant</button>
<button onClick={() => navigate('/services')}>Services disponibles</button> <button onClick={() => navigate('/services')}>Services disponibles</button>
<button onClick={() => navigate('/data')}>
Export / Import données
</button>
</div> </div>
</section> </section>
</main> </main>

View File

@ -0,0 +1,145 @@
import { getStoredIdentity } from './identity';
import { getStoredRelays } from './relay';
import { getStoredPairs } from './pairing';
import { getStoredKeyPair, getStoredServices } from './storage';
import type { LocalIdentity, RelayConfig, PairConfig } from '../types/identity';
import type { KeyPair } from './crypto';
import type { ServiceConfig } from '../types/auth';
const STORAGE_KEY_IDENTITY = 'userwallet_identity';
const STORAGE_KEY_RELAYS = 'userwallet_relays';
const STORAGE_KEY_PAIRS = 'userwallet_pairs';
const STORAGE_KEY_HASH_CACHE = 'userwallet_hash_cache';
const STORAGE_KEY_KEYPAIR = 'userwallet_keypair';
const STORAGE_KEY_SERVICES = 'userwallet_services';
export const EXPORT_VERSION = '1.0';
export interface UserWalletExport {
version: string;
exportedAt: number;
identity: LocalIdentity | null;
relays: RelayConfig[];
pairs: PairConfig[];
hash_cache: string[];
keypair?: KeyPair | null;
services?: ServiceConfig[];
}
/**
* Export all UserWallet data from localStorage as JSON string.
*/
export function exportUserWalletData(): string {
const identity = getStoredIdentity();
const relays = getStoredRelays();
const pairs = getStoredPairs();
const keypair = getStoredKeyPair();
const services = getStoredServices();
const hashCacheRaw = localStorage.getItem(STORAGE_KEY_HASH_CACHE);
const hash_cache: string[] =
hashCacheRaw !== null ? (JSON.parse(hashCacheRaw) as string[]) : [];
const data: UserWalletExport = {
version: EXPORT_VERSION,
exportedAt: Date.now(),
identity,
relays,
pairs,
hash_cache,
keypair: keypair ?? null,
services,
};
return JSON.stringify(data, null, 2);
}
function isRelayConfig(x: unknown): x is RelayConfig {
if (x === null || typeof x !== 'object') {
return false;
}
const o = x as Record<string, unknown>;
return (
typeof o.endpoint === 'string' &&
typeof o.priority === 'number' &&
typeof o.enabled === 'boolean'
);
}
function isPairConfig(x: unknown): x is PairConfig {
if (x === null || typeof x !== 'object') {
return false;
}
const o = x as Record<string, unknown>;
return (
typeof o.uuid === 'string' &&
Array.isArray(o.membres_parents_uuid) &&
typeof o.is_local === 'boolean' &&
typeof o.can_sign === 'boolean'
);
}
function isLocalIdentity(x: unknown): x is LocalIdentity {
if (x === null || typeof x !== 'object') {
return false;
}
const o = x as Record<string, unknown>;
return (
typeof o.uuid === 'string' &&
typeof o.privateKey === 'string' &&
typeof o.publicKey === 'string' &&
typeof o.t0_anniversaire === 'number' &&
typeof o.version === 'string'
);
}
/**
* Import UserWallet data from JSON string into localStorage.
* Overwrites existing data. Throws on invalid format.
*/
export function importUserWalletData(json: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(json) as unknown;
} catch (err) {
throw new Error('Invalid JSON');
}
if (parsed === null || typeof parsed !== 'object') {
throw new Error('Export must be an object');
}
const o = parsed as Record<string, unknown>;
if (typeof o.version !== 'string' || typeof o.exportedAt !== 'number') {
throw new Error('Missing or invalid version/exportedAt');
}
if (!Array.isArray(o.relays) || !o.relays.every(isRelayConfig)) {
throw new Error('Invalid relays');
}
if (!Array.isArray(o.pairs) || !o.pairs.every(isPairConfig)) {
throw new Error('Invalid pairs');
}
if (!Array.isArray(o.hash_cache) || !o.hash_cache.every((h) => typeof h === 'string')) {
throw new Error('Invalid hash_cache');
}
if (o.identity !== null && !isLocalIdentity(o.identity)) {
throw new Error('Invalid identity');
}
if (o.identity === null) {
localStorage.removeItem(STORAGE_KEY_IDENTITY);
} else {
localStorage.setItem(STORAGE_KEY_IDENTITY, JSON.stringify(o.identity));
}
localStorage.setItem(STORAGE_KEY_RELAYS, JSON.stringify(o.relays));
localStorage.setItem(STORAGE_KEY_PAIRS, JSON.stringify(o.pairs));
localStorage.setItem(STORAGE_KEY_HASH_CACHE, JSON.stringify(o.hash_cache));
if ('keypair' in o && o.keypair !== undefined) {
if (o.keypair === null) {
localStorage.removeItem(STORAGE_KEY_KEYPAIR);
} else {
localStorage.setItem(STORAGE_KEY_KEYPAIR, JSON.stringify(o.keypair));
}
}
if ('services' in o && o.services !== undefined && Array.isArray(o.services)) {
localStorage.setItem(STORAGE_KEY_SERVICES, JSON.stringify(o.services));
}
}

View File

@ -34840,7 +34840,7 @@ ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;5;0.00
ancrages;dc6b85db83f795087dcb16a7749c952048db9a7f0d40f9924f13ce794b7e5c36;7;0.000025;284;false; ancrages;dc6b85db83f795087dcb16a7749c952048db9a7f0d40f9924f13ce794b7e5c36;7;0.000025;284;false;
ancrages;d7fcaca3f983e4dc591d51515aa77ee85460c67991c6274dc00b6aeddd891f85;7;0.000025;530;false; ancrages;d7fcaca3f983e4dc591d51515aa77ee85460c67991c6274dc00b6aeddd891f85;7;0.000025;530;false;
ancrages;e7e6ed5f12757fcc21ebf47e022df260a7046922932bceb5998efd4919701562;0;0.000025;620;false; ancrages;e7e6ed5f12757fcc21ebf47e022df260a7046922932bceb5998efd4919701562;0;0.000025;620;false;
ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;7;0.000025;669;false; ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;7;0.000025;669;false;1769350182
ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;8;0.000025;669;false; ancrages;1b60296a88665c6775044246fa88c61324e10b9d9428725f8b29966a175d11c7;8;0.000025;669;false;
ancrages;cfb64e274caf85ad9ec3ab85cec1833182634cb630af1d4af917a32673a8f49a;2;0.000025;669;false; ancrages;cfb64e274caf85ad9ec3ab85cec1833182634cb630af1d4af917a32673a8f49a;2;0.000025;669;false;
ancrages;b54e4bb450da7820eefb76ef5b968e00979ce2960bee8b5e0191bf29937a4746;2;0.000025;669;false; ancrages;b54e4bb450da7820eefb76ef5b968e00979ce2960bee8b5e0191bf29937a4746;2;0.000025;669;false;