diff --git a/src/Core/Chain/ChainProfiles.cs b/src/Core/Chain/ChainProfiles.cs index 825c3dc..cda8b99 100644 --- a/src/Core/Chain/ChainProfiles.cs +++ b/src/Core/Chain/ChainProfiles.cs @@ -34,8 +34,15 @@ public static class ChainProfiles [ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub [ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub }, - // TODO: popolare con i server reali prima della release (host:tcp:ssl). - BootstrapServers = [], + // Server di indicizzazione noti per il primo contatto (§3/§9); altri + // peer vengono scoperti via server.peers.subscribe. + BootstrapServers = + [ + new ServerEndpoint("173.212.224.67", 50001, 50002), + new ServerEndpoint("144.91.120.225", 50001, 50002), + new ServerEndpoint("66.94.115.80", 50001, 50002), + new ServerEndpoint("89.117.149.130", 50001, 50002), + ], // TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release. Checkpoints = [], }; diff --git a/src/Core/Net/ElectrumApi.cs b/src/Core/Net/ElectrumApi.cs index 9098696..cc0c539 100644 --- a/src/Core/Net/ElectrumApi.cs +++ b/src/Core/Net/ElectrumApi.cs @@ -14,6 +14,9 @@ public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList /// Tip della catena notificato da blockchain.headers.subscribe. public readonly record struct ChainTip(int Height, string HeaderHex); +/// Peer annunciato da server.peers.subscribe (porte null = non offerte). +public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version); + /// /// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il /// trasporto JSON-RPC generico di . @@ -108,4 +111,50 @@ public static class ElectrumApi public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) => c.RequestAsync("server.ping", ct); + + /// Peer annunciati dal server (scoperta di altri server, §9). + public static async Task> GetPeersAsync(this ElectrumClient c, + CancellationToken ct = default) + { + var r = await c.RequestAsync("server.peers.subscribe", ct); + return ParsePeers(r); + } + + /// + /// Parsa la risposta di server.peers.subscribe: lista di + /// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]]; + /// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante). + /// + public static IReadOnlyList ParsePeers(JsonElement response) + { + var peers = new List(); + foreach (var entry in response.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3) + continue; + var host = entry[1].GetString(); + if (string.IsNullOrWhiteSpace(host)) + host = entry[0].GetString(); + if (string.IsNullOrWhiteSpace(host)) + continue; + + int? tcp = null, ssl = null; + string? version = null; + foreach (var feature in entry[2].EnumerateArray()) + { + var f = feature.GetString(); + if (string.IsNullOrEmpty(f)) + continue; + switch (f[0]) + { + case 'v': version = f[1..]; break; + case 't': tcp = int.TryParse(f[1..], out var t) ? t : 0; break; + case 's': ssl = int.TryParse(f[1..], out var s) ? s : 0; break; + } + } + if (tcp is not null || ssl is not null) + peers.Add(new PeerInfo(host!, tcp, ssl, version)); + } + return peers; + } } diff --git a/src/Core/Net/ServerRegistry.cs b/src/Core/Net/ServerRegistry.cs new file mode 100644 index 0000000..f31dd8d --- /dev/null +++ b/src/Core/Net/ServerRegistry.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Net; + +/// Server di indicizzazione noto (bootstrap o scoperto dai peer). +public sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null) +{ + public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort; + + public override string ToString() => + $"{Host} · tcp {TcpPort} / ssl {SslPort}" + (Version is null ? "" : $" · v{Version}"); +} + +/// +/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3), +/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste +/// le scoperte su file per le sessioni successive. +/// +public sealed class ServerRegistry +{ + private readonly ChainProfile _profile; + private readonly string _filePath; + private readonly List _servers = []; + private readonly object _lock = new(); + + public ServerRegistry(ChainProfile profile, string filePath) + { + _profile = profile; + _filePath = filePath; + + foreach (var s in profile.BootstrapServers) + _servers.Add(new KnownServer(s.Host, s.TcpPort, s.SslPort)); + + if (File.Exists(filePath)) + { + try + { + var saved = JsonSerializer.Deserialize>(File.ReadAllText(filePath)) ?? []; + foreach (var s in saved) + AddIfNew(s); + } + catch (JsonException) + { + // File corrotto: si riparte dai soli bootstrap. + } + } + } + + public IReadOnlyList All + { + get { lock (_lock) return [.. _servers]; } + } + + /// Primo server noto: default quando l'utente non ne indica uno. + public KnownServer? Default + { + get { lock (_lock) return _servers.FirstOrDefault(); } + } + + /// + /// Chiede al server connesso i peer che annuncia e integra i nuovi nel + /// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi. + /// + public async Task DiscoverAsync(ElectrumClient client, CancellationToken ct = default) + { + var peers = await client.GetPeersAsync(ct); + var added = 0; + lock (_lock) + { + foreach (var peer in peers) + { + var server = new KnownServer( + peer.Host, + peer.TcpPort is > 0 ? peer.TcpPort.Value : _profile.DefaultTcpPort, + peer.SslPort is > 0 ? peer.SslPort.Value : _profile.DefaultSslPort, + peer.Version); + if (AddIfNew(server)) + added++; + } + if (added > 0) + Save(); + } + return added; + } + + private bool AddIfNew(KnownServer server) + { + if (_servers.Any(s => string.Equals(s.Host, server.Host, StringComparison.OrdinalIgnoreCase))) + return false; + _servers.Add(server); + return true; + } + + private void Save() + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + // Si salvano solo gli scoperti: i bootstrap vengono dal profilo. + var discovered = _servers + .Where(s => !_profile.BootstrapServers.Any(b => + string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase))) + .ToList(); + File.WriteAllText(_filePath, JsonSerializer.Serialize(discovered, + new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/src/Core/Storage/AppPaths.cs b/src/Core/Storage/AppPaths.cs index 3117e14..1087f78 100644 --- a/src/Core/Storage/AppPaths.cs +++ b/src/Core/Storage/AppPaths.cs @@ -44,6 +44,9 @@ public static class AppPaths public static string CertificatePinsPath(NetKind net) => Path.Combine(ForNetwork(net), "server-certs.json"); + public static string ServersPath(NetKind net) => + Path.Combine(ForNetwork(net), "servers.json"); + public static string ConfigPath() => Path.Combine(DataRoot(), "config.json"); } diff --git a/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs b/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs new file mode 100644 index 0000000..7a7f3c8 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Net; + +namespace PalladiumWallet.Tests.Net; + +public class PeerParsingTests +{ + [Fact] + public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx() + { + // Formato reale: [ip, hostname, ["v...", "pN", "tPORTA", "sPORTA"]]. + const string json = """ + [ + ["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]], + ["10.0.0.1", "nodo.esempio.org", ["v1.4", "t"]], + ["10.0.0.2", "solo-ssl.esempio.org", ["v1.4", "s50002"]], + ["10.0.0.3", "senza-porte.esempio.org", ["v1.4"]] + ] + """; + var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement); + + Assert.Equal(3, peers.Count); // l'ultimo non offre porte → scartato + + Assert.Equal(new PeerInfo("173.212.224.67", 50001, 50002, "1.4.2"), peers[0]); + // "t" senza numero = porta di default (0 segnala "da risolvere col profilo"). + Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]); + Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]); + } +} + +public class ServerRegistryTests +{ + private static string TempPath() => + Path.Combine(Path.GetTempPath(), $"plm-servers-{Guid.NewGuid()}.json"); + + [Fact] + public void Il_registro_parte_dai_bootstrap_del_profilo() + { + var path = TempPath(); + var registry = new ServerRegistry(ChainProfiles.Mainnet, path); + + Assert.Equal(ChainProfiles.Mainnet.BootstrapServers.Count, registry.All.Count); + Assert.Equal("173.212.224.67", registry.Default!.Host); + Assert.Equal(50001, registry.Default.PortFor(useSsl: false)); + Assert.Equal(50002, registry.Default.PortFor(useSsl: true)); + } + + [Fact] + public void I_peer_scoperti_si_aggiungono_e_persistono_senza_duplicati() + { + var path = TempPath(); + try + { + var registry = new ServerRegistry(ChainProfiles.Mainnet, path); + var bootstrapCount = registry.All.Count; + + // Merge diretto via DiscoverAsync richiede un client: si testa la + // persistenza simulando il file degli scoperti. + var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") }; + File.WriteAllText(path, JsonSerializer.Serialize(discovered)); + + var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path); + Assert.Equal(bootstrapCount + 1, reloaded.All.Count); + Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org"); + + // Un bootstrap duplicato nel file non raddoppia. + File.WriteAllText(path, JsonSerializer.Serialize(new[] + { + new KnownServer("173.212.224.67", 50001, 50002), + new KnownServer("nuovo.esempio.org", 50001, 50002), + })); + var deduped = new ServerRegistry(ChainProfiles.Mainnet, path); + Assert.Equal(bootstrapCount + 1, deduped.All.Count); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Un_file_server_corrotto_non_blocca_l_avvio() + { + var path = TempPath(); + try + { + File.WriteAllText(path, "{ non-json "); + var registry = new ServerRegistry(ChainProfiles.Mainnet, path); + Assert.Equal(ChainProfiles.Mainnet.BootstrapServers.Count, registry.All.Count); + } + finally + { + File.Delete(path); + } + } +}