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:
@@ -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,28 +393,36 @@ 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
|
||||||
_lastTransactions = result.Transactions;
|
|
||||||
|
|
||||||
_doc.Cache = new SyncCache
|
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
_resyncRequested = false;
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
var result = await _synchronizer!.SyncOnceAsync();
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
_lastTransactions = result.Transactions;
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
_doc.Cache = new SyncCache
|
||||||
History = [.. result.History],
|
{
|
||||||
Utxos = [.. result.Utxos],
|
TipHeight = result.TipHeight,
|
||||||
Addresses = [.. result.AddressRows],
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
};
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
ApplyCache(_doc.Cache);
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
|
History = [.. result.History],
|
||||||
$"{result.History.Count} transazioni verificate SPV.";
|
Utxos = [.. result.Utxos],
|
||||||
|
Addresses = [.. result.AddressRows],
|
||||||
|
};
|
||||||
|
WalletStore.Save(_doc, _walletPath!, _password);
|
||||||
|
ApplyCache(_doc.Cache);
|
||||||
|
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
|
||||||
|
$"{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;
|
||||||
|
|||||||
@@ -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),
|
throw new SpvVerificationException(
|
||||||
header.MerkleRoot);
|
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||||
if (!ok)
|
}));
|
||||||
throw new SpvVerificationException(
|
foreach (var (txid, height) in toVerify)
|
||||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
_verifiedAtHeight[txid] = height;
|
||||||
verified[txid] = true;
|
|
||||||
}
|
}
|
||||||
|
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)));
|
||||||
{
|
|
||||||
consecutiveEmpty++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
historyByAddress[scripthash] = await client.GetHistoryAsync(scripthash, ct);
|
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
|
||||||
consecutiveEmpty = 0;
|
var histories = await Task.WhenAll(
|
||||||
firstUnused = index + 1;
|
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++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
consecutiveEmpty = 0;
|
||||||
|
firstUnused = batch[i].Index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return firstUnused;
|
return firstUnused;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user