feat(net): server registry, bootstrap servers, peer discovery

This commit is contained in:
2026-06-11 11:27:14 +02:00
parent 34c588c6f4
commit 1c2dacc27c
5 changed files with 264 additions and 2 deletions
+9 -2
View File
@@ -34,8 +34,15 @@ public static class ChainProfiles
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub [ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub [ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
}, },
// TODO: popolare con i server reali prima della release (host:tcp:ssl). // Server di indicizzazione noti per il primo contatto (§3/§9); altri
BootstrapServers = [], // 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. // TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release.
Checkpoints = [], Checkpoints = [],
}; };
+49
View File
@@ -14,6 +14,9 @@ public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary> /// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
public readonly record struct ChainTip(int Height, string HeaderHex); public readonly record struct ChainTip(int Height, string HeaderHex);
/// <summary>Peer annunciato da server.peers.subscribe (porte null = non offerte).</summary>
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
/// <summary> /// <summary>
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il /// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>. /// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
@@ -108,4 +111,50 @@ public static class ElectrumApi
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) => public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
c.RequestAsync("server.ping", ct); c.RequestAsync("server.ping", ct);
/// <summary>Peer annunciati dal server (scoperta di altri server, §9).</summary>
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
CancellationToken ct = default)
{
var r = await c.RequestAsync("server.peers.subscribe", ct);
return ParsePeers(r);
}
/// <summary>
/// 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).
/// </summary>
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
{
var peers = new List<PeerInfo>();
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;
}
} }
+106
View File
@@ -0,0 +1,106 @@
using System.Text.Json;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Net;
/// <summary>Server di indicizzazione noto (bootstrap o scoperto dai peer).</summary>
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}");
}
/// <summary>
/// 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.
/// </summary>
public sealed class ServerRegistry
{
private readonly ChainProfile _profile;
private readonly string _filePath;
private readonly List<KnownServer> _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<List<KnownServer>>(File.ReadAllText(filePath)) ?? [];
foreach (var s in saved)
AddIfNew(s);
}
catch (JsonException)
{
// File corrotto: si riparte dai soli bootstrap.
}
}
}
public IReadOnlyList<KnownServer> All
{
get { lock (_lock) return [.. _servers]; }
}
/// <summary>Primo server noto: default quando l'utente non ne indica uno.</summary>
public KnownServer? Default
{
get { lock (_lock) return _servers.FirstOrDefault(); }
}
/// <summary>
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
/// </summary>
public async Task<int> 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 }));
}
}
+3
View File
@@ -44,6 +44,9 @@ public static class AppPaths
public static string CertificatePinsPath(NetKind net) => public static string CertificatePinsPath(NetKind net) =>
Path.Combine(ForNetwork(net), "server-certs.json"); Path.Combine(ForNetwork(net), "server-certs.json");
public static string ServersPath(NetKind net) =>
Path.Combine(ForNetwork(net), "servers.json");
public static string ConfigPath() => public static string ConfigPath() =>
Path.Combine(DataRoot(), "config.json"); Path.Combine(DataRoot(), "config.json");
} }
@@ -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);
}
}
}