From 65c2f5e6cd8f8d5bac06c77783ce796fb20549f7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 17 Jul 2026 16:24:38 +0200 Subject: [PATCH] temp --- SECURITY.md | 14 ++ src/App/Localization/Loc.cs | 2 + .../ViewModels/MainWindowViewModel.Receive.cs | 19 +- .../ViewModels/MainWindowViewModel.Sync.cs | 78 ++++-- src/App/ViewModels/MainWindowViewModel.cs | 2 +- .../ViewModels/TransactionDetailsViewModel.cs | 3 +- src/App/Views/MainView.axaml | 11 +- src/Cli/Program.cs | 8 +- src/Core/Net/ElectrumApi.cs | 20 ++ src/Core/Spv/WalletSynchronizer.cs | 232 ++++++++++++++---- src/Core/Storage/WalletDocument.cs | 24 ++ src/Core/Storage/WalletStore.cs | 8 + src/Core/Wallet/TransactionFactory.cs | 7 + src/Core/Wallet/UtxoSpendability.cs | 10 +- .../Spv/WalletSynchronizerTests.cs | 109 +++++++- .../Wallet/TransactionFactoryTests.cs | 22 +- 16 files changed, 487 insertions(+), 82 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ef6ed24..bb14333 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,6 +41,20 @@ It cannot (given correct Merkle verification): - Fabricate a confirmed transaction with a valid Merkle proof - Forge a payment to a wrong address +**Progressive verification (mobile-friendly sync).** On a wallet with many historical +transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof: +balance and history are shown as soon as transaction downloads finish (`PartialResult`, +`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and +each transaction's `Verified` flag catches up progressively. This means the UI can display a +server-reported balance/history that includes not-yet-verified entries — clearly marked with a +"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The +security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by +`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true, +regardless of confirmations — so a server that fabricates a fake confirmed balance can get it +*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync +fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of +a sync, never a partial one, so no unverified data survives a restart. + --- ## Key and seed management diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs index 06f0c97..8199797 100644 --- a/src/App/Localization/Loc.cs +++ b/src/App/Localization/Loc.cs @@ -395,6 +395,8 @@ public sealed class Loc ["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"], ["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"], ["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"], + ["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft"], + ["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…"], ["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."], ["msg.certreset"] = [ "Certificati SSL azzerati: riprova la connessione.", diff --git a/src/App/ViewModels/MainWindowViewModel.Receive.cs b/src/App/ViewModels/MainWindowViewModel.Receive.cs index 9408bb3..75aca21 100644 --- a/src/App/ViewModels/MainWindowViewModel.Receive.cs +++ b/src/App/ViewModels/MainWindowViewModel.Receive.cs @@ -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) diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs index 68e0be0..87c3c14 100644 --- a/src/App/ViewModels/MainWindowViewModel.Sync.cs +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -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(); } + /// + /// 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. + /// + 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]; + } + /// /// 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 diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index fd6b655..4665ff1 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet; namespace PalladiumWallet.App.ViewModels; /// Transaction history row for the view. -public sealed record HistoryRow(string Conferma, string Importo, string Txid); +public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true); /// Address view row with pre-computed keys and derivation path. public sealed record AddressRow( diff --git a/src/App/ViewModels/TransactionDetailsViewModel.cs b/src/App/ViewModels/TransactionDetailsViewModel.cs index 22e83f3..e23282b 100644 --- a/src/App/ViewModels/TransactionDetailsViewModel.cs +++ b/src/App/ViewModels/TransactionDetailsViewModel.cs @@ -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) diff --git a/src/App/Views/MainView.axaml b/src/App/Views/MainView.axaml index 6b4891f..b85f4ed 100644 --- a/src/App/Views/MainView.axaml +++ b/src/App/Views/MainView.axaml @@ -317,6 +317,9 @@ + @@ -373,13 +376,16 @@ - + + diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 8ec687d..bb29161 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -104,8 +104,9 @@ static int Info(string[] o) Console.WriteLine($"xpub: {doc.AccountXpub}"); if (doc.Cache is { } cache) { - Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendable" + Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable" + (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "") + + (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "") + (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : "")); Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions"); Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}"); @@ -149,6 +150,8 @@ static async Task Sync(string[] o) ConfirmedSats = result.ConfirmedSats, UnconfirmedSats = result.UnconfirmedSats, ImmatureSats = result.ImmatureSats, + PendingVerificationSats = result.PendingVerificationSats, + SpendableSats = result.SpendableSats, NextReceiveIndex = result.NextReceiveIndex, NextChangeIndex = result.NextChangeIndex, History = [.. result.History], @@ -160,8 +163,9 @@ static async Task Sync(string[] o) }; WalletStore.Save(doc, path, Opt(o, "--password")); - Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendable" + Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable" + (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "") + + (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "") + (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : "")); Console.WriteLine($"History ({result.History.Count}):"); foreach (var tx in result.History) diff --git a/src/Core/Net/ElectrumApi.cs b/src/Core/Net/ElectrumApi.cs index 18c46ef..4ada993 100644 --- a/src/Core/Net/ElectrumApi.cs +++ b/src/Core/Net/ElectrumApi.cs @@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa /// Merkle proof (blockchain.transaction.get_merkle). public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList Merkle); +/// Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers. +public readonly record struct HeaderRangeResponse(int Count, string Hex); + /// Chain tip notified by blockchain.headers.subscribe. public readonly record struct ChainTip(int Height, string HeaderHex); @@ -82,6 +85,23 @@ public static class ElectrumApi return r.GetString()!; } + /// + /// Range fetch (blockchain.block.headers): one RPC returns up to + /// concatenated 80-byte headers starting at . Used to anchor + /// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical + /// on high-latency links (mobile) where thousands of serial single-header calls stall sync. + /// The server may return fewer than requested (see ); + /// callers must loop until the full range is covered. + /// + public static async Task GetBlockHeadersAsync(this ElectrumClient c, + int startHeight, int count, CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count); + return new HeaderRangeResponse( + r.GetProperty("count").GetInt32(), + r.GetProperty("hex").GetString()!); + } + public static async Task BroadcastAsync(this ElectrumClient c, string rawTxHex, CancellationToken ct = default) { diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index 48ab348..7385735 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -29,6 +29,23 @@ public sealed class SyncResult /// . /// public required long ImmatureSats { get; init; } + + /// + /// Confirmed and past its threshold, but not yet spendable because its Merkle proof + /// hasn't been checked yet (§7.4 progressive verification catching up in the background). + /// Subset of — NOT disjoint from + /// (an immature coinbase can also be unverified), so never subtract both from + /// to get a spendable total — use . + /// + public required long PendingVerificationSats { get; init; } + + /// + /// Sum of UTXOs that actually pass right + /// now — the true spendable balance. Computed directly from the same gate coin selection + /// uses, rather than by subtracting / + /// from , since those two can overlap. + /// + public required long SpendableSats { get; init; } public required int NextReceiveIndex { get; init; } public required int NextChangeIndex { get; init; } public required IReadOnlyList History { get; init; } @@ -46,14 +63,37 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl /// Human-readable progress (for CLI and GUI status bar). public event Action? Progress; + /// + /// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish + /// (Merkle proofs may still be pending — see / + /// ) and again periodically as background verification + /// progresses (§7.4). The wallet is usable after the first firing instead of waiting for + /// every historical proof to be checked; 's returned Task still + /// only completes once verification is fully done, for callers that need the final state. + /// + public event Action? PartialResult; + private readonly ConcurrentDictionary _txCache = new(); - private readonly Dictionary _verifiedAtHeight = []; + + // Concurrent: written incrementally by individual merkle-verification tasks as they + // complete (§7.4 progressive verification), not just once after they all finish. + private readonly ConcurrentDictionary _verifiedAtHeight = new(); private readonly ConcurrentDictionary> _headerFetches = new(); // checkpoint height -> highest height already proven to hash-chain back to it // (in-memory only: cheap to recompute from _headerFetches, no need to persist). private readonly ConcurrentDictionary _anchoredUpTo = new(); + // Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx + // being verified) would otherwise race on overlapping ranges and issue duplicate range + // requests. Fetches are network-bound and few (batches of up to 2016 headers), so + // serializing them costs nothing that matters. + private readonly SemaphoreSlim _headerRangeLock = new(1, 1); + + // Max headers requested per blockchain.block.headers call. The server may return fewer + // (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned. + private const int HeaderBatchSize = 2016; + // Indices known from the previous sync: used by ScanChainAsync for incremental // discovery — already-used addresses are fetched in a single burst instead of // sequential batches, reducing round-trips from O(used/gapLimit) to O(1). @@ -162,7 +202,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl foreach (var item in historyByAddress.Values.SelectMany(h => h)) txHeights[item.TxHash] = item.Height; - // 4+5. Download missing transactions and verify Merkle proofs in parallel. + // 4. Download missing transactions — needed to compute amounts/UTXOs locally. + // Merkle-proof verification (5) is deliberately NOT awaited together with this: on a + // wallet with thousands of transactions, downloads finish in seconds while proofs can + // take much longer over a high-latency link, and the wallet has everything it needs to + // show balance/history the moment downloads are done. Verification then continues in + // the background (§7.4 progressive verification), firing PartialResult as proofs land, + // while coin selection stays locked out of any UTXO until its own proof is checked + // (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server + // cannot get a fabricated balance spent just because it was shown early. var network = PalladiumNetworks.For(account.Profile.Kind); var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList(); var toVerify = txHeights @@ -171,46 +219,81 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl .ToList(); if (missing.Count > 0 || toVerify.Count > 0) - { Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…"); - var dlDone = 0; - var merkDone = 0; - var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () => - { - var raw = await client.GetTransactionAsync(txid, ct); - _txCache[txid] = Transaction.Parse(raw, network); - var n = Interlocked.Increment(ref dlDone); - if (n % 50 == 0 || n == missing.Count) - Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…"); - }, ct)); + var dlDone = 0; + await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () => + { + var raw = await client.GetTransactionAsync(txid, ct); + _txCache[txid] = Transaction.Parse(raw, network); + var n = Interlocked.Increment(ref dlDone); + if (n % 50 == 0 || n == missing.Count) + Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…"); + }, ct))); - var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () => - { - var (txid, height) = kv; - var proofTask = client.GetMerkleAsync(txid, height, ct); - var headerTask = _headerFetches.GetOrAdd(height, - h => client.GetBlockHeaderAsync(h, ct)); - var proof = await proofTask; - var header = BlockHeaderInfo.Parse(await headerTask); - await AnchorToCheckpointAsync(height, ct); - if (!MerkleProof.Verify( - uint256.Parse(txid), proof.Pos, - proof.Merkle.Select(uint256.Parse), header.MerkleRoot)) - throw new SpvVerificationException( - $"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy."); - var n = Interlocked.Increment(ref merkDone); - if (n % 50 == 0 || n == toVerify.Count) - Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…"); - }, ct)); + SyncResult BuildSnapshot() => + BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange); - await Task.WhenAll(dlTasks.Concat(merkTasks)); - foreach (var (txid, height) in toVerify) - _verifiedAtHeight[txid] = height; - } + PartialResult?.Invoke(BuildSnapshot()); + var merkDone = 0; + var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () => + { + var (txid, height) = kv; + var proofTask = client.GetMerkleAsync(txid, height, ct); + // Anchor first: on a checkpointed height this fills _headerFetches[height] + // via the batched range fetch (§7.3), so the header lookup below is a cache + // hit instead of a second individual blockchain.block.header RPC per tx — + // halves round-trips for this stage on mainnet, where it matters most on + // high-latency mobile links. Falls back to an individual fetch when no + // checkpoint covers this height (testnet/regtest today). + await AnchorToCheckpointAsync(height, ct); + var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct)); + var header = BlockHeaderInfo.Parse(headerHex); + var proof = await proofTask; + if (!MerkleProof.Verify( + uint256.Parse(txid), proof.Pos, + proof.Merkle.Select(uint256.Parse), header.MerkleRoot)) + throw new SpvVerificationException( + $"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy."); + _verifiedAtHeight[txid] = height; + var n = Interlocked.Increment(ref merkDone); + if (n % 50 == 0 || n == toVerify.Count) + Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…"); + if (n % PartialResultBatchSize == 0) + PartialResult?.Invoke(BuildSnapshot()); + }, ct)).ToList(); + + if (merkTasks.Count > 0) + await Task.WhenAll(merkTasks); + + return BuildSnapshot(); + } + + // Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on + // every single verified proof would make the background verification phase itself O(n²) for + // a wallet with thousands of transactions. Batching keeps "verified" badges catching up + // visibly without that cost. + private const int PartialResultBatchSize = 200; + + private bool IsTxVerified(string txid, int height) => + height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height); + + /// + /// Assembles a from the current state of and + /// . Callable multiple times per sync (§7.4): once as soon as + /// transaction downloads finish (proofs still pending), and again as verification progresses, + /// each time reflecting whichever transactions have been proof-checked so far. + /// + private SyncResult BuildResult( + int tipHeight, + List tracked, + Dictionary> historyByAddress, + Dictionary txHeights, + int nextReceive, + int nextChange) + { var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]); - var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0); // 6. Local UTXO reconstruction. var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t); @@ -222,6 +305,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl var utxos = new List(); foreach (var (txid, tx) in transactions) { + var height = txHeights[txid]; + var verifiedTx = IsTxVerified(txid, height); for (var vout = 0; vout < tx.Outputs.Count; vout++) { var output = tx.Outputs[vout]; @@ -237,8 +322,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl Address = addr.Address.ToString(), IsChange = addr.IsChange, AddressIndex = addr.Index, - Height = txHeights[txid], + Height = height, IsCoinbase = tx.IsCoinBase, + Verified = verifiedTx, }); } } @@ -247,6 +333,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl var history = new List(); foreach (var (txid, tx) in transactions) { + var height = txHeights[txid]; var received = tx.Outputs .Where(o => byScript.ContainsKey(o.ScriptPubKey)) .Sum(o => o.Value.Satoshi); @@ -257,9 +344,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl history.Add(new CachedTx { Txid = txid, - Height = txHeights[txid], + Height = height, DeltaSats = received - sentSats, - Verified = verified[txid], + Verified = IsTxVerified(txid, height), }); } history.Sort((a, b) => @@ -286,12 +373,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl return new SyncResult { - TipHeight = tip.Height, + TipHeight = tipHeight, ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats), UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats), ImmatureSats = utxos.Where(u => - u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile)) + u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile)) .Sum(u => u.ValueSats), + PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats), + SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats), NextReceiveIndex = nextReceive, NextChangeIndex = nextChange, History = history, @@ -325,9 +414,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height) return; - var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1) - .Select(async h => BlockHeaderInfo.Parse( - await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct))))); + await FetchHeaderRangeAsync(cp.Height, height, ct); + + var headers = Enumerable.Range(cp.Height, height - cp.Height + 1) + .Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result)) + .ToArray(); if (!headers[0].MatchesCheckpoint(cp)) throw new SpvVerificationException( @@ -341,6 +432,41 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl _anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height)); } + /// + /// Ensures every height in [, ] + /// is present in , downloading gaps with + /// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height. + /// + private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct) + { + await _headerRangeLock.WaitAsync(ct); + try + { + var h = fromHeight; + while (h <= toHeightInclusive) + { + if (_headerFetches.ContainsKey(h)) { h++; continue; } + + var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1); + var range = await client.GetBlockHeadersAsync(h, requested, ct); + if (range.Count == 0) + throw new SpvVerificationException( + $"Server returned no headers starting at height {h}: server is not trustworthy."); + + for (var i = 0; i < range.Count; i++) + { + var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2); + _headerFetches.TryAdd(h + i, Task.FromResult(headerHex)); + } + h += range.Count; + } + } + finally + { + _headerRangeLock.Release(); + } + } + /// /// Scans one chain (receiving or change). /// @@ -414,7 +540,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl return (firstUnused, tracked, history); } - private static async Task RetryOnBusyAsync(Func op, CancellationToken ct) + // Counts "server busy" retries across the whole sync: surfaced via Progress so a slow + // sync can be diagnosed as server-side throttling (exponential backoff eating the time) + // rather than guessed at from wall-clock numbers alone. + private int _busyRetries; + + private async Task RetryOnBusyAsync(Func op, CancellationToken ct) { var delay = 200; for (var attempt = 0; ; attempt++) @@ -423,13 +554,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl catch (ElectrumServerException ex) when (IsBusy(ex) && attempt < 7) { + ReportBusyRetry(delay); await Task.Delay(delay, ct); delay = Math.Min(delay * 2, 5_000); } } } - private static async Task RetryOnBusyAsync(Func> op, CancellationToken ct) + private async Task RetryOnBusyAsync(Func> op, CancellationToken ct) { var delay = 200; for (var attempt = 0; ; attempt++) @@ -438,12 +570,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl catch (ElectrumServerException ex) when (IsBusy(ex) && attempt < 7) { + ReportBusyRetry(delay); await Task.Delay(delay, ct); delay = Math.Min(delay * 2, 5_000); } } } + private void ReportBusyRetry(int delayMs) + { + var n = Interlocked.Increment(ref _busyRetries); + if (n == 1 || n % 20 == 0) + Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…"); + } + private static bool IsBusy(ElectrumServerException ex) => ex.Message.Contains("-102") || ex.Message.Contains("-101") || diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index 793fb57..a9d8d78 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -98,6 +98,16 @@ public sealed class SyncCache /// Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats. public long ImmatureSats { get; set; } + + /// + /// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of + /// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from + /// ConfirmedSats to get a spendable total; use SpendableSats instead. + /// + public long PendingVerificationSats { get; set; } + + /// The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable. + public long SpendableSats { get; set; } public int NextReceiveIndex { get; set; } public int NextChangeIndex { get; set; } public List History { get; set; } = []; @@ -141,6 +151,13 @@ public sealed class CachedTx public required string Txid { get; set; } public int Height { get; set; } public long DeltaSats { get; set; } + + /// + /// True once this tx's Merkle proof has actually been checked against a + /// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still + /// be pending its own proof while background verification catches up). Always + /// false for unconfirmed (mempool) entries, which have no proof to check yet. + /// public bool Verified { get; set; } } @@ -155,4 +172,11 @@ public sealed class CachedUtxo public int Height { get; set; } public bool IsCoinbase { get; set; } public bool Frozen { get; set; } + + /// + /// True once the owning tx's Merkle proof has been checked (see ). + /// Gates spendability in : a server can report a + /// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early. + /// + public bool Verified { get; set; } } diff --git a/src/Core/Storage/WalletStore.cs b/src/Core/Storage/WalletStore.cs index b2d7aef..45dc1a8 100644 --- a/src/Core/Storage/WalletStore.cs +++ b/src/Core/Storage/WalletStore.cs @@ -37,4 +37,12 @@ public static class WalletStore File.WriteAllText(tmp, content); File.Move(tmp, path, overwrite: true); } + + /// + /// Same as but with the JSON serialization and disk write off the + /// calling thread — for callers on a UI thread saving a large cache (thousands of cached + /// transactions/headers), where the synchronous version would block the UI. + /// + public static Task SaveAsync(WalletDocument doc, string path, string? password = null) => + Task.Run(() => Save(doc, path, password)); } diff --git a/src/Core/Wallet/TransactionFactory.cs b/src/Core/Wallet/TransactionFactory.cs index bb25ddc..86ba59b 100644 --- a/src/Core/Wallet/TransactionFactory.cs +++ b/src/Core/Wallet/TransactionFactory.cs @@ -82,6 +82,13 @@ public sealed class TransactionFactory(IWalletAccount account) reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). "); } + var unverified = utxos.Where(u => + !u.Frozen && u.Height > 0 && !u.Verified && + u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList(); + if (unverified.Count > 0) + reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " + + $"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). "); + throw new WalletSpendException(reasons.Length > 0 ? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}" : "No spendable UTXOs selected."); diff --git a/src/Core/Wallet/UtxoSpendability.cs b/src/Core/Wallet/UtxoSpendability.cs index 2b41d75..5f97b55 100644 --- a/src/Core/Wallet/UtxoSpendability.cs +++ b/src/Core/Wallet/UtxoSpendability.cs @@ -18,7 +18,13 @@ public static class UtxoSpendability public static int Confirmations(this CachedUtxo utxo, int tipHeight) => utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1; - /// True when the UTXO has met its confirmation threshold and is not frozen. + /// + /// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle + /// proof has actually been checked. Without the gate a + /// malicious server could report a fake confirmed UTXO and have it spent before background + /// verification ever caught the forgery. + /// public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) => - !utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile); + !utxo.Frozen && utxo.Height > 0 && utxo.Verified + && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile); } diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs index 8dd58a8..90e6750 100644 --- a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs @@ -101,6 +101,15 @@ public class WalletSynchronizerTests Headers.TryGetValue(p[0].GetInt32(), out var hex) ? hex : throw new FakeElectrumError(-32600, "no such block")); + server.Handle("blockchain.block.headers", p => + { + var start = p[0].GetInt32(); + var count = p[1].GetInt32(); + var hexes = new List(); + for (var h = start; hexes.Count < count && Headers.TryGetValue(h, out var hex); h++) + hexes.Add(hex); + return new { count = hexes.Count, hex = string.Concat(hexes) }; + }); } } @@ -232,7 +241,10 @@ public class WalletSynchronizerTests Assert.Equal(0, result.ConfirmedSats); Assert.Equal(250_000, result.UnconfirmedSats); var entry = Assert.Single(result.History); - Assert.False(entry.Verified); + // Verified means "its Merkle proof was checked" — a mempool tx has no proof to + // check yet, so it's vacuously true; "not confirmed" is signalled by Height <= 0, + // not by Verified (no merkle/header RPCs happen, asserted below). + Assert.True(entry.Verified); Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle")); Assert.Equal(0, server.CallCount("blockchain.block.header")); } @@ -318,8 +330,11 @@ public class WalletSynchronizerTests var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync(); Assert.Equal(1_000_000, result.ConfirmedSats); - // 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint. - Assert.Equal(6, server.CallCount("blockchain.block.header")); + // Anchoring runs before the header lookup, so the range call (blockchain.block.headers) + // covering 100..105 back to the checkpoint already includes the tx's own header — + // no separate single-header call needed. + Assert.Equal(0, server.CallCount("blockchain.block.header")); + Assert.Equal(1, server.CallCount("blockchain.block.headers")); } [Fact] @@ -352,12 +367,13 @@ public class WalletSynchronizerTests await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105 // 103 <= the memoized 105: anchoring must early-return without re-walking, - // so the header call count stays at the 6 of the first walk (103 is cached). + // so the header/range call counts stay at those of the first walk (103 is cached). scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1)); var result = await sync.SyncOnceAsync(); Assert.Equal(1_500_000, result.ConfirmedSats); - Assert.Equal(6, server.CallCount("blockchain.block.header")); + Assert.Equal(0, server.CallCount("blockchain.block.header")); + Assert.Equal(1, server.CallCount("blockchain.block.headers")); } [Fact] @@ -426,6 +442,89 @@ public class WalletSynchronizerTests Assert.Equal(1, server.CallCount("blockchain.block.header")); } + // ---- verifica progressiva (§7.4) ---- + + [Fact] + public async Task PartialResult_arriva_prima_della_proof_poi_il_risultato_finale_e_completamente_verificato() + { + var account = Account(); + var scenario = new Scenario(); + scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100); + + var (server, client) = await StartAsync(scenario); + await using var _ = server; await using var __ = client; + + // Gate the Merkle-proof response so the download phase can complete (and fire + // PartialResult) well before verification does. + using var gate = new ManualResetEventSlim(false); + server.Handle("blockchain.transaction.get_merkle", p => + { + gate.Wait(); + return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty() }; + }); + + var sync = new WalletSynchronizer(account, client); + SyncResult? partial = null; + var partialReceived = new TaskCompletionSource(); + sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); }; + + var syncTask = sync.SyncOnceAsync(); + await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(partial); + Assert.False(Assert.Single(partial!.History).Verified); + Assert.False(Assert.Single(partial.Utxos).Verified); + Assert.Equal(1_000_000, partial.PendingVerificationSats); + Assert.Equal(1_000_000, partial.ConfirmedSats); // reported confirmed, just not yet proof-checked + + gate.Set(); + var final = await syncTask; + + Assert.True(Assert.Single(final.History).Verified); + Assert.True(Assert.Single(final.Utxos).Verified); + Assert.Equal(0, final.PendingVerificationSats); + } + + [Fact] + public async Task Un_coinbase_immaturo_e_non_ancora_verificato_non_produce_un_saldo_spendibile_negativo() + { + // Regression: ImmatureSats and PendingVerificationSats can overlap (an immature + // coinbase can also be unverified) — "ConfirmedSats - ImmatureSats - PendingVerificationSats" + // double-subtracts that overlap and can go negative. SpendableSats must be computed + // directly from IsSpendable instead. + var account = Account(); + var scenario = new Scenario { TipHeight = 200 }; + // 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121 — immature. + scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true); + + var (server, client) = await StartAsync(scenario); + await using var _ = server; await using var __ = client; + + using var gate = new ManualResetEventSlim(false); + server.Handle("blockchain.transaction.get_merkle", p => + { + gate.Wait(); + return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty() }; + }); + + var sync = new WalletSynchronizer(account, client); + var partialReceived = new TaskCompletionSource(); + SyncResult? partial = null; + sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); }; + + var syncTask = sync.SyncOnceAsync(); + await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Immature AND unverified at the same time: both non-spendable subsets are the + // full 5 PLM, but the actual spendable balance must be exactly zero, not negative. + Assert.Equal(5_000_000_000, partial!.ImmatureSats); + Assert.Equal(5_000_000_000, partial.PendingVerificationSats); + Assert.Equal(0, partial.SpendableSats); + + gate.Set(); + await syncTask; + } + // ---- resilienza ---- [Fact] diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs index c21b1ea..278267c 100644 --- a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs @@ -33,12 +33,28 @@ public class TransactionFactoryTests { Txid = txid, Vout = 0, ValueSats = sats, Address = account.GetReceiveAddress(0).ToString(), - IsChange = false, AddressIndex = 0, Height = 100, + IsChange = false, AddressIndex = 0, Height = 100, Verified = true, }, }; return (utxos, new Dictionary { [txid] = funding }); } + [Fact] + public void Un_utxo_confermato_ma_non_ancora_verificato_non_e_spendibile() + { + // Confirmed by the server, but its Merkle proof hasn't been checked yet (progressive + // background verification, §7.4): must never be treated as spendable — otherwise a + // malicious server could get a fabricated balance spent before the forgery is caught. + var account = Account(); + var (utxos, txs) = Fund(account, 1_000_000); + utxos[0].Verified = false; + + var ex = Assert.Throws(() => new TransactionFactory(account).Build( + utxos, txs, account.GetReceiveAddress(5), amountSats: 400_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100)); + Assert.Contains("awaiting Merkle-proof verification", ex.Message); + } + [Fact] public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa() { @@ -169,7 +185,7 @@ public class TransactionFactoryTests { new() { Txid = txid, Vout = 0, ValueSats = 1_000_000, Address = mainnetAccount.GetReceiveAddress(0).ToString(), - IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false }, + IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false, Verified = true }, }; var txs = new Dictionary { [txid] = funding }; @@ -295,7 +311,7 @@ public class TransactionFactoryTests { Txid = txid, Vout = 0, ValueSats = 300_000, Address = account.GetReceiveAddress(i).ToString(), - IsChange = false, AddressIndex = i, Height = 100, + IsChange = false, AddressIndex = i, Height = 100, Verified = true, }); }