diff --git a/src/Core/Net/UpdateChecker.cs b/src/Core/Net/UpdateChecker.cs
index 2d1482f..80ce8c1 100644
--- a/src/Core/Net/UpdateChecker.cs
+++ b/src/Core/Net/UpdateChecker.cs
@@ -55,7 +55,7 @@ public static class UpdateChecker
}
/// Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable .
- private static bool TryParse(string raw, out Version version)
+ internal static bool TryParse(string raw, out Version version)
{
var s = raw.Trim();
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
diff --git a/src/Core/PalladiumWallet.Core.csproj b/src/Core/PalladiumWallet.Core.csproj
index 27a502a..3555b71 100644
--- a/src/Core/PalladiumWallet.Core.csproj
+++ b/src/Core/PalladiumWallet.Core.csproj
@@ -6,8 +6,12 @@
enable
-
-
+
+
+
+
+
+
diff --git a/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs b/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs
new file mode 100644
index 0000000..5b55e73
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs
@@ -0,0 +1,212 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Text.Json;
+
+namespace PalladiumWallet.Tests.Net;
+
+///
+/// Error a handler can throw to make the fake server reply with a JSON-RPC
+/// error object (e.g. code -102 "server busy" to exercise the retry path).
+///
+public sealed class FakeElectrumError(int code, string message) : Exception(message)
+{
+ public int Code { get; } = code;
+}
+
+///
+/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
+/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
+/// Handlers are registered per method and receive the request "params" array;
+/// whatever they return is serialised as the "result". "server.version" is
+/// pre-registered. Calls are counted per method (cache/retry assertions).
+///
+public sealed class FakeElectrumServer : IAsyncDisposable
+{
+ private readonly TcpListener _listener;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Task _acceptLoop;
+ private readonly X509Certificate2? _certificate;
+ private readonly ConcurrentDictionary> _handlers = new();
+ private readonly ConcurrentDictionary _callCounts = new();
+ private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
+
+ /// Sentinel: a handler returning this leaves the request unanswered (pending forever).
+ public static readonly object NoResponse = new();
+
+ public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
+ {
+ _certificate = certificate;
+ _listener = new TcpListener(IPAddress.Loopback, port);
+ _listener.Start();
+ Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
+ _acceptLoop = Task.Run(AcceptLoopAsync);
+ }
+
+ public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
+ public string Host => "127.0.0.1";
+
+ /// Registers (or replaces) the handler for a JSON-RPC method.
+ public void Handle(string method, Func handler) =>
+ _handlers[method] = handler;
+
+ public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
+
+ public void ResetCallCounts() => _callCounts.Clear();
+
+ /// Pushes a JSON-RPC notification to every connected client.
+ public async Task NotifyAsync(string method, object parameters)
+ {
+ var line = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ jsonrpc = "2.0",
+ method,
+ @params = parameters,
+ });
+ foreach (var (stream, writeLock, _) in _clients)
+ await WriteLineAsync(stream, writeLock, line);
+ }
+
+ /// Abruptly closes every accepted connection (tests the client's failure paths).
+ public void DropClients()
+ {
+ foreach (var (_, _, tcp) in _clients)
+ tcp.Close();
+ }
+
+ /// Self-signed certificate for TLS tests (exportable, valid now).
+ public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ $"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ using var cert = request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
+ // PFX roundtrip so the private key is usable by SslStream on every platform.
+ return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ try
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
+ _ = Task.Run(() => ServeClientAsync(tcp));
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (SocketException) { }
+ }
+
+ private async Task ServeClientAsync(TcpClient tcp)
+ {
+ try
+ {
+ Stream stream = tcp.GetStream();
+ if (_certificate is not null)
+ {
+ var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
+ await ssl.AuthenticateAsServerAsync(_certificate);
+ stream = ssl;
+ }
+
+ var writeLock = new SemaphoreSlim(1, 1);
+ _clients.Add((stream, writeLock, tcp));
+
+ using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
+ while (!_cts.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(_cts.Token);
+ if (line is null) break;
+ if (line.Length == 0) continue;
+ await HandleRequestAsync(stream, writeLock, line);
+ }
+ }
+ catch (Exception)
+ {
+ // Client went away or TLS failed (e.g. rejected pin): normal in tests.
+ }
+ }
+
+ private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
+ {
+ using var doc = JsonDocument.Parse(line);
+ var root = doc.RootElement;
+ var id = root.GetProperty("id").GetInt64();
+ var method = root.GetProperty("method").GetString()!;
+ var parameters = root.TryGetProperty("params", out var p) ? p : default;
+
+ _callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
+
+ byte[] payload;
+ if (!_handlers.TryGetValue(method, out var handler))
+ {
+ payload = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ jsonrpc = "2.0",
+ id,
+ error = new { code = -32601, message = $"unknown method '{method}'" },
+ });
+ }
+ else
+ {
+ try
+ {
+ var result = handler(parameters);
+ if (ReferenceEquals(result, NoResponse))
+ return;
+ payload = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ jsonrpc = "2.0",
+ id,
+ result,
+ });
+ }
+ catch (FakeElectrumError err)
+ {
+ payload = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ jsonrpc = "2.0",
+ id,
+ error = new { code = err.Code, message = err.Message },
+ });
+ }
+ }
+
+ await WriteLineAsync(stream, writeLock, payload);
+ }
+
+ private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
+ {
+ await writeLock.WaitAsync();
+ try
+ {
+ await stream.WriteAsync(payload);
+ stream.WriteByte((byte)'\n');
+ await stream.FlushAsync();
+ }
+ catch (Exception)
+ {
+ // Connection already gone: irrelevant for the test in progress.
+ }
+ finally
+ {
+ writeLock.Release();
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+ _listener.Stop();
+ DropClients();
+ try { await _acceptLoop; } catch { }
+ _cts.Dispose();
+ }
+}