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:
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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user