perf(spv): persist checkpoint-anchoring state across sync sessions

_anchoredUpTo tracked which heights were already proven to hash-chain
back to a checkpoint, but only in memory: every app restart re-walked
and re-verified the whole header chain from the checkpoint even when
the header bytes themselves were already cached on disk, dominating
reconnect time on large wallets. Round-trip it through SyncCache like
the other sync caches (RawTxHex/VerifiedAt/BlockHeaders).
This commit is contained in:
2026-07-19 12:15:50 +02:00
parent b6440484c1
commit d9dd05aa52
5 changed files with 94 additions and 15 deletions
+11 -5
View File
@@ -271,7 +271,8 @@ public partial class MainWindowViewModel
_doc.Cache?.BlockHeaders,
_doc.Cache?.NextReceiveIndex ?? 0,
_doc.Cache?.NextChangeIndex ?? 0,
net);
net,
_doc.Cache?.AnchoredUpTo);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
// Progressive verification (§7.4): the wallet becomes usable as soon as
// transaction downloads finish, without waiting for every historical Merkle
@@ -289,15 +290,19 @@ public partial class MainWindowViewModel
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
var (result, rawHex, verifiedAt, blockHeaders) = await Task.Run(async () =>
var (result, rawHex, verifiedAt, blockHeaders, anchoredUpTo) = await Task.Run(async () =>
{
var r = await _synchronizer.SyncOnceAsync(ct);
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders);
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders, caches.AnchoredUpTo);
}, ct);
_lastTransactions = result.Transactions;
var cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
var cache = new SyncCache
{
RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
};
FillDisplayFields(cache, result);
_doc.Cache = cache;
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
@@ -493,12 +498,13 @@ public partial class MainWindowViewModel
try
{
var net = PalladiumNetworks.For(_account.Profile.Kind);
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(net);
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = _synchronizer.ExportCaches(net);
if (rawHex.Count == 0 && verifiedAt.Count == 0)
return;
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
_doc.Cache.VerifiedAt = verifiedAt;
_doc.Cache.BlockHeaders = blockHeaders;
_doc.Cache.AnchoredUpTo = anchoredUpTo;
WalletStore.Save(_doc, _walletPath, _password);
}
catch { /* non-fatal: the next full save will recover */ }
+4 -2
View File
@@ -140,10 +140,11 @@ static async Task<int> Sync(string[] o)
doc.Cache?.BlockHeaders,
doc.Cache?.NextReceiveIndex ?? 0,
doc.Cache?.NextChangeIndex ?? 0,
net);
net,
doc.Cache?.AnchoredUpTo);
var result = await sync.SyncOnceAsync();
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
@@ -160,6 +161,7 @@ static async Task<int> Sync(string[] o)
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
};
WalletStore.Save(doc, path, Opt(o, "--password"));
+19 -5
View File
@@ -80,8 +80,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
// checkpoint height -> highest height already proven to hash-chain back to it
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
// checkpoint height -> highest height already proven to hash-chain back to it.
// Persisted across sessions (see ExportCaches/PreloadCaches): without it, every restart
// re-walks and re-verifies the whole header chain from the checkpoint even though the
// header bytes themselves are cached, which dominates reconnect time on large wallets.
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
@@ -110,7 +112,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
Dictionary<int, string>? blockHeaders,
int knownReceiveIndex,
int knownChangeIndex,
Network network)
Network network,
Dictionary<int, int>? anchoredUpTo = null)
{
foreach (var (txid, hex) in rawTxHex)
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
@@ -120,6 +123,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (blockHeaders is not null)
foreach (var (height, hex) in blockHeaders)
_headerFetches.TryAdd(height, Task.FromResult(hex));
// Only trust a preloaded anchor up to a height whose header is also cached: if the
// header cache was cleared/corrupted independently, re-deriving the chain-of-hashes
// check on next use (AnchorToCheckpointAsync re-fetches what's missing) is safer than
// trusting a stale "already validated" claim against headers that may no longer match.
if (anchoredUpTo is not null)
foreach (var (checkpointHeight, upToHeight) in anchoredUpTo)
if (_headerFetches.ContainsKey(upToHeight))
_anchoredUpTo.AddOrUpdate(checkpointHeight, upToHeight,
(_, existing) => Math.Max(existing, upToHeight));
_knownReceiveIndex = knownReceiveIndex;
_knownChangeIndex = knownChangeIndex;
}
@@ -131,7 +143,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// </summary>
public (Dictionary<string, string> RawTxHex,
Dictionary<string, int> VerifiedAt,
Dictionary<int, string> BlockHeaders)
Dictionary<int, string> BlockHeaders,
Dictionary<int, int> AnchoredUpTo)
ExportCaches(Network network)
{
var rawHex = _verifiedAtHeight.Keys
@@ -145,7 +158,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (task.IsCompletedSuccessfully)
headers[height] = task.Result;
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers);
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers,
new Dictionary<int, int>(_anchoredUpTo));
}
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
+8
View File
@@ -134,6 +134,14 @@ public sealed class SyncCache
/// subsequent syncs.
/// </summary>
public Dictionary<int, string>? BlockHeaders { get; set; }
/// <summary>
/// Checkpoint height → highest height already proven to hash-chain back to it (§7.3).
/// Avoids re-walking and re-verifying the whole header chain from the checkpoint on
/// every launch: the chain-of-hashes check already done for a height doesn't need
/// redoing once headers themselves are cached.
/// </summary>
public Dictionary<int, int>? AnchoredUpTo { get; set; }
}
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>