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)
+59 -19
View File
@@ -273,32 +273,34 @@ public partial class MainWindowViewModel
_doc.Cache?.NextChangeIndex ?? 0,
net);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
// Progressive verification (§7.4): the wallet becomes usable as soon as
// transaction downloads finish, without waiting for every historical Merkle
// proof — critical on mobile, where verifying thousands of proofs can take far
// longer than the download itself. Never persisted to disk on its own (only the
// 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));
}
do
{
_resyncRequested = false;
var result = await _synchronizer.SyncOnceAsync(ct);
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
var (result, rawHex, verifiedAt, blockHeaders) = await Task.Run(async () =>
{
var r = await _synchronizer.SyncOnceAsync(ct);
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders);
}, ct);
_lastTransactions = result.Transactions;
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
PalladiumNetworks.For(_account.Profile.Kind));
_doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
};
WalletStore.Save(_doc, _walletPath!, _password);
var cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
FillDisplayFields(cache, result);
_doc.Cache = cache;
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache);
_syncFailed = false;
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
@@ -345,6 +347,44 @@ public partial class MainWindowViewModel
_ = ConnectAndSync();
}
/// <summary>
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
/// resume without re-downloading) were never written.
/// </summary>
private void ApplyPartialResult(SyncResult result)
{
if (_doc is null)
return;
var previous = _doc.Cache;
var cache = new SyncCache
{
RawTxHex = previous?.RawTxHex,
VerifiedAt = previous?.VerifiedAt,
BlockHeaders = previous?.BlockHeaders,
};
FillDisplayFields(cache, result);
_doc.Cache = cache;
_lastTransactions = result.Transactions;
ApplyCache(cache);
}
private static void FillDisplayFields(SyncCache cache, SyncResult result)
{
cache.TipHeight = result.TipHeight;
cache.ConfirmedSats = result.ConfirmedSats;
cache.UnconfirmedSats = result.UnconfirmedSats;
cache.ImmatureSats = result.ImmatureSats;
cache.PendingVerificationSats = result.PendingVerificationSats;
cache.SpendableSats = result.SpendableSats;
cache.NextReceiveIndex = result.NextReceiveIndex;
cache.NextChangeIndex = result.NextChangeIndex;
cache.History = [.. result.History];
cache.Utxos = [.. result.Utxos];
cache.Addresses = [.. result.AddressRows];
}
/// <summary>
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
/// that connection; otherwise it opens a short-lived connection to a candidate server
+1 -1
View File
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Transaction history row for the view.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
public sealed record AddressRow(
@@ -102,7 +102,8 @@ public sealed class TransactionDetailsViewModel
{
if (d.Confirmations <= 0)
return loc["tx.status.mempool"];
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
}
private string Signed(long sats)