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.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 = [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user