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;
}
}
}