test: add ServerProbe tool and opt-in live server test

ServerProbe is a read-only conformance/health checker for Electrum
protocol servers (Fulcrum, ElectrumX): handshake/TLS, features and
genesis, header linkage, Merkle proof vs header root, fees, latency.
LiveServerTests runs it from dotnet test when PALLADIUM_TEST_SERVERS is
set. Also ignore .claude/, apk/aab and local.properties.
This commit is contained in:
davide committed 2026-11-03 09:34:34 +01:00
1 parent 8ac4a05c44
commit 42e8dfdb63
8 files changed
+377 -1

No files matched your search

+6
View File
@@ -93,6 +93,12 @@ settings.local.json
# Local agent/tooling metadata # Local agent/tooling metadata
.agents/ .agents/
.codex/ .codex/
.claude/
# Android packages and local SDK config
*.apk
*.aab
local.properties
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz) # Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
findings/ findings/
+1
View File
@@ -53,6 +53,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test - Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design) - Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static) - Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
- **Live server probe** (`tests/PalladiumWallet.ServerProbe`): read-only conformance/health check for real Electrum/Fulcrum/ElectrumX servers (`dotnet run --project tests/PalladiumWallet.ServerProbe -- --server host:port --ssl`, or `--registry`). Also runnable from `dotnet test` via env vars: `PALLADIUM_TEST_SERVERS=host:port,… [PALLADIUM_TEST_SSL=1] [PALLADIUM_TEST_NET=…] dotnet test --filter LiveServer` (no-op when unset, so the default run stays offline)
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding - **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests` - Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000` - Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
+1
View File
@@ -53,6 +53,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test - Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design) - Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static) - Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
- **Live server probe** (`tests/PalladiumWallet.ServerProbe`): read-only conformance/health check for real Electrum/Fulcrum/ElectrumX servers (`dotnet run --project tests/PalladiumWallet.ServerProbe -- --server host:port --ssl`, or `--registry`). Also runnable from `dotnet test` via env vars: `PALLADIUM_TEST_SERVERS=host:port,… [PALLADIUM_TEST_SSL=1] [PALLADIUM_TEST_NET=…] dotnet test --filter LiveServer` (no-op when unset, so the default run stays offline)
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding - **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests` - Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000` - Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
+15
View File
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tes
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.ServerProbe", "tests\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj", "{4CB51620-11B7-47E9-9971-A999E49D1CE1}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -117,6 +119,18 @@ Global
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x64.ActiveCfg = Debug|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x64.Build.0 = Debug|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x86.ActiveCfg = Debug|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x86.Build.0 = Debug|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|Any CPU.Build.0 = Release|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x64.ActiveCfg = Release|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x64.Build.0 = Release|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x86.ActiveCfg = Release|Any CPU
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -130,5 +144,6 @@ Global
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933} {BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{4CB51620-11B7-47E9-9971-A999E49D1CE1} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,292 @@
// Black-box conformance/health probe for Electrum-protocol servers (Fulcrum, ElectrumX, electrs).
// Reuses the wallet's own ElectrumClient, so a server that passes here is one the wallet can use.
// Read-only: never sends transactions and never touches wallet files.
using System.Diagnostics;
using System.Net.Security;
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
if (args.Length == 0 || args.Contains("--help") || args.Contains("-h")) return Usage();
var profile = Opt("--net") switch
{
"testnet" => ChainProfiles.Testnet,
"regtest" => ChainProfiles.Regtest,
_ => ChainProfiles.Mainnet,
};
var useSsl = args.Contains("--ssl");
var timeout = TimeSpan.FromSeconds(int.TryParse(Opt("--timeout"), out var t) ? t : 15);
var pings = int.TryParse(Opt("--pings"), out var p) ? p : 5;
var maxLag = int.TryParse(Opt("--max-lag"), out var l) ? l : 2;
var targets = new List<(string Host, int Port, bool Ssl)>();
for (var i = 0; i < args.Length - 1; i++)
if (args[i] == "--server") targets.Add(ParseTarget(args[i + 1]));
if (Opt("--file") is { } file)
foreach (var line in File.ReadAllLines(file))
if (line.Trim() is { Length: > 0 } s && !s.StartsWith('#')) targets.Add(ParseTarget(s));
if (args.Contains("--registry"))
foreach (var s in new ServerRegistry(profile, Path.GetTempFileName()).All)
targets.Add((s.Host, s.PortFor(useSsl), useSsl));
if (targets.Count == 0) { Console.Error.WriteLine("No server given."); return Usage(); }
var failed = 0;
var heights = new List<(string Name, int Height)>();
foreach (var target in targets)
{
Console.WriteLine($"\n=== {target.Host}:{target.Port}{(target.Ssl ? " (TLS)" : "")} [{profile.NetName}] ===");
var (ok, height) = await Probe(target);
if (!ok) failed++;
if (height is { } h) heights.Add(($"{target.Host}:{target.Port}", h));
}
if (heights.Count > 1)
{
var best = heights.Max(x => x.Height);
Console.WriteLine($"\n=== Cross-server tip (best {best}) ===");
foreach (var (name, h) in heights)
Console.WriteLine($" {(best - h > maxLag ? "WARN" : " ok ")} {name}: {h} (lag {best - h})");
}
Console.WriteLine($"\n{targets.Count - failed}/{targets.Count} server(s) passed.");
return failed == 0 ? 0 : 1;
// ---------------------------------------------------------------------------
async Task<(bool Ok, int? Height)> Probe((string Host, int Port, bool Ssl) s)
{
var allOk = true;
int? tip = null;
async Task<T?> Step<T>(string name, Func<CancellationToken, Task<(T Value, string Detail, bool Warn)>> body)
{
using var cts = new CancellationTokenSource(timeout);
var sw = Stopwatch.StartNew();
try
{
var (value, detail, warn) = await body(cts.Token);
Console.WriteLine($" {(warn ? "WARN" : " ok ")} {name,-22} {sw.ElapsedMilliseconds,5} ms {detail}");
return value;
}
catch (Exception ex)
{
allOk = false;
var msg = ex is OperationCanceledException ? $"timeout after {timeout.TotalSeconds:0}s" : ex.Message;
Console.WriteLine($" FAIL {name,-22} {sw.ElapsedMilliseconds,5} ms {msg}");
return default;
}
}
ElectrumClient? client = null;
try
{
client = await Step("connect+handshake", async ct =>
{
var c = await ElectrumClient.ConnectAsync(s.Host, s.Port, s.Ssl, pins: null, ct);
return (c, s.Ssl ? "TLS fingerprint " + await Fingerprint(s.Host, s.Port, ct) : "plaintext", !s.Ssl);
});
if (client is null) return (false, null);
var c = client;
await Step("server.banner", async ct =>
{
var r = await c.RequestAsync("server.banner", ct);
var b = (r.GetString() ?? "").Replace('\n', ' ').Trim();
return (0, b.Length > 60 ? b[..60] + "…" : b, false);
});
await Step("server.features", async ct =>
{
var r = await c.RequestAsync("server.features", ct);
var genesis = r.TryGetProperty("genesis_hash", out var g) ? g.GetString() : null;
var hashFn = r.TryGetProperty("hash_function", out var hf) ? hf.GetString() : null;
if (genesis != profile.GenesisHash)
throw new Exception($"genesis_hash {genesis} != expected {profile.GenesisHash} (wrong chain)");
if (hashFn != "sha256") throw new Exception($"hash_function '{hashFn}' (expected sha256)");
var pruning = r.TryGetProperty("pruning", out var pr) && pr.ValueKind == JsonValueKind.Number;
return (0, $"genesis ok, pruning={(pruning ? pr.ToString() : "none")}", pruning);
});
var tipInfo = await Step("headers.subscribe", async ct =>
{
var r = await c.RequestAsync("blockchain.headers.subscribe", ct);
var height = r.GetProperty("height").GetInt32();
var header = BlockHeaderInfo.Parse(r.GetProperty("hex").GetString()!);
var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
var stale = age.TotalSeconds > profile.BlockTimeSeconds * 30;
return ((height, header), $"height {height}, tip age {FormatAge(age)}", stale);
});
if (tipInfo.header is null) return (false, null);
tip = tipInfo.height;
var (tipHeight, tipHeader) = tipInfo;
await Step("genesis header", async ct =>
{
var hex = (await c.RequestAsync("blockchain.block.header", ct, 0)).GetString()!;
var h = BlockHeaderInfo.Parse(hex);
if (h.Hash.ToString() != profile.GenesisHash) throw new Exception($"header 0 hash {h.Hash}");
return (0, "hash matches profile", false);
});
await Step("header chain linkage", async ct =>
{
var count = Math.Min(20, tipHeight);
var start = tipHeight - count + 1;
var r = await c.RequestAsync("blockchain.block.headers", ct, start, count);
var raw = Convert.FromHexString(r.GetProperty("hex").GetString()!);
var n = raw.Length / BlockHeaderInfo.Size;
if (n == 0) throw new Exception("empty header batch");
BlockHeaderInfo? prev = null;
for (var i = 0; i < n; i++)
{
var h = BlockHeaderInfo.Parse(raw[(i * 80)..((i + 1) * 80)]);
if (prev is not null && h.PrevHash != prev.Hash) throw new Exception($"broken link at height {start + i}");
prev = h;
}
// The batch may be a block or two ahead of the earlier subscribe; only compare when equal.
var note = start + n - 1 == tipHeight && prev!.Hash != tipHeader.Hash
? throw new Exception("batch tip differs from headers.subscribe tip")
: "";
return (0, $"{n} headers linked {note}".Trim(), false);
});
await Step("merkle proof (SPV)", async ct =>
{
// A block a few confirmations deep: its coinbase must verify against the header root.
var height = Math.Max(1, tipHeight - 6);
var hdr = BlockHeaderInfo.Parse((await c.RequestAsync("blockchain.block.header", ct, height)).GetString()!);
var r = await c.RequestAsync("blockchain.transaction.id_from_pos", ct, height, 0, true);
if (r.ValueKind != JsonValueKind.Object || !(r.TryGetProperty("tx_hash", out _) || r.TryGetProperty("tx_id", out _)) || !r.TryGetProperty("merkle", out _)) throw new Exception("unexpected id_from_pos reply: " + r.ToString()[..Math.Min(120, r.ToString().Length)]);
var txid = uint256.Parse((r.TryGetProperty("tx_hash", out var th) ? th : r.GetProperty("tx_id")).GetString()!);
var branch = r.GetProperty("merkle").EnumerateArray().Select(e => uint256.Parse(e.GetString()!)).ToList();
if (!MerkleProof.Verify(txid, 0, branch, hdr.MerkleRoot))
throw new Exception($"merkle proof for coinbase at height {height} does NOT match header root");
return (0, $"coinbase {txid.ToString()[..12]}… @ {height} verified", false);
});
await Step("estimatefee/relayfee", async ct =>
{
var est = (await c.RequestAsync("blockchain.estimatefee", ct, 2)).GetDouble();
var relay = (await c.RequestAsync("blockchain.relayfee", ct)).GetDouble();
return (0, $"est(2 blk)={(est < 0 ? "n/a" : est.ToString("0.########"))} PLM/kB, relay={relay:0.########}", relay <= 0);
});
await Step("fee histogram", async ct =>
{
var r = await c.RequestAsync("mempool.get_fee_histogram", ct);
return (0, $"{r.GetArrayLength()} buckets", false);
});
await Step("peers.subscribe", async ct =>
{
var r = await c.RequestAsync("server.peers.subscribe", ct);
return (0, $"{r.GetArrayLength()} peers", false);
});
await Step("unknown scripthash", async ct =>
{
// Well-formed but unused scripthash must return an empty, not an error.
var sh = new string('0', 64);
var r = await c.RequestAsync("blockchain.scripthash.get_balance", ct, sh);
var conf = r.GetProperty("confirmed").GetInt64();
if (conf != 0) throw new Exception($"confirmed={conf} for an all-zero scripthash");
return (0, "balance 0/0", false);
});
await Step("error handling", async ct =>
{
try { await c.RequestAsync("blockchain.block.header", ct, int.MaxValue); }
catch (ElectrumServerException) { return (0, "bad height → JSON-RPC error", false); }
throw new Exception("server answered a nonexistent height instead of an error");
});
await Step($"latency x{pings}", async ct =>
{
var times = new List<double>();
for (var i = 0; i < pings; i++)
{
var sw = Stopwatch.StartNew();
await c.RequestAsync("server.ping", ct);
times.Add(sw.Elapsed.TotalMilliseconds);
}
return (0, $"min {times.Min():0} / avg {times.Average():0} / max {times.Max():0} ms", times.Average() > 1500);
});
await Step("concurrent burst x50", async ct =>
{
var sw = Stopwatch.StartNew();
var rs = await Task.WhenAll(Enumerable.Range(0, 50)
.Select(i => c.RequestAsync("blockchain.block.header", ct, Math.Max(0, tipHeight - i))));
if (rs.Any(r => r.ValueKind != JsonValueKind.String)) throw new Exception("malformed reply in burst");
return (0, $"50 pipelined requests in {sw.ElapsedMilliseconds} ms", false);
});
await Step("tip still advancing", async ct =>
{
var r = await c.RequestAsync("blockchain.headers.subscribe", ct);
var now = r.GetProperty("height").GetInt32();
tip = now;
return (0, $"height {now} (was {tipHeight})", now < tipHeight);
});
}
finally
{
if (client is not null) await client.DisposeAsync();
}
Console.WriteLine(allOk ? " => PASS" : " => FAIL");
return (allOk, tip);
}
static async Task<string> Fingerprint(string host, int port, CancellationToken ct)
{
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(host, port, ct);
string fp = "?";
await using var ssl = new SslStream(tcp.GetStream(), false, (_, cert, _, _) =>
{
if (cert is not null) fp = CertificatePinStore.Fingerprint(cert);
return true;
});
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, ct);
return fp.Length > 23 ? fp[..23] + "…" : fp;
}
static string FormatAge(TimeSpan a) =>
a.TotalMinutes < 120 ? $"{a.TotalMinutes:0} min" : a.TotalHours < 48 ? $"{a.TotalHours:0.#} h" : $"{a.TotalDays:0.#} d";
(string Host, int Port, bool Ssl) ParseTarget(string spec)
{
var parts = spec.Trim().Split(':');
var port = parts.Length > 1 ? int.Parse(parts[1]) : useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort;
return (parts[0], port, useSsl);
}
string? Opt(string name)
{
var i = Array.IndexOf(args, name);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
static int Usage()
{
Console.WriteLine("""
PalladiumWallet server probe — conformance/health checks for Electrum-protocol servers
usage: dotnet run --project tests/PalladiumWallet.ServerProbe -- [options]
--server host[:port] server to test (repeatable)
--file PATH text file, one host[:port] per line ('#' comments)
--registry test every server known for the network
--ssl use TLS (default port 50002; plain 50001)
--net mainnet|testnet|regtest (default mainnet)
--timeout SEC per-check timeout (default 15)
--pings N latency samples (default 5)
--max-lag N blocks behind the best server before WARN (default 2)
Checks: handshake/TLS, version, banner, features (genesis + sha256), tip freshness,
genesis header, header linkage, Merkle proof vs header root, fees, peers, error
handling, latency, pipelined burst. Exit code 1 if any server has a FAIL.
""");
return 2;
}
@@ -0,0 +1,45 @@
using System.Reflection;
namespace PalladiumWallet.Tests.Net;
/// <summary>
/// Opt-in conformance run of the ServerProbe against real Electrum/Fulcrum servers.
/// Does nothing (passes) unless <c>PALLADIUM_TEST_SERVERS</c> is set, so the default
/// <c>dotnet test</c> stays offline and deterministic.
///
/// PALLADIUM_TEST_SERVERS=host1:50002,host2:50002 servers to probe (comma separated)
/// PALLADIUM_TEST_SSL=1 use TLS
/// PALLADIUM_TEST_NET=mainnet|testnet|regtest network (default mainnet)
///
/// Example: PALLADIUM_TEST_SERVERS=1.2.3.4:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer
/// </summary>
[Collection("Console")] // the probe writes to Console, which is process-global
public class LiveServerTests
{
[Fact]
public async Task LiveServer_PassesProbe()
{
var servers = Environment.GetEnvironmentVariable("PALLADIUM_TEST_SERVERS");
if (string.IsNullOrWhiteSpace(servers)) return;
var args = new List<string>();
foreach (var s in servers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
args.AddRange(["--server", s]);
if (Environment.GetEnvironmentVariable("PALLADIUM_TEST_SSL") is "1" or "true") args.Add("--ssl");
if (Environment.GetEnvironmentVariable("PALLADIUM_TEST_NET") is { Length: > 0 } net) args.AddRange(["--net", net]);
// The probe is a top-level-statements exe: call its entry point rather than shelling out.
var entry = Assembly.Load("PalladiumWallet.ServerProbe").EntryPoint
?? throw new InvalidOperationException("ServerProbe has no entry point.");
var original = Console.Out;
var captured = new StringWriter();
Console.SetOut(captured);
int exit;
// The synthesized entry point is a synchronous Main(string[]) → int wrapper.
try { exit = await Task.Run(() => (int)entry.Invoke(null, [args.ToArray()])!); }
finally { Console.SetOut(original); }
Assert.True(exit == 0, captured.ToString());
}
}
@@ -23,7 +23,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" /> <ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" /> <ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
<ProjectReference Include="..\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>