feat(spv): cross-check recent headers with a witness server

Blocks above the newest checkpoint cannot be anchored, so the header from
the primary server is now compared with the one from a second server taken
from the hardcoded bootstrap list. A disagreement at 3+ blocks of depth
aborts the sync; a shallower one leaves the proof unverified until the next
sync; with no reachable witness behaviour is unchanged. A bounded
proof-of-work check was evaluated and rejected. See ADR 0042.
This commit is contained in:
davide committed 2026-11-03 15:29:29 +01:00
1 parent 305f22ed0e
commit 9010ebce59
11 files changed
+430 -4

No files matched your search

@@ -271,6 +271,8 @@ public partial class MainWindowViewModel
if (_synchronizer is null)
{
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
var primary = _client!;
_synchronizer.WitnessProvider = _ => ConnectWitnessesAsync(primary);
// Reload from disk cache: avoids re-downloading already known transactions
// (essential for wallets with thousands of historical txs — prevents -101).
var net = PalladiumNetworks.For(_account.Profile.Kind);
@@ -544,8 +546,35 @@ public partial class MainWindowViewModel
catch { /* non-fatal: the next full save will recover */ }
}
// Witness servers for cross-checking recent headers (ADR 0042): opened lazily by the
// synchronizer, only from the hardcoded bootstrap list, and closed with the primary connection.
private readonly List<ElectrumClient> _witnesses = [];
private async Task<IReadOnlyList<ElectrumClient>> ConnectWitnessesAsync(ElectrumClient primary)
{
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
var list = await WitnessConnector.ConnectAsync(Profile, Profile.BootstrapServers,
primary.Host, primary.Port, UseSsl, pins);
lock (_witnesses)
_witnesses.AddRange(list);
return list;
}
private void DisposeWitnesses()
{
ElectrumClient[] toClose;
lock (_witnesses)
{
toClose = [.. _witnesses];
_witnesses.Clear();
}
foreach (var w in toClose)
_ = w.DisposeAsync().AsTask();
}
private async Task DisconnectAsync()
{
DisposeWitnesses();
if (_client is { } client)
{
_client = null;
@@ -267,6 +267,7 @@ public partial class MainWindowViewModel : ViewModelBase
_walletLock?.Dispose();
_walletLock = null;
_ = _client?.DisposeAsync().AsTask();
DisposeWitnesses();
_client = null;
_synchronizer = null;
_autoReconnect = false;
+14 -1
View File
@@ -159,6 +159,13 @@ static async Task<int> Sync(string[] o)
await using var client = await Connect(o, account.Profile);
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
IReadOnlyList<ElectrumClient> witnesses = [];
sync.WitnessProvider = async ct =>
{
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(account.Profile.Kind));
return witnesses = await WitnessConnector.ConnectAsync(account.Profile,
account.Profile.BootstrapServers, client.Host, client.Port, client.UseSsl, pins, ct: ct);
};
sync.Progress += msg => Console.WriteLine($" {msg}");
var net = PalladiumNetworks.For(account.Profile.Kind);
sync.PreloadCaches(
@@ -169,7 +176,13 @@ static async Task<int> Sync(string[] o)
doc.Cache?.NextChangeIndex ?? 0,
net,
doc.Cache?.AnchoredDownTo);
var result = await sync.SyncOnceAsync();
SyncResult result;
try { result = await sync.SyncOnceAsync(); }
finally
{
foreach (var w in witnesses)
await w.DisposeAsync();
}
var (rawHex, verifiedAt, blockHeaders, anchoredDownTo) = sync.ExportCaches(net);
doc.Cache = new SyncCache
+86 -1
View File
@@ -63,6 +63,24 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
public event Action<string>? Progress;
/// <summary>
/// Supplies the witness servers used to cross-check headers of blocks above the newest
/// checkpoint, which cannot be anchored (ADR 0042). Called at most once, lazily, the first
/// time such a header is needed; null or an empty result means no cross-check.
/// </summary>
public Func<CancellationToken, Task<IReadOnlyList<ElectrumClient>>>? WitnessProvider { get; set; }
/// <summary>
/// Below this depth (tip − height) a disagreement between servers is treated as a possible
/// reorganisation in progress, not as a forgery: the proof is left unverified until the next sync.
/// </summary>
public const int CrossCheckMinDepth = 3;
private readonly object _witnessLock = new();
private Task<IReadOnlyList<ElectrumClient>>? _witnesses;
private readonly ConcurrentDictionary<int, Task> _crossChecks = new();
private int _tipHeight;
/// <summary>
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
@@ -186,6 +204,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
{
var tip = await client.SubscribeHeadersAsync(ct);
_tipHeight = tip.Height;
Progress?.Invoke($"chain tip: {tip.Height}");
// 1-2. Address scanning.
@@ -295,7 +314,13 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
// 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);
try { await AnchorToCheckpointAsync(height, ct); }
catch (CrossCheckInconclusiveException)
{
// A recent block on which the servers disagree: maybe a reorganisation in progress.
// Leave this proof unverified (not spendable) and decide again at the next sync.
return;
}
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
var header = BlockHeaderInfo.Parse(headerHex);
var proof = await proofTask;
@@ -460,7 +485,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (c.Height >= height && (upper is not { } best || c.Height < best.Height))
upper = c;
if (upper is not { } cp)
{
await CrossCheckAsync(height, ct);
return;
}
_anchoredDownTo.TryGetValue(cp.Height, out var anchoredFrom);
var alreadyAnchored = _anchoredDownTo.ContainsKey(cp.Height);
@@ -488,6 +516,60 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
_anchoredDownTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Min(existing, height));
}
/// <summary>
/// For a block above the newest checkpoint: ask the witness servers for the same header and
/// require the same bytes (ADR 0042). A witness that is unreachable or does not know the
/// block is skipped; no witness at all leaves only the Merkle proof, as before. A
/// disagreement at a safe depth is a forgery or a fork: the sync aborts. At a shallow depth it
/// may be a reorganisation, so it is only inconclusive.
/// </summary>
private async Task CrossCheckAsync(int height, CancellationToken ct)
{
if (WitnessProvider is null)
return;
var check = _crossChecks.GetOrAdd(height, h => CrossCheckCoreAsync(h, ct));
try
{
await check;
}
catch (Exception ex) when (ex is CrossCheckInconclusiveException or OperationCanceledException)
{
_crossChecks.TryRemove(height, out _); // retry at the next sync / with another token
throw;
}
}
private async Task CrossCheckCoreAsync(int height, CancellationToken ct)
{
Task<IReadOnlyList<ElectrumClient>> witnessesTask;
lock (_witnessLock)
witnessesTask = _witnesses ??= WitnessProvider!(CancellationToken.None);
var witnesses = await witnessesTask;
if (witnesses.Count == 0)
return;
var ours = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
foreach (var witness in witnesses)
{
string theirs;
try
{
theirs = await witness.GetBlockHeaderAsync(height, ct);
}
catch (Exception ex) when (ex is ElectrumServerException or IOException or TimeoutException
or InvalidOperationException or ObjectDisposedException)
{
continue; // this witness cannot say: skip it
}
if (string.Equals(theirs, ours, StringComparison.OrdinalIgnoreCase))
continue;
if (_tipHeight - height < CrossCheckMinDepth)
throw new CrossCheckInconclusiveException();
throw new SpvVerificationException(
$"Servers disagree about the header at height {height}: server is not trustworthy.");
}
}
/// <summary>
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
/// is present in <see cref="_headerFetches"/>, downloading gaps with
@@ -649,3 +731,6 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// <summary>SPV verification failed: server data contradicts the proofs (§17).</summary>
public sealed class SpvVerificationException(string message) : Exception(message);
/// <summary>A recent header on which two servers disagree: retry later, do not mark the proof verified.</summary>
internal sealed class CrossCheckInconclusiveException : Exception;
+54
View File
@@ -0,0 +1,54 @@
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Core.Spv;
/// <summary>
/// Opens the "witness" connections used to cross-check recent headers (ADR 0042): servers
/// other than the primary one, taken only from the hardcoded bootstrap list (never from the peers
/// a server announces, which a hostile server could fill with its own), each verified to be on the
/// profile's chain before use.
/// </summary>
public static class WitnessConnector
{
/// <summary>One witness is enough to turn a single lying server into a detectable disagreement.</summary>
public const int DefaultMax = 1;
/// <summary>
/// Connects up to <paramref name="max"/> witnesses among <paramref name="candidates"/>,
/// skipping the primary server. Unreachable, wrong-chain or otherwise failing candidates are
/// skipped; an empty result means no cross-check is possible this time.
/// </summary>
public static async Task<IReadOnlyList<ElectrumClient>> ConnectAsync(ChainProfile profile,
IEnumerable<ServerEndpoint> candidates, string primaryHost, int primaryPort, bool useSsl,
CertificatePinStore? pins, int max = DefaultMax, CancellationToken ct = default)
{
var witnesses = new List<ElectrumClient>();
foreach (var endpoint in candidates)
{
if (witnesses.Count >= max)
break;
var port = useSsl ? endpoint.SslPort : endpoint.TcpPort;
if (string.Equals(endpoint.Host, primaryHost, StringComparison.OrdinalIgnoreCase) && port == primaryPort)
continue;
ElectrumClient? client = null;
try
{
client = await ElectrumClient.ConnectAsync(endpoint.Host, port, useSsl, pins, ct);
await client.VerifyChainAsync(profile, ct);
witnesses.Add(client);
}
catch (OperationCanceledException)
{
if (client is not null) await client.DisposeAsync();
throw;
}
catch
{
// Best effort: this candidate is unusable, try the next one.
if (client is not null) await client.DisposeAsync();
}
}
return witnesses;
}
}