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.
This commit is contained in:
davide committed 2026-11-03 21:19:36 +01:00
1 parent 9010ebce59
commit ed8de06609
5 files changed
+65 -10

No files matched your search

+20 -2
View File
@@ -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<ElectrumClient> _witnesses = [];
private int _syncRun; // bumped each time a sync's final snapshot is applied (see PartialResult)
private async Task<IReadOnlyList<ElectrumClient>> 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);
+10 -2
View File
@@ -543,8 +543,16 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
{
Task<IReadOnlyList<ElectrumClient>> witnessesTask;
lock (_witnessLock)
witnessesTask = _witnesses ??= WitnessProvider!(CancellationToken.None);
var witnesses = await witnessesTask;
witnessesTask = _witnesses ??= Task.Run(() => WitnessProvider!(CancellationToken.None));
IReadOnlyList<ElectrumClient> 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;
@@ -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();
@@ -54,16 +54,22 @@ public abstract class VmTestBase : IDisposable
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
}
protected static async Task Eventually(Func<bool> condition, int timeoutMs = 10_000)
protected async Task Eventually(Func<bool> condition, int timeoutMs = 10_000, Func<string>? 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);
@@ -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()
{