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

+6
View File
@@ -14,6 +14,12 @@ release and covers the full history from the initial commit.
### Added
- Recent blocks (above the newest checkpoint, which cannot be anchored) are now cross-checked with
a second server: the header at the same height must be identical. The witness is taken only from
the hardcoded bootstrap servers, one extra pinned connection opened on demand. A disagreement at
3+ blocks of depth aborts the sync; a shallower one leaves the proof unverified until the next
sync (possible reorganisation); with no reachable witness behaviour is unchanged. A bounded
proof-of-work check was evaluated and rejected (see ADR 0042).
- History rows and the transaction detail now flag a confirmed transaction in a block above the
newest hardcoded checkpoint as **not anchored** (orange label with an explanatory tooltip).
Such a block cannot be chained to a checkpoint (ADR 0040), so only the Merkle proof against the
+1 -1
View File
@@ -21,7 +21,7 @@ It does **not** protect against:
This wallet is an SPV client, not a full node. It validates:
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) **up to the nearest hardcoded checkpoint at or above its height**, and the header at that checkpoint height must hash to the checkpoint exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`, ADR 0040). The direction matters: with PoW skipped, a chain built *from* a checkpoint *up to* a header proves nothing, because anyone can fabricate such a chain; a chain ending in a known hash cannot be forged without inverting SHA-256. Mainnet checkpoints run every 20,000 blocks from 20,000 (so even the oldest blocks are covered) and are extended at each release. **Known limit:** a transaction in a block *above the highest checkpoint* has no checkpoint to anchor to, so only its Merkle proof against the header the server supplied is checked there; the window is the blocks mined since the last release. The wallet flags such transactions as **not anchored** in the history list and in the transaction detail (ADR 0041), so the reduced guarantee is visible instead of silent. Testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) **up to the nearest hardcoded checkpoint at or above its height**, and the header at that checkpoint height must hash to the checkpoint exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`, ADR 0040). The direction matters: with PoW skipped, a chain built *from* a checkpoint *up to* a header proves nothing, because anyone can fabricate such a chain; a chain ending in a known hash cannot be forged without inverting SHA-256. Mainnet checkpoints run every 20,000 blocks from 20,000 (so even the oldest blocks are covered) and are extended at each release. **Known limit:** a transaction in a block *above the highest checkpoint* has no checkpoint to anchor to, so only its Merkle proof against the header the server supplied is checked there; the window is the blocks mined since the last release. The wallet flags such transactions as **not anchored** in the history list and in the transaction detail (ADR 0041), so the reduced guarantee is visible instead of silent. For those blocks the wallet also cross-checks the header with a second server taken from the hardcoded bootstrap list (never from announced peers): a different header at a safe depth (3+ blocks) aborts the sync, a shallower disagreement leaves the proof unverified until the next sync, and with no reachable witness only the Merkle proof applies (ADR 0042). This raises the bar from one lying server to two; it does not help against two colluding servers or an attacker in front of both. Testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
It does **not** validate:
+1 -1
View File
@@ -585,7 +585,7 @@ checkpoint (shipped with each release) cannot be anchored this way: for those on
proof is checked, which is why updating the wallet regularly matters. Such transactions carry an
orange **"not anchored"** label in the History tab and in the transaction detail (hover it for
the explanation); it disappears once you update to a release whose newest checkpoint covers
that block. The label is informational: the transaction is still Merkle-verified. Verified data is cached in the wallet file, so later syncs only fetch what
that block. The label is informational: the transaction is still Merkle-verified, and its block header is also compared with a second built-in server when one is reachable (if they disagree about a block that is a few blocks deep the sync stops with a "server is not trustworthy" error; for a very recent block the transaction just stays "verifying" until the next sync). Verified data is cached in the wallet file, so later syncs only fetch what
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
unverified data; switch servers.
@@ -0,0 +1,35 @@
# 0042. Cross-check recent headers with a witness server; no bounded-PoW check
- **Status:** Accepted
- **Area:** SPV
- **Recorded:** 2026-11-03
## Context
ADR 0040 anchors every header used for a Merkle proof up to a hardcoded checkpoint, but a block above the newest checkpoint has nothing to anchor to: only the header the primary server supplies and the Merkle proof against it are checked. A hostile server could show a payment that never happened in those recent blocks. ADR 0041 made the limit visible; this ADR reduces it.
## Decision
- **Witness cross-check.** For a block above the newest checkpoint, `WalletSynchronizer` asks a second server (a "witness") for the header at the same height and requires identical bytes. The witnesses come from `WalletSynchronizer.WitnessProvider`, called lazily once, only when such a header is first needed.
- **Witness selection** (`WitnessConnector`): only from the **hardcoded bootstrap list** of the profile, never from the peers a server announces (a hostile server could announce only its own), never the primary server, each verified to be on the chain (ADR 0036). One witness by default (`DefaultMax`). Used by the GUI and the CLI; the GUI closes them with the primary connection.
- **Outcomes.** Agreement: the proof verifies as before. A witness that is unreachable, errors, or does not know the block: skipped. No witness at all: unchanged behaviour (Merkle proof only). Disagreement at a depth of at least `CrossCheckMinDepth` (3) blocks: `SpvVerificationException` ("servers disagree"), the sync aborts. Disagreement at a shallower depth may be a reorganisation in progress: the proof is left **unverified** (not spendable, not cached) and decided again at the next sync.
- Each height is asked once per synchronizer. Heights covered by a checkpoint are not cross-checked.
- The "not anchored" flag of ADR 0041 stays height-based: it is not cleared by a successful cross-check, because that result is not persisted.
## Rejected: bounded proof-of-work
The idea was to require recent headers to meet a target no easier than a bound on the newest checkpoint's difficulty. The data in the shipped checkpoints argue against it: between consecutive checkpoints (20,000 blocks) the target changed by up to 8x easier and 200x harder, so a safe bound would be loose; the network is small (about tens of TH/s at the last checkpoint), so work at a fraction of a block's difficulty is cheap to rent; and a bound that is too tight would make the wallet reject honest servers whenever hash rate drops. It would add cost and a false-rejection risk for little protection. Not implemented.
## Consequences
- A single lying server can no longer fabricate a recent payment on its own: it would have to control the witness too. A witness run by the same operator as the primary, or an attacker positioned in front of both, defeats it; this is a hardening, not a proof.
- One extra connection (TLS, pinned) is opened when a recent block needs checking; none for wallets whose history is all below the newest checkpoint.
- A transaction in a block 1-2 deep on which the servers briefly disagree shows as "verifying" until the next sync.
- A fresh checkpoint in every release still shrinks the window the cross-check has to cover.
## References
- `src/Core/Spv/WalletSynchronizer.cs`
- `src/Core/Spv/WitnessConnector.cs`
- `tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs`
- Related: 0036, 0040, 0041
+1
View File
@@ -45,6 +45,7 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf
| 0039 | [Server fee estimates, relay floor and a high-fee warning](0039-server-fee-estimates-and-relay-floor.md) | Wallet | Accepted |
| 0040 | [Anchor headers up to a checkpoint above them](0040-anchor-headers-up-to-a-checkpoint.md) | SPV | Accepted |
| 0041 | [Flag transactions above the newest checkpoint as not anchored](0041-flag-transactions-above-the-newest-checkpoint.md) | SPV | Accepted |
| 0042 | [Cross-check recent headers with a witness server; no bounded-PoW check](0042-cross-check-recent-headers-with-a-witness-server.md) | SPV | Accepted |
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
If one misstates the original reasoning, fix it in a pull request.
@@ -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;
}
}
@@ -852,4 +852,206 @@ public class WalletSynchronizerTests
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
Assert.Equal(1, result.NextReceiveIndex);
}
// ---- cross-check of recent headers against a witness server (ADR 0042) ----
private static ChainProfile ProfileWithCheckpointAt100() => Profile with
{
// Newest checkpoint at 100: a tx at height 150 has nothing to anchor to.
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
};
/// <summary>A witness server answering blockchain.block.header with <paramref name="headerAt150"/>.</summary>
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartWitnessAsync(
Func<int, string> headerAt)
{
var server = new FakeElectrumServer();
server.Handle("blockchain.block.header", p => headerAt(p[0].GetInt32()));
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
return (server, client);
}
private static Task<IReadOnlyList<ElectrumClient>> Witnesses(params ElectrumClient[] clients) =>
Task.FromResult<IReadOnlyList<ElectrumClient>>(clients);
[Fact]
public async Task A_witness_that_agrees_lets_a_recent_proof_verify()
{
var account = Account(ProfileWithCheckpointAt100());
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
var (server, client) = await StartAsync(scenario);
var (witness, witnessClient) = await StartWitnessAsync(_ => scenario.Headers[150]);
await using var _ = server; await using var __ = client;
await using var ___ = witness; await using var ____ = witnessClient;
var sync = new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) };
var result = await sync.SyncOnceAsync();
Assert.Equal(1_000_000, result.SpendableSats);
Assert.Equal(0, result.PendingVerificationSats);
Assert.Equal(1, witness.CallCount("blockchain.block.header"));
}
[Fact]
public async Task A_witness_that_disagrees_at_a_safe_depth_fails_the_sync()
{
var account = Account(ProfileWithCheckpointAt100());
var scenario = new Scenario(); // tip 200, tx at 150: depth 50
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
var (server, client) = await StartAsync(scenario);
var other = Scenario.SingleTxHeaderHex(uint256.One, 150); // a different header at 150
var (witness, witnessClient) = await StartWitnessAsync(_ => other);
await using var _ = server; await using var __ = client;
await using var ___ = witness; await using var ____ = witnessClient;
var sync = new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) };
var ex = await Assert.ThrowsAsync<SpvVerificationException>(() => sync.SyncOnceAsync());
Assert.Contains("disagree about the header at height 150", ex.Message);
}
[Fact]
public async Task A_disagreement_at_a_shallow_depth_leaves_the_proof_unverified_without_failing()
{
var account = Account(ProfileWithCheckpointAt100());
var scenario = new Scenario { TipHeight = 151 }; // tx at 150: depth 1, could be a reorg
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
var (server, client) = await StartAsync(scenario);
var (witness, witnessClient) = await StartWitnessAsync(_ => Scenario.SingleTxHeaderHex(uint256.One, 150));
await using var _ = server; await using var __ = client;
await using var ___ = witness; await using var ____ = witnessClient;
var sync = new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) };
var result = await sync.SyncOnceAsync();
Assert.Equal(0, result.SpendableSats); // not spendable
Assert.Equal(1_000_000, result.ConfirmedSats); // but still reported
Assert.False(Assert.Single(result.History).Verified); // and not marked verified (no caching)
Assert.Empty(sync.ExportCaches(Net).VerifiedAt);
// The witness now agrees (the servers converged): the next sync verifies it.
witness.Handle("blockchain.block.header", _ => scenario.Headers[150]);
var again = await sync.SyncOnceAsync();
Assert.Equal(0, again.PendingVerificationSats);
Assert.True(Assert.Single(again.History).Verified);
}
[Fact]
public async Task A_witness_that_does_not_know_the_block_or_is_gone_is_skipped()
{
var account = Account(ProfileWithCheckpointAt100());
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
var (server, client) = await StartAsync(scenario);
var (witness, witnessClient) = await StartWitnessAsync(_ => throw new FakeElectrumError(-1, "no such block"));
await using var _ = server; await using var __ = client;
await using var ___ = witness;
var behind = await new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) }
.SyncOnceAsync();
Assert.Equal(1_000_000, behind.SpendableSats);
await witnessClient.DisposeAsync(); // a dead witness connection
var gone = await new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) }
.SyncOnceAsync();
Assert.Equal(1_000_000, gone.SpendableSats);
}
[Fact]
public async Task Without_witnesses_the_behaviour_is_unchanged()
{
var account = Account(ProfileWithCheckpointAt100());
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var none = await new WalletSynchronizer(account, client).SyncOnceAsync();
var empty = await new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses() }.SyncOnceAsync();
Assert.Equal(1_000_000, none.SpendableSats);
Assert.Equal(1_000_000, empty.SpendableSats);
}
[Fact]
public async Task Heights_covered_by_a_checkpoint_are_not_cross_checked()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointAccount = Account(Profile with
{
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
});
var (server, client) = await StartAsync(scenario);
var (witness, witnessClient) = await StartWitnessAsync(_ => "00");
await using var _ = server; await using var __ = client;
await using var ___ = witness; await using var ____ = witnessClient;
var sync = new WalletSynchronizer(checkpointAccount, client) { WitnessProvider = _ => Witnesses(witnessClient) };
var result = await sync.SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(0, witness.CallCount("blockchain.block.header"));
}
[Fact]
public async Task The_witness_is_asked_once_per_height_and_the_provider_once_per_synchronizer()
{
var account = Account(ProfileWithCheckpointAt100());
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
scenario.Pay(account.GetReceiveAddress(1), 500_000, height: 160);
var (server, client) = await StartAsync(scenario);
var (witness, witnessClient) = await StartWitnessAsync(h => scenario.Headers[h]);
await using var _ = server; await using var __ = client;
await using var ___ = witness; await using var ____ = witnessClient;
var providerCalls = 0;
var sync = new WalletSynchronizer(account, client)
{
WitnessProvider = _ => { Interlocked.Increment(ref providerCalls); return Witnesses(witnessClient); },
};
var result = await sync.SyncOnceAsync();
await sync.SyncOnceAsync();
Assert.Equal(1_500_000, result.SpendableSats);
Assert.Equal(2, witness.CallCount("blockchain.block.header")); // heights 150 and 160, once each
Assert.Equal(1, providerCalls);
}
[Fact]
public async Task WitnessConnector_skips_the_primary_and_wrong_chain_servers_and_respects_the_maximum()
{
await using var primary = new FakeElectrumServer();
await using var wrongChain = new FakeElectrumServer();
wrongChain.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Testnet.GenesisHash });
await using var good1 = new FakeElectrumServer();
await using var good2 = new FakeElectrumServer();
foreach (var f in new[] { primary, good1, good2 }) // the chain check reads the tip
f.Handle("blockchain.headers.subscribe", _ => new { height = 10, hex = new string('0', 160) });
var candidates = new[] { primary, wrongChain, good1, good2 }
.Select(f => new ServerEndpoint(f.Host, f.Port, f.Port)).ToArray();
// an unreachable candidate in front too
candidates = [new ServerEndpoint("127.0.0.1", 1, 1), .. candidates];
var profile = ChainProfiles.Mainnet with { Checkpoints = [] };
var witnesses = await WitnessConnector.ConnectAsync(profile, candidates,
primary.Host, primary.Port, useSsl: false, pins: null, max: 1);
try
{
var w = Assert.Single(witnesses);
Assert.Equal(good1.Port, w.Port); // not the primary, not the wrong chain, not the dead port
Assert.Equal(0, good2.CallCount("server.version")); // stopped at the maximum
}
finally { foreach (var w in witnesses) await w.DisposeAsync(); }
var two = await WitnessConnector.ConnectAsync(profile, candidates,
primary.Host, primary.Port, useSsl: false, pins: null, max: 5);
try { Assert.Equal([good1.Port, good2.Port], two.Select(c => c.Port).ToArray()); }
finally { foreach (var w in two) await w.DisposeAsync(); }
}
}