From d9dd05aa52e34d4505a39ff27caeeaa424045996 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 19 Jul 2026 12:15:50 +0200 Subject: [PATCH] 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). --- .../ViewModels/MainWindowViewModel.Sync.cs | 16 ++++-- src/Cli/Program.cs | 6 +- src/Core/Spv/WalletSynchronizer.cs | 24 ++++++-- src/Core/Storage/WalletDocument.cs | 8 +++ .../Spv/WalletSynchronizerTests.cs | 55 ++++++++++++++++++- 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs index 87c3c14..fe6480b 100644 --- a/src/App/ViewModels/MainWindowViewModel.Sync.cs +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -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 */ } diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index bb29161..20aaffb 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -140,10 +140,11 @@ static async Task 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 Sync(string[] o) RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders, + AnchoredUpTo = anchoredUpTo, }; WalletStore.Save(doc, path, Opt(o, "--password")); diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index 7385735..2048db9 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -80,8 +80,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl private readonly ConcurrentDictionary _verifiedAtHeight = new(); private readonly ConcurrentDictionary> _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 _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? blockHeaders, int knownReceiveIndex, int knownChangeIndex, - Network network) + Network network, + Dictionary? 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 /// public (Dictionary RawTxHex, Dictionary VerifiedAt, - Dictionary BlockHeaders) + Dictionary BlockHeaders, + Dictionary 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(_verifiedAtHeight), headers); + return (rawHex, new Dictionary(_verifiedAtHeight), headers, + new Dictionary(_anchoredUpTo)); } public async Task SyncOnceAsync(CancellationToken ct = default) diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index a9d8d78..5889db9 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -134,6 +134,14 @@ public sealed class SyncCache /// subsequent syncs. /// public Dictionary? BlockHeaders { get; set; } + + /// + /// 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. + /// + public Dictionary? AnchoredUpTo { get; set; } } /// Scanned address with its own balance and transaction count (address view). diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs index 90e6750..1f3c4ac 100644 --- a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs @@ -595,7 +595,7 @@ public class WalletSynchronizerTests var first = new WalletSynchronizer(account, client); var result1 = await first.SyncOnceAsync(); - var (rawTx, verifiedAt, headers) = first.ExportCaches(Net); + var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net); Assert.Single(rawTx); // the confirmed tx is exported Assert.Single(verifiedAt); // with its verified height @@ -605,7 +605,7 @@ public class WalletSynchronizerTests server.ResetCallCounts(); var second = new WalletSynchronizer(account, client); second.PreloadCaches(rawTx, verifiedAt, headers, - result1.NextReceiveIndex, result1.NextChangeIndex, Net); + result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo); var result2 = await second.SyncOnceAsync(); Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats); @@ -614,6 +614,55 @@ public class WalletSynchronizerTests Assert.Equal(0, server.CallCount("blockchain.block.header")); } + [Fact] + public async Task Lo_stato_di_anchoring_precaricato_da_disco_evita_di_ricamminare_la_catena_al_riavvio() + { + // Same scenario as "Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo", but + // across two separate WalletSynchronizer instances (simulating an app restart) with + // AnchoredUpTo round-tripped through ExportCaches/PreloadCaches like a real save/reload — + // the in-memory-only memoization that test covers wouldn't survive that on its own. + var account = Account(); + var scenario = new Scenario(); + var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105); + + var tx2 = Net.CreateTransaction(); + tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1))); + tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1)); + + var chain = ChainedHeaders(100, 105, funding.GetHash(), + roots: new() { [103] = tx2.GetHash() }); + foreach (var (h, hex) in chain) scenario.Headers[h] = hex; + + var checkpointProfile = Profile with + { + Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)], + }; + var checkpointAccount = Account(checkpointProfile); + + var (server, client) = await StartAsync(scenario); + await using var _ = server; await using var __ = client; + + var first = new WalletSynchronizer(checkpointAccount, client); + var result1 = await first.SyncOnceAsync(); // walks and memoizes the anchor up to 105 + var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net); + Assert.NotEmpty(anchoredUpTo); + + // Fresh synchroniser (new launch) preloaded from the exported caches, then a new tx at + // height 103 — within the already-anchored range — is announced. + scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1)); + server.ResetCallCounts(); + var second = new WalletSynchronizer(checkpointAccount, client); + second.PreloadCaches(rawTx, verifiedAt, headers, + result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo); + var result2 = await second.SyncOnceAsync(); + + // 103 <= the persisted anchor of 105: no header-range re-walk despite this being a + // brand-new synchroniser instance that never anchored anything itself. + Assert.Equal(1_500_000, result2.ConfirmedSats); + Assert.Equal(0, server.CallCount("blockchain.block.header")); + Assert.Equal(0, server.CallCount("blockchain.block.headers")); + } + [Fact] public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache() { @@ -625,7 +674,7 @@ public class WalletSynchronizerTests var sync = new WalletSynchronizer(account, client); await sync.SyncOnceAsync(); - var (rawTx, verifiedAt, _) = sync.ExportCaches(Net); + var (rawTx, verifiedAt, _, _) = sync.ExportCaches(Net); // Unconfirmed txs can change (RBF): they must always be re-downloaded. Assert.Empty(rawTx);