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:
davide committed 2026-07-17 19:43:18 +02:00
1 parent feb765663c
commit 3460e53b4f
16 files changed
+487 -82

No files matched your search

+20
View File
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
public readonly record struct HeaderRangeResponse(int Count, string Hex);
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
public readonly record struct ChainTip(int Height, string HeaderHex);
@@ -82,6 +85,23 @@ public static class ElectrumApi
return r.GetString()!;
}
/// <summary>
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. 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 <see cref="HeaderRangeResponse.Count"/>);
/// callers must loop until the full range is covered.
/// </summary>
public static async Task<HeaderRangeResponse> 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<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
CancellationToken ct = default)
{