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

@@ -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(); }
}
}