diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs index c324ba9..fb6076d 100644 --- a/src/App/ViewModels/MainWindowViewModel.Sync.cs +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -292,7 +292,19 @@ public partial class MainWindowViewModel // final, fully-verified snapshot below is saved); coin selection still refuses // any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this // early can't be exploited to spend a server-fabricated balance. - _synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r)); + // A partial snapshot posted to the UI thread can run after the sync's final snapshot was + // applied (UI thread busy, or a test that pumps jobs late): it would then overwrite the + // verified rows and cache with the older unverified ones. Tag it with the run it + // belongs to and drop it once that run has been finalised. + _synchronizer.PartialResult += r => + { + var run = Volatile.Read(ref _syncRun); + Dispatcher.UIThread.Post(() => + { + if (run == Volatile.Read(ref _syncRun)) + ApplyPartialResult(r); + }); + }; } do @@ -315,9 +327,12 @@ public partial class MainWindowViewModel AnchoredDownTo = anchoredDownTo, }; FillDisplayFields(cache, result); + // Finalise this run BEFORE the save's await: a stale partial snapshot still queued on the + // UI thread would otherwise run during the save and replace _doc.Cache with unverified rows. + Interlocked.Increment(ref _syncRun); _doc.Cache = cache; + ApplyCache(cache); await WalletStore.SaveAsync(_doc, _walletPath!, _password); - ApplyCache(_doc.Cache); _syncFailed = false; StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + $"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; @@ -549,9 +564,12 @@ public partial class MainWindowViewModel // Witness servers for cross-checking recent headers (ADR 0042): opened lazily by the // synchronizer, only from the hardcoded bootstrap list, and closed with the primary connection. private readonly List _witnesses = []; + private int _syncRun; // bumped each time a sync's final snapshot is applied (see PartialResult) private async Task> ConnectWitnessesAsync(ElectrumClient primary) { + if (Profile.BootstrapServers.Count == 0) + return []; var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); var list = await WitnessConnector.ConnectAsync(Profile, Profile.BootstrapServers, primary.Host, primary.Port, UseSsl, pins); diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index 9e49969..6c5879a 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -543,8 +543,16 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl { Task> witnessesTask; lock (_witnessLock) - witnessesTask = _witnesses ??= WitnessProvider!(CancellationToken.None); - var witnesses = await witnessesTask; + witnessesTask = _witnesses ??= Task.Run(() => WitnessProvider!(CancellationToken.None)); + IReadOnlyList witnesses; + try + { + witnesses = await witnessesTask; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return; // best effort: a failure to open witnesses leaves only the Merkle proof, as before + } if (witnesses.Count == 0) return; diff --git a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs index 91b4174..71a1b7e 100644 --- a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs +++ b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs @@ -388,7 +388,8 @@ public sealed class MainWindowViewModelTests : VmTestBase var vm = new MainWindowViewModel(); RestoreFlow(vm, Mnemonic, null, "funded"); - await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1); + await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified, + describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'"); Assert.True(vm.History[0].Verified); Assert.Contains("0.025", vm.BalanceText); @@ -412,7 +413,8 @@ public sealed class MainWindowViewModelTests : VmTestBase }; var vm = new MainWindowViewModel(); RestoreFlow(vm, Mnemonic, null, "unanch"); - await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1); + await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified, + describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'"); Assert.True(vm.History[0].Verified); // the Merkle proof checks out Assert.True(vm.History[0].Unanchored); // but height 100 is above the checkpoint at 50 @@ -494,7 +496,8 @@ public sealed class MainWindowViewModelTests : VmTestBase _server.Handle("blockchain.estimatefee", _ => 0.00002m); var vm = new MainWindowViewModel(); RestoreFlow(vm, Mnemonic, null, "fees4"); - await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing && vm.History.Count == 1); + await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified, + describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'"); vm.SendTo = account.GetReceiveAddress(7).ToString(); vm.SendAmount = "0.1"; @@ -522,7 +525,8 @@ public sealed class MainWindowViewModelTests : VmTestBase WireFundedChain(account.GetReceiveAddress(0), 50_000_000); var vm = new MainWindowViewModel(); RestoreFlow(vm, Mnemonic, null, "sender"); - await Eventually(() => !vm.IsSyncing && vm.History.Count == 1); + await Eventually(() => !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified, + describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'"); // Coins need MinConfirmations (tip 200, height 100 → 101 confirmations): spendable. vm.SendTo = account.GetReceiveAddress(7).ToString(); diff --git a/tests/PalladiumWallet.Tests/App/VmTestBase.cs b/tests/PalladiumWallet.Tests/App/VmTestBase.cs index a78a30d..8f02193 100644 --- a/tests/PalladiumWallet.Tests/App/VmTestBase.cs +++ b/tests/PalladiumWallet.Tests/App/VmTestBase.cs @@ -54,16 +54,22 @@ public abstract class VmTestBase : IDisposable try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } } - protected static async Task Eventually(Func condition, int timeoutMs = 10_000) + protected async Task Eventually(Func condition, int timeoutMs = 10_000, Func? describe = null) { for (var waited = 0; !condition(); waited += 20) { - Assert.True(waited < timeoutMs, "condition not reached in time"); + Assert.True(waited < timeoutMs, "condition not reached in time" + (describe is null ? "" : $": {describe()}") + $" | calls: {Calls()}"); Dispatcher.UIThread.RunJobs(); await Task.Delay(20); } } + private string Calls() => string.Join(", ", new[] + { + "blockchain.headers.subscribe", "blockchain.scripthash.get_history", "blockchain.transaction.get", + "blockchain.transaction.get_merkle", "blockchain.block.header", "blockchain.block.headers", + }.Select(m => $"{m.Split('.')[^1]}={_server.CallCount(m)}")); + protected static void RestoreFlow(MainWindowViewModel vm, string mnemonic, string? password, string? name = null) { vm.WizardStartRestoreCommand.Execute(null); diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs index 3af937e..08f1eb5 100644 --- a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs @@ -937,6 +937,25 @@ public class WalletSynchronizerTests Assert.True(Assert.Single(again.History).Verified); } + [Fact] + public async Task A_witness_provider_that_throws_leaves_only_the_merkle_proof() + { + var account = Account(ProfileWithCheckpointAt100()); + var scenario = new Scenario(); + scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150); + var (server, client) = await StartAsync(scenario); + await using var _ = server; await using var __ = client; + + var sync = new WalletSynchronizer(account, client) + { + WitnessProvider = _ => throw new IOException("cannot open witnesses"), + }; + var result = await sync.SyncOnceAsync(); + + Assert.Equal(1_000_000, result.SpendableSats); + Assert.Equal(0, result.PendingVerificationSats); + } + [Fact] public async Task A_witness_that_does_not_know_the_block_or_is_gone_is_skipped() {