feat(net): server registry, bootstrap servers, peer discovery
This commit is contained in:
@@ -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 = [],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user