diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs index 5d8c6e0..64c1500 100644 --- a/src/App/ViewModels/MainWindowViewModel.Sync.cs +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Storage; @@ -115,6 +117,30 @@ public partial class MainWindowViewModel return (host, port); } + /// + /// Restituisce i server da provare in ordine: prima quello selezionato nella UI + /// (o digitato manualmente), poi gli altri in KnownServers, evitando duplicati. + /// + private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + // 1. Server corrente (selezionato o digitato manualmente). + if (!string.IsNullOrWhiteSpace(selectedHost)) + { + var key = $"{selectedHost}:{selectedPort}"; + if (seen.Add(key)) + yield return (selectedHost, selectedPort); + } + // 2. Altri server noti, in ordine di lista. + foreach (var s in KnownServers) + { + var p = s.PortFor(UseSsl); + var key = $"{s.Host}:{p}"; + if (seen.Add(key)) + yield return (s.Host, p); + } + } + [RelayCommand] private async Task ConnectAndSync() { @@ -137,18 +163,50 @@ public partial class MainWindowViewModel if (_client is null || !_client.IsConnected) { - ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…"; - var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); - _client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins); - _client.NotificationReceived += OnServerNotification; - _client.Disconnected += _ => Dispatcher.UIThread.Post(() => + // Salva le cache prima di distruggere il synchronizer: il nuovo + // synchronizer userà un client diverso e deve essere ricreato, + // ma i dati già scaricati vengono preservati via _doc.Cache. + PersistPartialTxCache(); + _synchronizer = null; + + // Prova tutti i server noti in ordine; parte da quello selezionato + // e scorre la lista fino al primo che risponde. + var candidates = BuildServerCandidates(host, port); + Exception? lastError = null; + foreach (var (h, p) in candidates) { - IsConnected = false; - ConnectionStatus = Loc.Tr("conn.none"); - }); - IsConnected = true; - _autoReconnect = true; - ConnectionStatus = Loc.Tr("conn.connectedto"); + ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…"; + try + { + var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); + _client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins); + _client.NotificationReceived += OnServerNotification; + _client.Disconnected += _ => Dispatcher.UIThread.Post(() => + { + IsConnected = false; + ConnectionStatus = Loc.Tr("conn.none"); + }); + IsConnected = true; + _autoReconnect = true; + ConnectionStatus = Loc.Tr("conn.connectedto"); + // Aggiorna la UI per riflettere il server effettivamente connesso. + _syncingServerFields = true; + ServerHost = h; + ServerPort = p.ToString(); + SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == h) + ?? SelectedKnownServer; + _syncingServerFields = false; + lastError = null; + break; + } + catch (Exception ex) + { + lastError = ex; + _client = null; + } + } + if (lastError is not null) + throw lastError; } if (_account is null || _doc is null) @@ -156,7 +214,14 @@ public partial class MainWindowViewModel if (_synchronizer is null) { - _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); + _synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit); + // Ricarica dalla cache su disco: evita di riscaricale le tx già note + // (fondamentale per wallet con migliaia di tx storiche — previene -101). + var net = PalladiumNetworks.For(_account.Profile.Kind); + _synchronizer.PreloadCaches( + _doc.Cache?.RawTxHex ?? [], + _doc.Cache?.VerifiedAt ?? [], + net); _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); } @@ -166,6 +231,8 @@ public partial class MainWindowViewModel var result = await _synchronizer.SyncOnceAsync(); _lastTransactions = result.Transactions; + var (rawHex, verifiedAt) = _synchronizer.ExportCaches( + PalladiumNetworks.For(_account.Profile.Kind)); _doc.Cache = new SyncCache { TipHeight = result.TipHeight, @@ -176,9 +243,12 @@ public partial class MainWindowViewModel History = [.. result.History], Utxos = [.. result.Utxos], Addresses = [.. result.AddressRows], + RawTxHex = rawHex, + VerifiedAt = verifiedAt, }; WalletStore.Save(_doc, _walletPath!, _password); ApplyCache(_doc.Cache); + _syncFailed = false; StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + $"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; } while (_resyncRequested); @@ -194,6 +264,13 @@ public partial class MainWindowViewModel IsConnected = _client?.IsConnected == true; ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none"); StatusMessage = $"Errore: {ex.Message}"; + if (_account is not null) + { + _syncFailed = true; + // Salva le tx già scaricate: al retry il synchronizer riparte + // da qui invece di ricominciare da zero (es. dopo -101). + PersistPartialTxCache(); + } } finally { @@ -245,6 +322,28 @@ public partial class MainWindowViewModel StatusMessage = Loc.Tr("msg.certreset"); } + /// + /// Salva nel SyncCache le tx e le prove di Merkle già accumulate dal synchronizer, + /// anche se la sync non è ancora completa. Consente al retry successivo + /// (o al riavvio dell'app) di riprendere senza riscaricale da zero. + /// + private void PersistPartialTxCache() + { + if (_synchronizer is null || _doc is null || _walletPath is null || _account is null) + return; + try + { + var net = PalladiumNetworks.For(_account.Profile.Kind); + var (rawHex, verifiedAt) = _synchronizer.ExportCaches(net); + if (rawHex.Count == 0 && verifiedAt.Count == 0) + return; + (_doc.Cache ??= new SyncCache()).RawTxHex = rawHex; + _doc.Cache.VerifiedAt = verifiedAt; + WalletStore.Save(_doc, _walletPath, _password); + } + catch { /* non fatale: il prossimo salvataggio completo recupererà */ } + } + private async Task DisconnectAsync() { if (_client is { } client) diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index fb70016..8acd27c 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Threading; using NBitcoin; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; @@ -35,11 +37,15 @@ public sealed class SyncResult /// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo; /// estende la scansione fino al gap limit (§5). /// -public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20) +public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20) { /// Avanzamento leggibile (per CLI e barra di stato GUI). public event Action? Progress; + // Richieste contemporanee verso il server. Troppo alte → -102 "server busy"; + // troppo basse → throughput scarso su storie grandi. + private const int MaxConcurrent = 20; + // Cache tra le passate (stesso synchronizer per tutta la vita della // connessione): le tx già scaricate e le prove di Merkle già verificate a // una data altezza non si rifanno — le risincronizzazioni da notifica @@ -47,56 +53,150 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, private readonly Dictionary _txCache = []; private readonly Dictionary _verifiedAtHeight = []; + // Header grezzi per altezza: una Task per altezza, condivisa tra + // tutte le tx dello stesso blocco → ogni blocco viene scaricato una sola + // volta anche con centinaia di tx confermate nello stesso blocco. + private readonly ConcurrentDictionary> _headerFetches = new(); + + /// + /// Pre-popola le cache interne da dati salvati su disco (SyncCache). + /// Chiamare prima di SyncOnceAsync per evitare di riscaricale le tx già note. + /// + public void PreloadCaches(Dictionary rawTxHex, + Dictionary verifiedAt, Network network) + { + foreach (var (txid, hex) in rawTxHex) + if (!_txCache.ContainsKey(txid)) + _txCache[txid] = Transaction.Parse(hex, network); + foreach (var (txid, height) in verifiedAt) + if (!_verifiedAtHeight.ContainsKey(txid)) + _verifiedAtHeight[txid] = height; + } + + /// + /// Esporta le cache correnti in forma serializzabile su disco. + /// Solo le tx confermate (height > 0) vengono incluse: le non confermate + /// possono cambiare (RBF) e vanno sempre riscaricate. + /// + public (Dictionary RawTxHex, Dictionary VerifiedAt) + ExportCaches(Network network) + { + // Includi solo le tx associate a una prova di Merkle verificata + // (cioè confermate e verificate): sono le uniche immutabili. + var rawHex = _verifiedAtHeight.Keys + .Where(_txCache.ContainsKey) + .ToDictionary(txid => txid, txid => _txCache[txid].ToHex()); + return (rawHex, new Dictionary(_verifiedAtHeight)); + } + public async Task SyncOnceAsync(CancellationToken ct = default) { var tip = await client.SubscribeHeadersAsync(ct); Progress?.Invoke($"tip della catena: {tip.Height}"); - // 1-2. Scansione indirizzi con gap limit, per catena receiving e change. + // 1-2. Scansione indirizzi. var tracked = new List(); var historyByAddress = new Dictionary>(); - var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct); - var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct); + int nextReceive, nextChange; + + if (account.FixedAddresses is { } fixedAddresses) + { + // Importati WIF: lista fissa, nessun gap limit. + // Pochi indirizzi → subscribe diretto per notifiche push. + foreach (var (addr, isChange, idx) in fixedAddresses) + tracked.Add(new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, idx)); + nextReceive = tracked.Count(t => !t.IsChange); + nextChange = 0; + + var histories = await Task.WhenAll( + tracked.Select(t => client.GetHistoryAsync(t.ScriptHash, ct))); + for (var i = 0; i < tracked.Count; i++) + { + if (histories[i].Count > 0) + historyByAddress[tracked[i].ScriptHash] = histories[i]; + } + // Subscribe a tutti (pochi): notifiche push per ogni indirizzo importato. + await Task.WhenAll(tracked.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct))); + } + else + { + // HD: discovery con GetHistoryAsync (senza subscription → no -101 su wallet grandi); + // subscribe solo al gap window per ricevere notifiche push di nuove tx. + nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct); + nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct); + + // Iscriviti al gap window (prossimi indirizzi attesi) per notifiche push. + // In questo modo il numero di subscription è sempre ≤ 2×gapLimit, indipendentemente + // dalla dimensione dello storico — nessun rischio di -101. + var gapAddresses = tracked.Where(t => + (!t.IsChange && t.Index >= nextReceive && t.Index < nextReceive + gapLimit) || + ( t.IsChange && t.Index >= nextChange && t.Index < nextChange + gapLimit)).ToList(); + if (gapAddresses.Count > 0) + await Task.WhenAll(gapAddresses.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct))); + } // 3. Storico unico (txid → altezza massima riportata). var txHeights = new Dictionary(); foreach (var item in historyByAddress.Values.SelectMany(h => h)) txHeights[item.TxHash] = item.Height; - // 4. Scarica in parallelo le sole transazioni nuove. + // 4. Scarica le transazioni nuove: semaforo MaxConcurrent per non saturare + // il server, con aggiornamento progresso in tempo reale. var network = PalladiumNetworks.For(account.Profile.Kind); var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList(); if (missing.Count > 0) { - Progress?.Invoke($"scarico {missing.Count} transazioni…"); - var downloaded = await Task.WhenAll(missing.Select(async txid => - (txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network)))); - foreach (var (txid, tx) in downloaded) - _txCache[txid] = tx; + var dlSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent); + var dlDone = 0; + Progress?.Invoke($"scarico 0/{missing.Count} transazioni…"); + await Task.WhenAll(missing.Select(async txid => + { + await dlSem.WaitAsync(ct); + try + { + var raw = await client.GetTransactionAsync(txid, ct); + _txCache[txid] = Transaction.Parse(raw, network); + var n = Interlocked.Increment(ref dlDone); + Progress?.Invoke($"scarico {n}/{missing.Count} transazioni…"); + } + finally { dlSem.Release(); } + })); } var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]); - // 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e - // solo per le tx non ancora verificate a quell'altezza. + // 5. Verifica Merkle delle confermate (§7.4 punto 4). + // Gli header per altezza sono condivisi via _headerFetches: se 500 tx + // stanno nello stesso blocco, l'header viene scaricato una sola volta. var toVerify = txHeights .Where(kv => kv.Value > 0 && (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value)) .ToList(); if (toVerify.Count > 0) { - Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…"); + var merkSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent); + var merkDone = 0; + Progress?.Invoke($"verifico 0/{toVerify.Count} prove di Merkle…"); await Task.WhenAll(toVerify.Select(async kv => { - var (txid, height) = kv; - var proofTask = client.GetMerkleAsync(txid, height, ct); - var headerTask = client.GetBlockHeaderAsync(height, ct); - var proof = await proofTask; - var header = BlockHeaderInfo.Parse(await headerTask); - if (!MerkleProof.Verify( - uint256.Parse(txid), proof.Pos, - proof.Merkle.Select(uint256.Parse), header.MerkleRoot)) - throw new SpvVerificationException( - $"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile."); + await merkSem.WaitAsync(ct); + try + { + var (txid, height) = kv; + // Proof e header in parallelo; l'header è condiviso per altezza. + var proofTask = client.GetMerkleAsync(txid, height, ct); + var headerTask = _headerFetches.GetOrAdd(height, + h => client.GetBlockHeaderAsync(h, ct)); + var proof = await proofTask; + var header = BlockHeaderInfo.Parse(await headerTask); + if (!MerkleProof.Verify( + uint256.Parse(txid), proof.Pos, + proof.Merkle.Select(uint256.Parse), header.MerkleRoot)) + throw new SpvVerificationException( + $"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile."); + var n = Interlocked.Increment(ref merkDone); + Progress?.Invoke($"verifico {n}/{toVerify.Count} prove di Merkle…"); + } + finally { merkSem.Release(); } })); foreach (var (txid, height) in toVerify) _verifiedAtHeight[txid] = height; @@ -194,8 +294,10 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, /// /// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi - /// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit - /// subscribe per volta (le richieste JSON-RPC sono pipelinabili). + /// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit per volta. + /// Usa GetHistoryAsync per la discovery — senza subscription → nessun rischio di + /// -101 "excessive resource usage" su wallet con molti indirizzi storici. + /// Le subscription per notifiche push vengono gestite dal chiamante (solo gap window). /// Ritorna il primo indice non usato. /// private async Task ScanChainAsync(bool isChange, List tracked, @@ -214,19 +316,15 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, index += batch.Count; tracked.AddRange(batch); - // La subscribe registra anche la notifica push per i cambi futuri. - var statuses = await Task.WhenAll( - batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct))); - - var used = batch.Where((t, i) => statuses[i] is not null).ToList(); + // GetHistoryAsync per discovery: risposta vuota [] se inutilizzato, + // lista di tx se usato — un solo round-trip per indirizzo. var histories = await Task.WhenAll( - used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct))); - for (var i = 0; i < used.Count; i++) - historyByAddress[used[i].ScriptHash] = histories[i]; + batch.Select(t => client.GetHistoryAsync(t.ScriptHash, ct))); for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++) { - if (statuses[i] is null) + var history = histories[i]; + if (history.Count == 0) { consecutiveEmpty++; } @@ -234,6 +332,7 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, { consecutiveEmpty = 0; firstUnused = batch[i].Index + 1; + historyByAddress[batch[i].ScriptHash] = history; } } }