feat(spv): Electrum-style continuous updates with incremental parallel sync

WalletSynchronizer now pipelines JSON-RPC requests (address subscribes,
histories, tx downloads and Merkle proofs run in parallel batches) and
keeps per-connection caches of downloaded transactions and verified
proofs, so notification-triggered resyncs only cost the delta.

The app connects automatically when a wallet opens, keeps one
synchronizer per connection, and queues notifications that arrive
during a sync instead of dropping them: incoming transactions now show
up within seconds.
This commit is contained in:
2026-06-11 13:37:31 +02:00
parent ece634f42e
commit 73a2d5356d
2 changed files with 118 additions and 61 deletions
+33 -8
View File
@@ -35,9 +35,13 @@ public partial class MainWindowViewModel : ViewModelBase
private string? _walletPath; private string? _walletPath;
private string? _password; private string? _password;
private ElectrumClient? _client; private ElectrumClient? _client;
private WalletSynchronizer? _synchronizer;
private IReadOnlyDictionary<string, Transaction>? _lastTransactions; private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
private BuiltTransaction? _pendingSend; private BuiltTransaction? _pendingSend;
/// <summary>Notifica arrivata durante una sync: si risincronizza appena finita.</summary>
private bool _resyncRequested;
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary> /// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
private string? _pendingOpenPath; private string? _pendingOpenPath;
@@ -308,9 +312,10 @@ public partial class MainWindowViewModel : ViewModelBase
+ (doc.IsWatchOnly ? " · watch-only" : ""); + (doc.IsWatchOnly ? " · watch-only" : "");
ApplyCache(doc.Cache); ApplyCache(doc.Cache);
IsWalletOpen = true; IsWalletOpen = true;
StatusMessage = doc.Cache is null StatusMessage = "Wallet aperto: connessione al server…";
? "Wallet aperto. Connettiti a un server per sincronizzare." // Come Electrum: ci si connette da soli al server selezionato,
: "Wallet aperto (dati dell'ultima sincronizzazione)."; // senza aspettare un click.
_ = ConnectAndSync();
} }
private void ApplyCache(SyncCache? cache) private void ApplyCache(SyncCache? cache)
@@ -364,6 +369,11 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
if (_account is null || _doc is null) if (_account is null || _doc is null)
return; return;
if (IsSyncing)
{
_resyncRequested = true;
return;
}
IsSyncing = true; IsSyncing = true;
StatusMessage = ""; StatusMessage = "";
try try
@@ -383,11 +393,18 @@ public partial class MainWindowViewModel : ViewModelBase
IsConnected = true; IsConnected = true;
_autoReconnect = true; _autoReconnect = true;
ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}"; ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}";
// Synchronizer per connessione: conserva la cache di tx e
// prove verificate, così le risincronizzazioni sono incrementali.
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
} }
var sync = new WalletSynchronizer(_account, _client, _doc.GapLimit); // Se durante la sync arrivano notifiche, si ripete subito: nessun
sync.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); // aggiornamento del server va perso (modello Electrum).
var result = await sync.SyncOnceAsync(); do
{
_resyncRequested = false;
var result = await _synchronizer!.SyncOnceAsync();
_lastTransactions = result.Transactions; _lastTransactions = result.Transactions;
_doc.Cache = new SyncCache _doc.Cache = new SyncCache
@@ -404,7 +421,8 @@ public partial class MainWindowViewModel : ViewModelBase
WalletStore.Save(_doc, _walletPath!, _password); WalletStore.Save(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache); ApplyCache(_doc.Cache);
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " + StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
$"{result.History.Count} transazioni verificate SPV."; $"{result.History.Count} transazioni verificate SPV. Aggiornamento in tempo reale attivo.";
} while (_resyncRequested);
} }
catch (CertificatePinMismatchException ex) catch (CertificatePinMismatchException ex)
{ {
@@ -453,10 +471,14 @@ public partial class MainWindowViewModel : ViewModelBase
private void OnServerNotification(string method, System.Text.Json.JsonElement payload) private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
{ {
// Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza. // Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
// Se una sync è già in corso, si accoda (il loop in ConnectAndSync la
// ripete subito dopo): nessuna notifica viene persa.
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe") if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
if (!IsSyncing) if (IsSyncing)
_resyncRequested = true;
else
_ = ConnectAndSync(); _ = ConnectAndSync();
}); });
} }
@@ -541,6 +563,9 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
_ = _client?.DisposeAsync().AsTask(); _ = _client?.DisposeAsync().AsTask();
_client = null; _client = null;
_synchronizer = null;
_autoReconnect = false;
_resyncRequested = false;
_doc = null; _doc = null;
_account = null; _account = null;
_lastTransactions = null; _lastTransactions = null;
+65 -33
View File
@@ -40,6 +40,13 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary> /// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
public event Action<string>? Progress; public event Action<string>? Progress;
// 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
// costano solo ciò che è cambiato (modello Electrum).
private readonly Dictionary<string, Transaction> _txCache = [];
private readonly Dictionary<string, int> _verifiedAtHeight = [];
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default) public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
{ {
var tip = await client.SubscribeHeadersAsync(ct); var tip = await client.SubscribeHeadersAsync(ct);
@@ -56,37 +63,45 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
foreach (var item in historyByAddress.Values.SelectMany(h => h)) foreach (var item in historyByAddress.Values.SelectMany(h => h))
txHeights[item.TxHash] = item.Height; txHeights[item.TxHash] = item.Height;
// 4. Scarica le transazioni. // 4. Scarica in parallelo le sole transazioni nuove.
Progress?.Invoke($"scarico {txHeights.Count} transazioni…");
var network = PalladiumNetworks.For(account.Profile.Kind); var network = PalladiumNetworks.For(account.Profile.Kind);
var transactions = new Dictionary<string, Transaction>(); var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
foreach (var txid in txHeights.Keys) if (missing.Count > 0)
{ {
var hex = await client.GetTransactionAsync(txid, ct); Progress?.Invoke($"scarico {missing.Count} transazioni…");
transactions[txid] = Transaction.Parse(hex, network); 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 transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
// 5. Verifica Merkle delle confermate (§7.4 punto 4). // 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
var verified = new Dictionary<string, bool>(); // solo per le tx non ancora verificate a quell'altezza.
foreach (var (txid, height) in txHeights) var toVerify = txHeights
.Where(kv => kv.Value > 0
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
.ToList();
if (toVerify.Count > 0)
{ {
if (height <= 0) Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
await Task.WhenAll(toVerify.Select(async kv =>
{ {
verified[txid] = false; // in mempool: nessuna prova possibile var (txid, height) = kv;
continue; var proofTask = client.GetMerkleAsync(txid, height, ct);
} var headerTask = client.GetBlockHeaderAsync(height, ct);
var proof = await client.GetMerkleAsync(txid, height, ct); var proof = await proofTask;
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct)); var header = BlockHeaderInfo.Parse(await headerTask);
var ok = MerkleProof.Verify( if (!MerkleProof.Verify(
uint256.Parse(txid), uint256.Parse(txid), proof.Pos,
proof.Pos, proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
proof.Merkle.Select(uint256.Parse),
header.MerkleRoot);
if (!ok)
throw new SpvVerificationException( throw new SpvVerificationException(
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile."); $"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
verified[txid] = true; }));
foreach (var (txid, height) in toVerify)
_verifiedAtHeight[txid] = height;
} }
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri // 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
// script; spesi = outpoint consumati da una qualunque tx del wallet. // script; spesi = outpoint consumati da una qualunque tx del wallet.
@@ -179,7 +194,9 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
/// <summary> /// <summary>
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi /// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
/// vuoti consecutivi (§5). Ritorna il primo indice non usato. /// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
/// Ritorna il primo indice non usato.
/// </summary> /// </summary>
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked, private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct) Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
@@ -187,23 +204,38 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
var consecutiveEmpty = 0; var consecutiveEmpty = 0;
var index = 0; var index = 0;
var firstUnused = 0; var firstUnused = 0;
for (; consecutiveEmpty < gapLimit; index++) while (consecutiveEmpty < gapLimit)
{ {
var address = account.GetAddress(isChange, index); var batch = Enumerable.Range(index, gapLimit).Select(i =>
var scripthash = Scripthash.FromAddress(address); {
tracked.Add(new TrackedAddress(address, scripthash, isChange, index)); var address = account.GetAddress(isChange, i);
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
}).ToList();
index += batch.Count;
tracked.AddRange(batch);
// La subscribe registra anche la notifica push per i cambi futuri. // La subscribe registra anche la notifica push per i cambi futuri.
var status = await client.SubscribeScripthashAsync(scripthash, ct); var statuses = await Task.WhenAll(
if (status is null) batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
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];
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
{
if (statuses[i] is null)
{ {
consecutiveEmpty++; consecutiveEmpty++;
continue;
} }
else
historyByAddress[scripthash] = await client.GetHistoryAsync(scripthash, ct); {
consecutiveEmpty = 0; consecutiveEmpty = 0;
firstUnused = index + 1; firstUnused = batch[i].Index + 1;
}
}
} }
return firstUnused; return firstUnused;
} }