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);
+ }
+ }
+}