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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user