using NBitcoin.Crypto; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Tests.Net; namespace PalladiumWallet.Tests.Spv; /// Chain-identity check after the handshake (genesis + highest checkpoint ≤ tip). public class ChainVerifierTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); // 80 arbitrary bytes are a valid "header" for hashing purposes; seed makes them distinct. private static string HeaderHex(int seed) => Convert.ToHexString(Hashes.SHA256(BitConverter.GetBytes(seed)) .Concat(Hashes.SHA256(BitConverter.GetBytes(seed + 1_000))) .Concat(new byte[16]).ToArray()).ToLowerInvariant(); private static Checkpoint CheckpointFor(int height, int seed) => new(height, BlockHeaderInfo.Parse(HeaderHex(seed)).Hash.ToString(), 0x1d00ffff); private static ChainProfile Profile(params Checkpoint[] checkpoints) => ChainProfiles.Mainnet with { Checkpoints = checkpoints }; private static async Task ConnectAsync(FakeElectrumServer s) => await ElectrumClient.ConnectAsync(s.Host, s.Port, useSsl: false); private static void Tip(FakeElectrumServer s, int height) => s.Handle("blockchain.headers.subscribe", _ => new { height, hex = HeaderHex(0) }); [Fact] public async Task Accepts_a_server_with_the_expected_genesis_and_checkpoint_header() { await using var server = new FakeElectrumServer(); Tip(server, 150); server.Handle("blockchain.block.header", p => p[0].GetInt32() == 100 ? HeaderHex(7) : throw new FakeElectrumError(-1, "no")); await using var client = await ConnectAsync(server); await client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout); } [Fact] public async Task Uses_the_highest_checkpoint_not_above_the_tip() { await using var server = new FakeElectrumServer(); Tip(server, 150); var asked = new List(); server.Handle("blockchain.block.header", p => { asked.Add(p[0].GetInt32()); return HeaderHex(asked[^1]); }); await using var client = await ConnectAsync(server); await client.VerifyChainAsync( Profile(CheckpointFor(50, 50), CheckpointFor(100, 100), CheckpointFor(200, 200)), default).WaitAsync(Timeout); Assert.Equal([100], asked); } [Fact] public async Task Rejects_a_different_header_at_the_checkpoint_height() { await using var server = new FakeElectrumServer(); Tip(server, 150); server.Handle("blockchain.block.header", _ => HeaderHex(999)); // other chain await using var client = await ConnectAsync(server); var ex = await Assert.ThrowsAsync( () => client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout)); Assert.Equal(server.Port, ex.Port); Assert.Contains("checkpoint", ex.Message); } [Fact] public async Task Rejects_a_different_genesis_hash_before_asking_for_headers() { await using var server = new FakeElectrumServer(); server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Testnet.GenesisHash }); await using var client = await ConnectAsync(server); await Assert.ThrowsAsync( () => client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout)); Assert.Equal(0, server.CallCount("blockchain.headers.subscribe")); } [Fact] public async Task Genesis_comparison_is_case_insensitive() { await using var server = new FakeElectrumServer(); server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Mainnet.GenesisHash.ToUpperInvariant() }); Tip(server, 10); await using var client = await ConnectAsync(server); await client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout); } [Theory] [InlineData("error")] [InlineData("no-genesis")] [InlineData("not-an-object")] public async Task Rejects_a_server_that_does_not_report_a_genesis_hash(string mode) { await using var server = new FakeElectrumServer(); server.Handle("server.features", _ => mode switch { "error" => throw new FakeElectrumError(-32601, "unknown method"), "no-genesis" => new { hash_function = "sha256" }, _ => "nope", }); await using var client = await ConnectAsync(server); await Assert.ThrowsAsync( () => client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout)); } [Fact] public async Task A_tip_below_the_first_checkpoint_is_accepted_on_the_genesis_alone() { await using var server = new FakeElectrumServer(); Tip(server, 50); await using var client = await ConnectAsync(server); await client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout); Assert.Equal(0, server.CallCount("blockchain.block.header")); } [Fact] public async Task A_profile_without_checkpoints_relies_on_the_genesis_alone() { await using var server = new FakeElectrumServer(); Tip(server, 5_000_000); await using var client = await ConnectAsync(server); await client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout); Assert.Equal(0, server.CallCount("blockchain.block.header")); } [Theory] [InlineData("server-error")] [InlineData("short-header")] [InlineData("not-hex")] public async Task A_missing_or_malformed_checkpoint_header_is_a_wrong_chain(string mode) { await using var server = new FakeElectrumServer(); Tip(server, 150); server.Handle("blockchain.block.header", _ => mode switch { "server-error" => throw new FakeElectrumError(-1, "no such block"), "short-header" => "00ff", _ => "zz", }); await using var client = await ConnectAsync(server); await Assert.ThrowsAsync( () => client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout)); } [Fact] public void The_shipped_mainnet_profile_has_checkpoints_the_check_can_use() { Assert.NotEmpty(ChainProfiles.Mainnet.Checkpoints); Assert.All(ChainProfiles.Mainnet.Checkpoints, c => Assert.Equal(64, c.BlockHash.Length)); } }