diff --git a/src/Core/Net/CertificatePinStore.cs b/src/Core/Net/CertificatePinStore.cs
new file mode 100644
index 0000000..89ebdf6
--- /dev/null
+++ b/src/Core/Net/CertificatePinStore.cs
@@ -0,0 +1,72 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text.Json;
+
+namespace PalladiumWallet.Core.Net;
+
+///
+/// Pinning TLS "trust on first use" (blueprint §9): al primo contatto la
+/// fingerprint SHA-256 del certificato del server viene salvata; ai successivi
+/// viene confrontata. Se cambia, la connessione va rifiutata e l'utente può
+/// sbloccare con un reset esplicito (caso tipico: server self-signed rinnovato).
+///
+public sealed class CertificatePinStore(string filePath)
+{
+ private readonly object _lock = new();
+
+ public static string Fingerprint(X509Certificate certificate) =>
+ Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
+
+ ///
+ /// Verifica TOFU: true se il certificato è quello già visto (o se è il primo
+ /// contatto, nel qual caso viene salvato). False = certificato cambiato.
+ ///
+ public bool VerifyOrPin(string host, int port, X509Certificate certificate)
+ {
+ var key = $"{host}:{port}";
+ var fingerprint = Fingerprint(certificate);
+ lock (_lock)
+ {
+ var pins = Load();
+ if (pins.TryGetValue(key, out var pinned))
+ return pinned == fingerprint;
+ pins[key] = fingerprint;
+ Save(pins);
+ return true;
+ }
+ }
+
+ /// Reset del certificato salvato per un server ("reset certificati SSL", §9).
+ public bool Reset(string host, int port)
+ {
+ lock (_lock)
+ {
+ var pins = Load();
+ if (!pins.Remove($"{host}:{port}"))
+ return false;
+ Save(pins);
+ return true;
+ }
+ }
+
+ public void ResetAll()
+ {
+ lock (_lock)
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ private Dictionary Load() =>
+ File.Exists(filePath)
+ ? JsonSerializer.Deserialize>(File.ReadAllText(filePath)) ?? []
+ : [];
+
+ private void Save(Dictionary pins)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
+ File.WriteAllText(filePath, JsonSerializer.Serialize(pins,
+ new JsonSerializerOptions { WriteIndented = true }));
+ }
+}
diff --git a/src/Core/Net/ElectrumApi.cs b/src/Core/Net/ElectrumApi.cs
new file mode 100644
index 0000000..9098696
--- /dev/null
+++ b/src/Core/Net/ElectrumApi.cs
@@ -0,0 +1,111 @@
+using System.Text.Json;
+
+namespace PalladiumWallet.Core.Net;
+
+/// Voce di storico di uno scripthash (blockchain.scripthash.get_history).
+public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
+
+/// UTXO riportato dal server (blockchain.scripthash.listunspent).
+public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
+
+/// Prova di Merkle (blockchain.transaction.get_merkle).
+public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList Merkle);
+
+/// Tip della catena notificato da blockchain.headers.subscribe.
+public readonly record struct ChainTip(int Height, string HeaderHex);
+
+///
+/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
+/// trasporto JSON-RPC generico di .
+///
+public static class ElectrumApi
+{
+ public static async Task SubscribeHeadersAsync(this ElectrumClient c, CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.headers.subscribe", ct);
+ return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!);
+ }
+
+ /// Status corrente dello scripthash (null = mai usato).
+ public static async Task SubscribeScripthashAsync(this ElectrumClient c, string scripthash,
+ CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.scripthash.subscribe", ct, scripthash);
+ return r.ValueKind == JsonValueKind.Null ? null : r.GetString();
+ }
+
+ public static async Task> GetHistoryAsync(this ElectrumClient c,
+ string scripthash, CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.scripthash.get_history", ct, scripthash);
+ return [.. r.EnumerateArray().Select(e => new HistoryItem(
+ e.GetProperty("tx_hash").GetString()!,
+ e.GetProperty("height").GetInt32(),
+ e.TryGetProperty("fee", out var fee) ? fee.GetInt64() : 0))];
+ }
+
+ public static async Task> ListUnspentAsync(this ElectrumClient c,
+ string scripthash, CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.scripthash.listunspent", ct, scripthash);
+ return [.. r.EnumerateArray().Select(e => new UnspentItem(
+ e.GetProperty("tx_hash").GetString()!,
+ e.GetProperty("tx_pos").GetInt32(),
+ e.GetProperty("value").GetInt64(),
+ e.GetProperty("height").GetInt32()))];
+ }
+
+ public static async Task GetTransactionAsync(this ElectrumClient c, string txid,
+ CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.transaction.get", ct, txid);
+ return r.GetString()!;
+ }
+
+ public static async Task GetMerkleAsync(this ElectrumClient c, string txid,
+ int height, CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.transaction.get_merkle", ct, txid, height);
+ return new MerkleProofResponse(
+ r.GetProperty("block_height").GetInt32(),
+ r.GetProperty("pos").GetInt32(),
+ [.. r.GetProperty("merkle").EnumerateArray().Select(m => m.GetString()!)]);
+ }
+
+ public static async Task GetBlockHeaderAsync(this ElectrumClient c, int height,
+ CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.block.header", ct, height);
+ return r.GetString()!;
+ }
+
+ public static async Task BroadcastAsync(this ElectrumClient c, string rawTxHex,
+ CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.transaction.broadcast", ct, rawTxHex);
+ return r.GetString()!;
+ }
+
+ /// Fee stimata in coin/kB per il target dato; -1 se il server non sa stimare.
+ public static async Task EstimateFeeAsync(this ElectrumClient c, int targetBlocks,
+ CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.estimatefee", ct, targetBlocks);
+ return r.GetDecimal();
+ }
+
+ public static async Task RelayFeeAsync(this ElectrumClient c, CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("blockchain.relayfee", ct);
+ return r.GetDecimal();
+ }
+
+ public static async Task BannerAsync(this ElectrumClient c, CancellationToken ct = default)
+ {
+ var r = await c.RequestAsync("server.banner", ct);
+ return r.GetString()!;
+ }
+
+ public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
+ c.RequestAsync("server.ping", ct);
+}
diff --git a/src/Core/Net/ElectrumClient.cs b/src/Core/Net/ElectrumClient.cs
new file mode 100644
index 0000000..674e88b
--- /dev/null
+++ b/src/Core/Net/ElectrumClient.cs
@@ -0,0 +1,196 @@
+using System.Collections.Concurrent;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+
+namespace PalladiumWallet.Core.Net;
+
+///
+/// Client del protocollo del server di indicizzazione (blueprint §10):
+/// JSON-RPC 2.0 newline-delimited su TCP, opzionalmente TLS con pinning TOFU.
+/// Le notifiche (subscription) arrivano sull'evento .
+///
+public sealed class ElectrumClient : IAsyncDisposable
+{
+ public const string ClientName = "PalladiumWallet";
+ public const string ProtocolVersion = "1.4";
+
+ private readonly TcpClient _tcp;
+ private readonly Stream _stream;
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+ private readonly ConcurrentDictionary> _pending = new();
+ private readonly CancellationTokenSource _cts = new();
+ private readonly Task _readLoop;
+ private long _nextId;
+
+ public string Host { get; }
+ public int Port { get; }
+ public bool UseSsl { get; }
+ public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested;
+
+ /// (metodo, parametri) per le notifiche di subscription.
+ public event Action? NotificationReceived;
+
+ /// Scatta quando la connessione cade (errore di lettura o chiusura remota).
+ public event Action? Disconnected;
+
+ private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
+ {
+ _tcp = tcp;
+ _stream = stream;
+ Host = host;
+ Port = port;
+ UseSsl = useSsl;
+ _readLoop = Task.Run(ReadLoopAsync);
+ }
+
+ ///
+ /// Connette al server. Con la validazione del
+ /// certificato è TOFU tramite (§9): primo contatto
+ /// salva, contatti successivi confrontano; mismatch ⇒ .
+ ///
+ public static async Task ConnectAsync(string host, int port, bool useSsl,
+ CertificatePinStore? pins = null, CancellationToken ct = default)
+ {
+ var tcp = new TcpClient();
+ try
+ {
+ await tcp.ConnectAsync(host, port, ct);
+ Stream stream = tcp.GetStream();
+ if (useSsl)
+ {
+ var pinOk = true;
+ var ssl = new SslStream(stream, leaveInnerStreamOpen: false,
+ (_, cert, _, _) =>
+ {
+ // I server Electrum sono tipicamente self-signed: la
+ // fiducia è il pin TOFU, non la catena CA (§9).
+ if (cert is null)
+ return false;
+ pinOk = pins is null || pins.VerifyOrPin(host, port, cert);
+ return pinOk;
+ });
+ try
+ {
+ await ssl.AuthenticateAsClientAsync(
+ new SslClientAuthenticationOptions { TargetHost = host }, ct);
+ }
+ catch (System.Security.Authentication.AuthenticationException) when (!pinOk)
+ {
+ throw new CertificatePinMismatchException(host, port);
+ }
+ stream = ssl;
+ }
+
+ var client = new ElectrumClient(tcp, stream, host, port, useSsl);
+ // Negoziazione obbligatoria prima di ogni altra richiesta.
+ await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
+ return client;
+ }
+ catch
+ {
+ tcp.Dispose();
+ throw;
+ }
+ }
+
+ public async Task RequestAsync(string method, CancellationToken ct = default,
+ params object?[] parameters)
+ {
+ var id = Interlocked.Increment(ref _nextId);
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pending[id] = tcs;
+
+ var payload = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ jsonrpc = "2.0",
+ id,
+ method,
+ @params = parameters,
+ });
+
+ await _writeLock.WaitAsync(ct);
+ try
+ {
+ await _stream.WriteAsync(payload, ct);
+ await _stream.WriteAsync("\n"u8.ToArray(), ct);
+ await _stream.FlushAsync(ct);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+
+ await using var registration = ct.Register(() => tcs.TrySetCanceled(ct));
+ return await tcs.Task;
+ }
+
+ private async Task ReadLoopAsync()
+ {
+ Exception? failure = null;
+ try
+ {
+ using var reader = new StreamReader(_stream, Encoding.UTF8, leaveOpen: true);
+ while (!_cts.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(_cts.Token);
+ if (line is null)
+ break; // chiusura remota
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ using var doc = JsonDocument.Parse(line);
+ var root = doc.RootElement;
+
+ if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
+ {
+ if (!_pending.TryRemove(idEl.GetInt64(), out var tcs))
+ continue;
+ if (root.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null)
+ tcs.TrySetException(new ElectrumServerException(error.ToString()));
+ else
+ tcs.TrySetResult(root.GetProperty("result").Clone());
+ }
+ else if (root.TryGetProperty("method", out var methodEl))
+ {
+ NotificationReceived?.Invoke(
+ methodEl.GetString()!,
+ root.GetProperty("params").Clone());
+ }
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ failure = ex;
+ }
+ finally
+ {
+ foreach (var (_, tcs) in _pending)
+ tcs.TrySetException(failure ?? new IOException("Connessione al server chiusa."));
+ _pending.Clear();
+ Disconnected?.Invoke(failure);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+ _tcp.Close();
+ try { await _readLoop; } catch { /* in chiusura */ }
+ _cts.Dispose();
+ _writeLock.Dispose();
+ }
+}
+
+/// Errore restituito dal server (campo "error" della risposta JSON-RPC).
+public sealed class ElectrumServerException(string error) : Exception(error);
+
+///
+/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9).
+/// Si sblocca con il reset esplicito dei certificati.
+///
+public sealed class CertificatePinMismatchException(string host, int port) : Exception(
+ $"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
+ "Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.");