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,
});
}