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.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 = [],
};
+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>
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>
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
/// 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) =>
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) =>
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");
}