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:
@@ -98,6 +98,16 @@ public sealed class SyncCache
|
||||
|
||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||
public long ImmatureSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public long PendingVerificationSats { get; set; }
|
||||
|
||||
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
|
||||
public long SpendableSats { get; set; }
|
||||
public int NextReceiveIndex { get; set; }
|
||||
public int NextChangeIndex { get; set; }
|
||||
public List<CachedTx> 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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
|
||||
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
|
||||
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -37,4 +37,12 @@ public static class WalletStore
|
||||
File.WriteAllText(tmp, content);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="Save"/> 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.
|
||||
/// </summary>
|
||||
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
|
||||
Task.Run(() => Save(doc, path, password));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user