feat(spv): verify Merkle proofs progressively, gate spendability on it

Balance/history now render as soon as tx downloads finish instead of
blocking on every historical Merkle proof, critical for mobile where
proof-checking can take much longer than the download itself. Proofs
continue to be checked in the background and each tx's Verified flag
catches up progressively; header ranges are now fetched in batches
(blockchain.block.headers) instead of one call per header to keep this
fast over high-latency links.

Coin selection (UtxoSpendability.IsSpendable) refuses to spend a UTXO
until its Merkle proof is actually checked, regardless of confirmation
count, so a server that fabricates a confirmed balance can get it
displayed early but never spent before the forgery is caught. The disk
cache only ever persists the fully-verified end state of a sync.

UI surfaces the new PendingVerificationSats/SpendableSats split with a
"verifying..." badge, and the sync save now runs off the UI thread to
avoid freezing on slower hardware.
This commit is contained in:
2026-07-17 19:41:31 +02:00
parent feb765663c
commit 3460e53b4f
16 changed files with 487 additions and 82 deletions
@@ -28,6 +28,9 @@ public partial class MainWindowViewModel
[ObservableProperty]
private string immatureText = "";
[ObservableProperty]
private string verifyingText = "";
[ObservableProperty]
private string networkInfo = "";
@@ -253,6 +256,7 @@ public partial class MainWindowViewModel
BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = "";
ImmatureText = "";
VerifyingText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear();
Addresses.Clear();
@@ -265,7 +269,11 @@ public partial class MainWindowViewModel
$"m/{_doc!.AccountPath}/0/{i}"));
return;
}
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
// immature coinbase can also be unverified), which double-subtracts and can go
// negative. SpendableSats is computed directly from the same IsSpendable gate
// coin selection uses, so it's always correct.
BalanceText = Fmt(cache.SpendableSats);
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
@@ -273,13 +281,20 @@ public partial class MainWindowViewModel
ImmatureText = cache.ImmatureSats != 0
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
: "";
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
// proof background verification hasn't reached yet — same "not spendable" treatment
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
VerifyingText = cache.PendingVerificationSats != 0
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
: "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear();
foreach (var tx in cache.History)
History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid));
tx.Txid,
tx.Verified));
Addresses.Clear();
foreach (var a in cache.Addresses)