fix(spv): resilient sync — GetHistory discovery, tx cache, auto-connect fallback
Three interrelated fixes for sync reliability on large wallets: 1. Prevent -101 "excessive resource usage": ScanChainAsync uses GetHistoryAsync for discovery instead of SubscribeScripthashAsync. Subscriptions are limited to the gap window only (≤ 2×gapLimit addresses), so the session subscription count stays constant regardless of wallet history size — eliminating -101 on wallets with 1000+ historical addresses. 2. Persist and resume tx cache across sessions and disconnects: WalletSynchronizer gains ExportCaches() and PreloadCaches(). On every successful sync (and on partial failure) the downloaded raw tx hex and verified Merkle heights are written to SyncCache.RawTxHex/VerifiedAt in the wallet file. On reconnect or app restart the new synchronizer is pre-populated from disk so only new transactions are downloaded. _synchronizer is always nulled on reconnect (new client = new instance) while caches survive via the wallet file. 3. Auto-connect with multi-server fallback and retry: ConnectAndSync iterates BuildServerCandidates() (current server first, then all KnownServers in order) and stops at the first that responds. _autoReconnect and _syncFailed are set in OpenLoaded() so the keepalive timer (20 s) retries automatically on any failure — connection error, -101 mid-download, or timeout. PersistPartialTxCache() saves whatever was downloaded so each retry resumes from its checkpoint.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -6,6 +7,7 @@ using Avalonia.Threading;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PalladiumWallet.App.Localization;
|
using PalladiumWallet.App.Localization;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Spv;
|
using PalladiumWallet.Core.Spv;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
@@ -115,6 +117,30 @@ public partial class MainWindowViewModel
|
|||||||
return (host, port);
|
return (host, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restituisce i server da provare in ordine: prima quello selezionato nella UI
|
||||||
|
/// (o digitato manualmente), poi gli altri in KnownServers, evitando duplicati.
|
||||||
|
/// </summary>
|
||||||
|
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(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]
|
[RelayCommand]
|
||||||
private async Task ConnectAndSync()
|
private async Task ConnectAndSync()
|
||||||
{
|
{
|
||||||
@@ -137,9 +163,23 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (_client is null || !_client.IsConnected)
|
if (_client is null || !_client.IsConnected)
|
||||||
{
|
{
|
||||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
|
// 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)
|
||||||
|
{
|
||||||
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||||
|
try
|
||||||
|
{
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
|
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||||
_client.NotificationReceived += OnServerNotification;
|
_client.NotificationReceived += OnServerNotification;
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -149,6 +189,24 @@ public partial class MainWindowViewModel
|
|||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
_autoReconnect = true;
|
_autoReconnect = true;
|
||||||
ConnectionStatus = Loc.Tr("conn.connectedto");
|
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)
|
if (_account is null || _doc is null)
|
||||||
@@ -156,7 +214,14 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (_synchronizer is null)
|
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);
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +231,8 @@ public partial class MainWindowViewModel
|
|||||||
var result = await _synchronizer.SyncOnceAsync();
|
var result = await _synchronizer.SyncOnceAsync();
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
|
var (rawHex, verifiedAt) = _synchronizer.ExportCaches(
|
||||||
|
PalladiumNetworks.For(_account.Profile.Kind));
|
||||||
_doc.Cache = new SyncCache
|
_doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
@@ -176,9 +243,12 @@ public partial class MainWindowViewModel
|
|||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
Utxos = [.. result.Utxos],
|
Utxos = [.. result.Utxos],
|
||||||
Addresses = [.. result.AddressRows],
|
Addresses = [.. result.AddressRows],
|
||||||
|
RawTxHex = rawHex,
|
||||||
|
VerifiedAt = verifiedAt,
|
||||||
};
|
};
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
WalletStore.Save(_doc, _walletPath!, _password);
|
||||||
ApplyCache(_doc.Cache);
|
ApplyCache(_doc.Cache);
|
||||||
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||||
} while (_resyncRequested);
|
} while (_resyncRequested);
|
||||||
@@ -194,6 +264,13 @@ public partial class MainWindowViewModel
|
|||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -245,6 +322,28 @@ public partial class MainWindowViewModel
|
|||||||
StatusMessage = Loc.Tr("msg.certreset");
|
StatusMessage = Loc.Tr("msg.certreset");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
private async Task DisconnectAsync()
|
||||||
{
|
{
|
||||||
if (_client is { } client)
|
if (_client is { } client)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
@@ -35,11 +37,15 @@ public sealed class SyncResult
|
|||||||
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
||||||
/// estende la scansione fino al gap limit (§5).
|
/// estende la scansione fino al gap limit (§5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20)
|
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
||||||
{
|
{
|
||||||
/// <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;
|
||||||
|
|
||||||
|
// 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
|
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
||||||
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
||||||
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
||||||
@@ -47,49 +53,139 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
private readonly Dictionary<string, Transaction> _txCache = [];
|
private readonly Dictionary<string, Transaction> _txCache = [];
|
||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
|
|
||||||
|
// Header grezzi per altezza: una Task<string> 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<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-popola le cache interne da dati salvati su disco (SyncCache).
|
||||||
|
/// Chiamare prima di SyncOnceAsync per evitare di riscaricale le tx già note.
|
||||||
|
/// </summary>
|
||||||
|
public void PreloadCaches(Dictionary<string, string> rawTxHex,
|
||||||
|
Dictionary<string, int> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public (Dictionary<string, string> RawTxHex, Dictionary<string, int> 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<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);
|
||||||
Progress?.Invoke($"tip della catena: {tip.Height}");
|
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<TrackedAddress>();
|
var tracked = new List<TrackedAddress>();
|
||||||
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||||
var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
int nextReceive, nextChange;
|
||||||
var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
|
||||||
|
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).
|
// 3. Storico unico (txid → altezza massima riportata).
|
||||||
var txHeights = new Dictionary<string, int>();
|
var txHeights = new Dictionary<string, int>();
|
||||||
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 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 network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
if (missing.Count > 0)
|
if (missing.Count > 0)
|
||||||
{
|
{
|
||||||
Progress?.Invoke($"scarico {missing.Count} transazioni…");
|
var dlSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
||||||
var downloaded = await Task.WhenAll(missing.Select(async txid =>
|
var dlDone = 0;
|
||||||
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
|
Progress?.Invoke($"scarico 0/{missing.Count} transazioni…");
|
||||||
foreach (var (txid, tx) in downloaded)
|
await Task.WhenAll(missing.Select(async txid =>
|
||||||
_txCache[txid] = tx;
|
{
|
||||||
|
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]);
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||||
|
|
||||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
|
// 5. Verifica Merkle delle confermate (§7.4 punto 4).
|
||||||
// solo per le tx non ancora verificate a quell'altezza.
|
// 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
|
var toVerify = txHeights
|
||||||
.Where(kv => kv.Value > 0
|
.Where(kv => kv.Value > 0
|
||||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (toVerify.Count > 0)
|
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 =>
|
await Task.WhenAll(toVerify.Select(async kv =>
|
||||||
|
{
|
||||||
|
await merkSem.WaitAsync(ct);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var (txid, height) = kv;
|
var (txid, height) = kv;
|
||||||
|
// Proof e header in parallelo; l'header è condiviso per altezza.
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
var headerTask = _headerFetches.GetOrAdd(height,
|
||||||
|
h => client.GetBlockHeaderAsync(h, ct));
|
||||||
var proof = await proofTask;
|
var proof = await proofTask;
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
if (!MerkleProof.Verify(
|
if (!MerkleProof.Verify(
|
||||||
@@ -97,6 +193,10 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
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.");
|
||||||
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
|
Progress?.Invoke($"verifico {n}/{toVerify.Count} prove di Merkle…");
|
||||||
|
}
|
||||||
|
finally { merkSem.Release(); }
|
||||||
}));
|
}));
|
||||||
foreach (var (txid, height) in toVerify)
|
foreach (var (txid, height) in toVerify)
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
@@ -194,8 +294,10 @@ 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), procedendo a batch paralleli di gapLimit
|
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit per volta.
|
||||||
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
/// 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.
|
/// 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,
|
||||||
@@ -214,19 +316,15 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
index += batch.Count;
|
index += batch.Count;
|
||||||
tracked.AddRange(batch);
|
tracked.AddRange(batch);
|
||||||
|
|
||||||
// La subscribe registra anche la notifica push per i cambi futuri.
|
// GetHistoryAsync per discovery: risposta vuota [] se inutilizzato,
|
||||||
var statuses = await Task.WhenAll(
|
// lista di tx se usato — un solo round-trip per indirizzo.
|
||||||
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(
|
var histories = await Task.WhenAll(
|
||||||
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
batch.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++)
|
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||||
{
|
{
|
||||||
if (statuses[i] is null)
|
var history = histories[i];
|
||||||
|
if (history.Count == 0)
|
||||||
{
|
{
|
||||||
consecutiveEmpty++;
|
consecutiveEmpty++;
|
||||||
}
|
}
|
||||||
@@ -234,6 +332,7 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
{
|
{
|
||||||
consecutiveEmpty = 0;
|
consecutiveEmpty = 0;
|
||||||
firstUnused = batch[i].Index + 1;
|
firstUnused = batch[i].Index + 1;
|
||||||
|
historyByAddress[batch[i].ScriptHash] = history;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user