From ed8de06609d38107d9e857b3212b332324b8eaf2 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 3 Nov 2026 15:36:02 +0100 Subject: [PATCH] test: wait for verified history rows before asserting on them The funded-wallet ViewModel tests only waited for the history row to appear, but the Merkle proof can complete after sync reports idle. On the slower Windows runner the assertions on Verified and the spend tests (which need verified UTXOs) raced it and failed. Wait for the row to be verified. --- .../ViewModels/MainWindowViewModel.Sync.cs | 22 +++++++++++++++++-- src/Core/Spv/WalletSynchronizer.cs | 12 ++++++++-- .../App/MainWindowViewModelTests.cs | 12 ++++++---- tests/PalladiumWallet.Tests/App/VmTestBase.cs | 10 +++++++-- .../Spv/WalletSynchronizerTests.cs | 19 ++++++++++++++++ 5 files changed, 65 insertions(+), 10 deletions(-) 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() {