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:
@@ -271,7 +271,8 @@ public partial class MainWindowViewModel
|
|||||||
_doc.Cache?.BlockHeaders,
|
_doc.Cache?.BlockHeaders,
|
||||||
_doc.Cache?.NextReceiveIndex ?? 0,
|
_doc.Cache?.NextReceiveIndex ?? 0,
|
||||||
_doc.Cache?.NextChangeIndex ?? 0,
|
_doc.Cache?.NextChangeIndex ?? 0,
|
||||||
net);
|
net,
|
||||||
|
_doc.Cache?.AnchoredUpTo);
|
||||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||||
// Progressive verification (§7.4): the wallet becomes usable as soon as
|
// Progressive verification (§7.4): the wallet becomes usable as soon as
|
||||||
// transaction downloads finish, without waiting for every historical Merkle
|
// 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
|
// 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
|
// 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.
|
// 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 r = await _synchronizer.SyncOnceAsync(ct);
|
||||||
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
|
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);
|
}, ct);
|
||||||
_lastTransactions = result.Transactions;
|
_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);
|
FillDisplayFields(cache, result);
|
||||||
_doc.Cache = cache;
|
_doc.Cache = cache;
|
||||||
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
||||||
@@ -493,12 +498,13 @@ public partial class MainWindowViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
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)
|
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||||
return;
|
return;
|
||||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||||
_doc.Cache.VerifiedAt = verifiedAt;
|
_doc.Cache.VerifiedAt = verifiedAt;
|
||||||
_doc.Cache.BlockHeaders = blockHeaders;
|
_doc.Cache.BlockHeaders = blockHeaders;
|
||||||
|
_doc.Cache.AnchoredUpTo = anchoredUpTo;
|
||||||
WalletStore.Save(_doc, _walletPath, _password);
|
WalletStore.Save(_doc, _walletPath, _password);
|
||||||
}
|
}
|
||||||
catch { /* non-fatal: the next full save will recover */ }
|
catch { /* non-fatal: the next full save will recover */ }
|
||||||
|
|||||||
+4
-2
@@ -140,10 +140,11 @@ static async Task<int> Sync(string[] o)
|
|||||||
doc.Cache?.BlockHeaders,
|
doc.Cache?.BlockHeaders,
|
||||||
doc.Cache?.NextReceiveIndex ?? 0,
|
doc.Cache?.NextReceiveIndex ?? 0,
|
||||||
doc.Cache?.NextChangeIndex ?? 0,
|
doc.Cache?.NextChangeIndex ?? 0,
|
||||||
net);
|
net,
|
||||||
|
doc.Cache?.AnchoredUpTo);
|
||||||
var result = await sync.SyncOnceAsync();
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
|
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
|
||||||
doc.Cache = new SyncCache
|
doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
@@ -160,6 +161,7 @@ static async Task<int> Sync(string[] o)
|
|||||||
RawTxHex = rawHex,
|
RawTxHex = rawHex,
|
||||||
VerifiedAt = verifiedAt,
|
VerifiedAt = verifiedAt,
|
||||||
BlockHeaders = blockHeaders,
|
BlockHeaders = blockHeaders,
|
||||||
|
AnchoredUpTo = anchoredUpTo,
|
||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
// checkpoint height -> highest height already proven to hash-chain back to it
|
// checkpoint height -> highest height already proven to hash-chain back to it.
|
||||||
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
// 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();
|
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||||
|
|
||||||
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
|
// 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,
|
Dictionary<int, string>? blockHeaders,
|
||||||
int knownReceiveIndex,
|
int knownReceiveIndex,
|
||||||
int knownChangeIndex,
|
int knownChangeIndex,
|
||||||
Network network)
|
Network network,
|
||||||
|
Dictionary<int, int>? anchoredUpTo = null)
|
||||||
{
|
{
|
||||||
foreach (var (txid, hex) in rawTxHex)
|
foreach (var (txid, hex) in rawTxHex)
|
||||||
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
||||||
@@ -120,6 +123,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
if (blockHeaders is not null)
|
if (blockHeaders is not null)
|
||||||
foreach (var (height, hex) in blockHeaders)
|
foreach (var (height, hex) in blockHeaders)
|
||||||
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
_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;
|
_knownReceiveIndex = knownReceiveIndex;
|
||||||
_knownChangeIndex = knownChangeIndex;
|
_knownChangeIndex = knownChangeIndex;
|
||||||
}
|
}
|
||||||
@@ -131,7 +143,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public (Dictionary<string, string> RawTxHex,
|
public (Dictionary<string, string> RawTxHex,
|
||||||
Dictionary<string, int> VerifiedAt,
|
Dictionary<string, int> VerifiedAt,
|
||||||
Dictionary<int, string> BlockHeaders)
|
Dictionary<int, string> BlockHeaders,
|
||||||
|
Dictionary<int, int> AnchoredUpTo)
|
||||||
ExportCaches(Network network)
|
ExportCaches(Network network)
|
||||||
{
|
{
|
||||||
var rawHex = _verifiedAtHeight.Keys
|
var rawHex = _verifiedAtHeight.Keys
|
||||||
@@ -145,7 +158,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
if (task.IsCompletedSuccessfully)
|
if (task.IsCompletedSuccessfully)
|
||||||
headers[height] = task.Result;
|
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)
|
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ public sealed class SyncCache
|
|||||||
/// subsequent syncs.
|
/// subsequent syncs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<int, string>? BlockHeaders { get; set; }
|
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>
|
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ public class WalletSynchronizerTests
|
|||||||
|
|
||||||
var first = new WalletSynchronizer(account, client);
|
var first = new WalletSynchronizer(account, client);
|
||||||
var result1 = await first.SyncOnceAsync();
|
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(rawTx); // the confirmed tx is exported
|
||||||
Assert.Single(verifiedAt); // with its verified height
|
Assert.Single(verifiedAt); // with its verified height
|
||||||
@@ -605,7 +605,7 @@ public class WalletSynchronizerTests
|
|||||||
server.ResetCallCounts();
|
server.ResetCallCounts();
|
||||||
var second = new WalletSynchronizer(account, client);
|
var second = new WalletSynchronizer(account, client);
|
||||||
second.PreloadCaches(rawTx, verifiedAt, headers,
|
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
|
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
|
||||||
var result2 = await second.SyncOnceAsync();
|
var result2 = await second.SyncOnceAsync();
|
||||||
|
|
||||||
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||||
@@ -614,6 +614,55 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
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]
|
[Fact]
|
||||||
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
|
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);
|
var sync = new WalletSynchronizer(account, client);
|
||||||
await sync.SyncOnceAsync();
|
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.
|
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
|
||||||
Assert.Empty(rawTx);
|
Assert.Empty(rawTx);
|
||||||
|
|||||||
Reference in New Issue
Block a user