diff --git a/tests/PalladiumWallet.Tests/Net/CertificatePinStoreTests.cs b/tests/PalladiumWallet.Tests/Net/CertificatePinStoreTests.cs
new file mode 100644
index 0000000..8f33856
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Net/CertificatePinStoreTests.cs
@@ -0,0 +1,160 @@
+using System.Security.Cryptography.X509Certificates;
+using PalladiumWallet.Core.Net;
+
+namespace PalladiumWallet.Tests.Net;
+
+///
+/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
+/// certificate passes, changed certificate is rejected until an explicit reset.
+///
+public class CertificatePinStoreTests
+{
+ private static string TempPath() =>
+ Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
+
+ private static X509Certificate2 Cert(string cn) =>
+ FakeElectrumServer.CreateSelfSignedCertificate(cn);
+
+ private static void Cleanup(string path)
+ {
+ if (Directory.Exists(Path.GetDirectoryName(path)))
+ Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
+ }
+
+ [Fact]
+ public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
+ {
+ var path = TempPath();
+ try
+ {
+ using var cert = Cert("server");
+ var store = new CertificatePinStore(path);
+
+ Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
+ Assert.True(File.Exists(path));
+ Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
+
+ // A fresh instance reads the same file (persistence).
+ Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void Un_certificato_cambiato_viene_rifiutato()
+ {
+ var path = TempPath();
+ try
+ {
+ using var original = Cert("originale");
+ using var changed = Cert("cambiato");
+ var store = new CertificatePinStore(path);
+
+ Assert.True(store.VerifyOrPin("host", 50002, original));
+ Assert.False(store.VerifyOrPin("host", 50002, changed));
+ // The rejection must not overwrite the pin.
+ Assert.True(store.VerifyOrPin("host", 50002, original));
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void Host_e_porte_diverse_hanno_pin_indipendenti()
+ {
+ var path = TempPath();
+ try
+ {
+ using var cert1 = Cert("uno");
+ using var cert2 = Cert("due");
+ var store = new CertificatePinStore(path);
+
+ Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
+ Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
+ Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
+ Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
+ {
+ var path = TempPath();
+ try
+ {
+ using var original = Cert("originale");
+ using var renewed = Cert("rinnovato");
+ var store = new CertificatePinStore(path);
+
+ Assert.True(store.VerifyOrPin("host", 50002, original));
+ Assert.False(store.VerifyOrPin("host", 50002, renewed));
+
+ Assert.True(store.Reset("host", 50002));
+ Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
+ Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void Il_reset_di_un_server_mai_visto_restituisce_false()
+ {
+ var path = TempPath();
+ try
+ {
+ Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void ResetAll_cancella_il_file_e_tutti_i_pin()
+ {
+ var path = TempPath();
+ try
+ {
+ using var cert1 = Cert("uno");
+ using var cert2 = Cert("due");
+ var store = new CertificatePinStore(path);
+ store.VerifyOrPin("host-a", 50002, cert1);
+ store.VerifyOrPin("host-b", 50002, cert1);
+
+ store.ResetAll();
+
+ Assert.False(File.Exists(path));
+ Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void Un_file_pin_corrotto_non_blocca_le_connessioni()
+ {
+ var path = TempPath();
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, "{ non-json ");
+
+ using var cert = Cert("server");
+ var store = new CertificatePinStore(path);
+ Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
+ Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
+ }
+ finally { Cleanup(path); }
+ }
+
+ [Fact]
+ public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
+ {
+ using var cert = Cert("server");
+ var fingerprint = CertificatePinStore.Fingerprint(cert);
+
+ Assert.Equal(64, fingerprint.Length);
+ Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
+ Assert.Equal(
+ Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
+ .ToLowerInvariant(),
+ fingerprint);
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs b/tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs
new file mode 100644
index 0000000..7fc90ae
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs
@@ -0,0 +1,189 @@
+using System.Text.Json;
+using PalladiumWallet.Core.Net;
+
+namespace PalladiumWallet.Tests.Net;
+
+///
+/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
+/// (real loopback TCP socket: framing, pipelining, and failure paths are the
+/// same code paths used in production).
+///
+public class ElectrumClientTests
+{
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
+
+ [Fact]
+ public async Task Connessione_e_richiesta_fanno_roundtrip()
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("server.banner", _ => "benvenuto");
+
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ Assert.True(client.IsConnected);
+ Assert.Equal("benvenuto", await client.BannerAsync());
+ // ConnectAsync performs the protocol handshake up front.
+ Assert.Equal(1, server.CallCount("server.version"));
+ }
+
+ [Fact]
+ public async Task Un_errore_del_server_diventa_ElectrumServerException()
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("blockchain.transaction.get",
+ _ => throw new FakeElectrumError(-32600, "tx non trovata"));
+
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ var ex = await Assert.ThrowsAsync(
+ () => client.GetTransactionAsync("00"));
+ Assert.Contains("tx non trovata", ex.Message);
+ Assert.Contains("-32600", ex.Message);
+ }
+
+ [Fact]
+ public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("echo", p => p[0].GetInt32());
+
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ // More requests than the in-flight cap (32): exercises both the write
+ // batching and the id → response correlation.
+ var tasks = Enumerable.Range(0, 200)
+ .Select(i => client.RequestAsync("echo", default, i))
+ .ToList();
+ var results = await Task.WhenAll(tasks);
+
+ for (var i = 0; i < results.Length; i++)
+ Assert.Equal(i, results[i].GetInt32());
+ }
+
+ [Fact]
+ public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
+ {
+ await using var server = new FakeElectrumServer();
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
+
+ await server.NotifyAsync("blockchain.headers.subscribe", new object[]
+ {
+ new { height = 4242, hex = "00" },
+ });
+
+ var (method, parameters) = await received.Task.WaitAsync(Timeout);
+ Assert.Equal("blockchain.headers.subscribe", method);
+ Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
+ }
+
+ [Fact]
+ public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
+
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.Disconnected += _ => disconnected.TrySetResult();
+
+ var pending = client.BannerAsync();
+ server.DropClients();
+
+ await Assert.ThrowsAnyAsync(() => pending.WaitAsync(Timeout));
+ await disconnected.Task.WaitAsync(Timeout);
+ }
+
+ [Fact]
+ public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
+
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ using var cts = new CancellationTokenSource();
+ var pending = client.BannerAsync(cts.Token);
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => pending.WaitAsync(Timeout));
+ }
+
+ [Fact]
+ public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("blockchain.scripthash.listunspent", _ => new object[]
+ {
+ new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
+ });
+ server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
+ server.Handle("blockchain.estimatefee", _ => 0.00012m);
+ server.Handle("blockchain.relayfee", _ => 0.00001m);
+ server.Handle("server.ping", _ => null);
+
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
+ Assert.Equal(12_345L, unspent.ValueSats);
+ Assert.Equal(1, unspent.TxPos);
+ Assert.Equal(99, unspent.Height);
+
+ Assert.Equal("4", await client.BroadcastAsync("beef"));
+ Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
+ Assert.Equal(0.00001m, await client.RelayFeeAsync());
+ await client.PingAsync(); // must not throw
+ }
+
+ [Fact]
+ public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
+ {
+ var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
+ try
+ {
+ var pins = new CertificatePinStore(pinsPath);
+ int port;
+
+ // First contact: any certificate is pinned (trust on first use).
+ using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
+ {
+ await using var server = new FakeElectrumServer(cert1);
+ port = server.Port;
+ await using var client = await ElectrumClient.ConnectAsync(
+ server.Host, port, useSsl: true, pins);
+ Assert.True(client.IsConnected);
+ Assert.True(client.UseSsl);
+ }
+
+ // Same host:port, different certificate: the pin must block the connection.
+ using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
+ {
+ await using var server = await RebindAsync(cert2, port);
+ await Assert.ThrowsAsync(() =>
+ ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
+ }
+ }
+ finally
+ {
+ File.Delete(pinsPath);
+ }
+ }
+
+ /// Rebinds a fresh server to the port just released by the previous one.
+ private static async Task RebindAsync(
+ System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
+ {
+ for (var attempt = 0; ; attempt++)
+ {
+ try { return new FakeElectrumServer(cert, port); }
+ catch (System.Net.Sockets.SocketException) when (attempt < 20)
+ {
+ await Task.Delay(50);
+ }
+ }
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs b/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs
index 8c31538..df24515 100644
--- a/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs
+++ b/tests/PalladiumWallet.Tests/Net/ServerRegistryTests.cs
@@ -79,6 +79,49 @@ public class ServerRegistryTests
}
}
+ [Fact]
+ public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
+ {
+ var path = TempPath();
+ try
+ {
+ await using var server = new FakeElectrumServer();
+ server.Handle("server.peers.subscribe", _ => new object[]
+ {
+ // Full announcement, port-less announcement (→ profile defaults),
+ // and a bootstrap duplicate that must not be re-added.
+ new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
+ new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
+ new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
+ });
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
+ var bootstrapCount = registry.All.Count;
+
+ var added = await registry.DiscoverAsync(client);
+
+ Assert.Equal(2, added);
+ Assert.Equal(bootstrapCount + 2, registry.All.Count);
+ var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
+ Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
+ Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
+
+ // Persisted for the next session (bootstrap servers excluded from the file).
+ var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
+ Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
+ var savedJson = File.ReadAllText(path);
+ Assert.DoesNotContain("173.212.224.67", savedJson);
+
+ // A second discovery of the same peers adds nothing.
+ Assert.Equal(0, await registry.DiscoverAsync(client));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
[Fact]
public void Un_file_server_corrotto_non_blocca_l_avvio()
{
diff --git a/tests/PalladiumWallet.Tests/Net/UpdateCheckerTests.cs b/tests/PalladiumWallet.Tests/Net/UpdateCheckerTests.cs
new file mode 100644
index 0000000..1f2d72f
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Net/UpdateCheckerTests.cs
@@ -0,0 +1,44 @@
+using PalladiumWallet.Core.Net;
+
+namespace PalladiumWallet.Tests.Net;
+
+///
+/// Tests for the release-tag parsing used by the update check. The network
+/// call itself is best-effort by design (any failure → null) and is not
+/// exercised here: only the version-comparison logic is deterministic.
+///
+public class UpdateCheckerTests
+{
+ [Theory]
+ [InlineData("v1.2.3", "1.2.3")]
+ [InlineData("V0.9.1", "0.9.1")]
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("0.9.0-beta", "0.9.0")]
+ [InlineData("v2.0.0-rc.1", "2.0.0")]
+ [InlineData(" 1.4 ", "1.4")]
+ public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
+ {
+ Assert.True(UpdateChecker.TryParse(tag, out var version));
+ Assert.Equal(Version.Parse(expected), version);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("main")]
+ [InlineData("v")]
+ [InlineData("1")]
+ [InlineData("non-una-versione")]
+ public void I_tag_non_versionati_vengono_rifiutati(string tag)
+ {
+ Assert.False(UpdateChecker.TryParse(tag, out _));
+ }
+
+ [Fact]
+ public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
+ {
+ // The same comparison CheckAsync applies between remote tag and running app.
+ Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
+ Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
+ Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs
new file mode 100644
index 0000000..03443a8
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs
@@ -0,0 +1,376 @@
+using System.Text.Json;
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+using PalladiumWallet.Core.Net;
+using PalladiumWallet.Core.Spv;
+using PalladiumWallet.Tests.Net;
+
+namespace PalladiumWallet.Tests.Spv;
+
+///
+/// End-to-end tests of the SPV synchroniser against the in-process fake server:
+/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
+/// path that must reject a lying server), busy retry, and the disk cache.
+/// Every block in these scenarios contains a single transaction, so the Merkle
+/// root is the txid itself and the branch is empty (the tree math has its own
+/// dedicated tests in ).
+///
+public class WalletSynchronizerTests
+{
+ private static readonly ChainProfile Profile = ChainProfiles.Regtest;
+ private static readonly Network Net = PalladiumNetworks.Regtest;
+
+ private static HdAccount Account()
+ {
+ Assert.True(Bip39.TryParse(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ out var mnemonic));
+ return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
+ }
+
+ ///
+ /// Server-side chain state: transactions, per-scripthash histories and
+ /// single-transaction block headers, wired onto a FakeElectrumServer.
+ ///
+ private sealed class Scenario
+ {
+ public int TipHeight = 200;
+ public readonly Dictionary Txs = [];
+ public readonly Dictionary> History = [];
+ public readonly Dictionary Headers = [];
+
+ /// Creates a tx paying to and registers it.
+ public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
+ bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
+ {
+ var tx = Net.CreateTransaction();
+ tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
+ tx.Outputs.Add(Money.Satoshis(sats), to);
+ if (changeTo is not null)
+ tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
+ Register(tx, height, to, changeTo);
+ return tx;
+ }
+
+ public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
+ {
+ var txid = tx.GetHash().ToString();
+ Txs[txid] = tx;
+ foreach (var addr in touchedAddresses)
+ {
+ if (addr is null) continue;
+ var sh = Scripthash.FromAddress(addr);
+ if (!History.TryGetValue(sh, out var list))
+ History[sh] = list = [];
+ if (!list.Contains((txid, height)))
+ list.Add((txid, height));
+ }
+ if (height > 0 && !Headers.ContainsKey(height))
+ Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
+ }
+
+ /// 80-byte header of a block whose only transaction is .
+ public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null)
+ {
+ var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
+ header.HashPrevBlock = uint256.Zero;
+ header.HashMerkleRoot = merkleRoot ?? txid;
+ header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
+ header.Bits = new Target(0x1d00ffffu);
+ header.Nonce = (uint)height;
+ return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
+ }
+
+ public void WireTo(FakeElectrumServer server)
+ {
+ server.Handle("blockchain.headers.subscribe",
+ _ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
+ server.Handle("blockchain.scripthash.subscribe", _ => null);
+ server.Handle("blockchain.scripthash.get_history", p =>
+ (History.GetValueOrDefault(p[0].GetString()!) ?? [])
+ .Select(h => new { tx_hash = h.Txid, height = h.Height }));
+ server.Handle("blockchain.transaction.get", p =>
+ Txs.TryGetValue(p[0].GetString()!, out var tx)
+ ? tx.ToHex()
+ : throw new FakeElectrumError(-32600, "no such transaction"));
+ server.Handle("blockchain.transaction.get_merkle", p =>
+ new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty() });
+ server.Handle("blockchain.block.header", p =>
+ Headers.TryGetValue(p[0].GetInt32(), out var hex)
+ ? hex
+ : throw new FakeElectrumError(-32600, "no such block"));
+ }
+ }
+
+ private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
+ {
+ var server = new FakeElectrumServer();
+ scenario.WireTo(server);
+ var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+ return (server, client);
+ }
+
+ // ---- scansione ----
+
+ [Fact]
+ public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
+ {
+ var scenario = new Scenario();
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
+
+ Assert.Equal(200, result.TipHeight);
+ Assert.Equal(0, result.ConfirmedSats);
+ Assert.Equal(0, result.UnconfirmedSats);
+ Assert.Equal(0, result.NextReceiveIndex);
+ Assert.Equal(0, result.NextChangeIndex);
+ // Default gap limit 20 on both chains.
+ Assert.Equal(40, result.Addresses.Count);
+ Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
+ Assert.Empty(result.Utxos);
+ Assert.Empty(result.History);
+ }
+
+ [Fact]
+ public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
+
+ // Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
+ Assert.Equal(4, result.NextReceiveIndex);
+ Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
+ Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
+ Assert.Equal(50_000, result.ConfirmedSats);
+ }
+
+ // ---- ricostruzione UTXO e storico ----
+
+ [Fact]
+ public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
+
+ Assert.Equal(1_000_000, result.ConfirmedSats);
+ Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
+ Assert.Equal(1, result.NextReceiveIndex);
+
+ var utxo = Assert.Single(result.Utxos);
+ Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
+ Assert.Equal(100, utxo.Height);
+ Assert.False(utxo.IsChange);
+
+ var entry = Assert.Single(result.History);
+ Assert.Equal(1_000_000, entry.DeltaSats);
+ Assert.True(entry.Verified); // Merkle proof checked against the header
+
+ var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
+ Assert.Equal(1_000_000, row.BalanceSats);
+ Assert.Equal(1, row.TxCount);
+ }
+
+ [Fact]
+ public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
+
+ // Spend: 600k to an external address, 390k change to change/0, 10k fee.
+ var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
+ var spend = Net.CreateTransaction();
+ spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
+ spend.Outputs.Add(Money.Satoshis(600_000), external);
+ spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
+ scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
+
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
+
+ // The funding UTXO is spent: only the change remains.
+ var utxo = Assert.Single(result.Utxos);
+ Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
+ Assert.True(utxo.IsChange);
+ Assert.Equal(390_000, result.ConfirmedSats);
+ Assert.Equal(1, result.NextChangeIndex);
+
+ // History newest-first with the net deltas.
+ Assert.Equal(2, result.History.Count);
+ Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
+ Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change − 1M spent
+ Assert.Equal(1_000_000, result.History[1].DeltaSats);
+ }
+
+ [Fact]
+ public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
+
+ Assert.Equal(0, result.ConfirmedSats);
+ Assert.Equal(250_000, result.UnconfirmedSats);
+ var entry = Assert.Single(result.History);
+ Assert.False(entry.Verified);
+ Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
+ Assert.Equal(0, server.CallCount("blockchain.block.header"));
+ }
+
+ [Fact]
+ public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
+ {
+ var account = Account();
+ var scenario = new Scenario { TipHeight = 200 };
+ // 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
+ scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
+
+ Assert.Equal(5_000_000_000, result.ConfirmedSats);
+ Assert.Equal(5_000_000_000, result.ImmatureSats);
+ Assert.True(Assert.Single(result.Utxos).IsCoinbase);
+ }
+
+ // ---- sicurezza SPV ----
+
+ [Fact]
+ public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
+ // The server lies: the block 100 header commits to a different Merkle root.
+ scenario.Headers[100] = Scenario.SingleTxHeaderHex(
+ funding.GetHash(), 100, merkleRoot: uint256.One);
+
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var ex = await Assert.ThrowsAsync(
+ () => new WalletSynchronizer(account, client).SyncOnceAsync());
+ Assert.Contains(funding.GetHash().ToString(), ex.Message);
+ }
+
+ // ---- resilienza ----
+
+ [Fact]
+ public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
+
+ var server = new FakeElectrumServer();
+ scenario.WireTo(server);
+ // The first 3 history calls fail with the ElectrumX throttling error.
+ var failures = 3;
+ var histories = scenario.History;
+ server.Handle("blockchain.scripthash.get_history", p =>
+ {
+ if (Interlocked.Decrement(ref failures) >= 0)
+ throw new FakeElectrumError(-102, "excessive resource usage");
+ return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
+ .Select(h => new { tx_hash = h.Txid, height = h.Height });
+ });
+
+ await using var _ = server;
+ await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+
+ var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
+ Assert.Equal(1_000_000, result.ConfirmedSats);
+ }
+
+ // ---- cache su disco ----
+
+ [Fact]
+ public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var first = new WalletSynchronizer(account, client);
+ var result1 = await first.SyncOnceAsync();
+ var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
+
+ Assert.Single(rawTx); // the confirmed tx is exported
+ Assert.Single(verifiedAt); // with its verified height
+ Assert.NotEmpty(headers);
+
+ // Fresh synchroniser (new launch), warm cache: no re-download.
+ server.ResetCallCounts();
+ var second = new WalletSynchronizer(account, client);
+ second.PreloadCaches(rawTx, verifiedAt, headers,
+ result1.NextReceiveIndex, result1.NextChangeIndex, Net);
+ var result2 = await second.SyncOnceAsync();
+
+ Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
+ Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
+ Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
+ Assert.Equal(0, server.CallCount("blockchain.block.header"));
+ }
+
+ [Fact]
+ public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
+ {
+ var account = Account();
+ var scenario = new Scenario();
+ scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var sync = new WalletSynchronizer(account, client);
+ await sync.SyncOnceAsync();
+ var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
+
+ // Unconfirmed txs can change (RBF): they must always be re-downloaded.
+ Assert.Empty(rawTx);
+ Assert.Empty(verifiedAt);
+ }
+
+ // ---- account a indirizzi fissi (WIF importati) ----
+
+ [Fact]
+ public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
+ {
+ var key = new Key();
+ var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
+ var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
+
+ var scenario = new Scenario();
+ scenario.Pay(address, 750_000, height: 150);
+ var (server, client) = await StartAsync(scenario);
+ await using var _ = server; await using var __ = client;
+
+ var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
+
+ Assert.Equal(750_000, result.ConfirmedSats);
+ Assert.Single(result.Addresses);
+ Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
+ Assert.Equal(1, result.NextReceiveIndex);
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs
new file mode 100644
index 0000000..01ee0c7
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs
@@ -0,0 +1,217 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+using PalladiumWallet.Core.Net;
+using PalladiumWallet.Core.Wallet;
+using PalladiumWallet.Tests.Net;
+
+namespace PalladiumWallet.Tests.Wallet;
+
+///
+/// Tests for the transaction detail assembly (§10): fee from resolved inputs,
+/// mine/theirs attribution, RBF flag, coinbase, and the degraded paths when the
+/// server cannot provide a previous transaction.
+///
+public class TransactionInspectorTests
+{
+ private static readonly Network Net = PalladiumNetworks.Regtest;
+
+ private static HdAccount Account()
+ {
+ Assert.True(Bip39.TryParse(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ out var mnemonic));
+ return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
+ }
+
+ /// Funding (1M sats to receive/0) + spend (600k external, 390k change/0, 10k fee, RBF).
+ private static (Transaction Funding, Transaction Spend, BitcoinAddress External, HdAccount Account) SpendPair()
+ {
+ var account = Account();
+ var funding = Net.CreateTransaction();
+ funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
+ funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
+
+ var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
+ var spend = Net.CreateTransaction();
+ spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
+ spend.Outputs.Add(Money.Satoshis(600_000), external);
+ spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
+ return (funding, spend, external, account);
+ }
+
+ private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(
+ params Transaction[] served)
+ {
+ var byId = served.ToDictionary(t => t.GetHash().ToString());
+ var server = new FakeElectrumServer();
+ server.Handle("blockchain.transaction.get", p =>
+ byId.TryGetValue(p[0].GetString()!, out var tx)
+ ? tx.ToHex()
+ : throw new FakeElectrumError(-32600, "no such transaction"));
+ server.Handle("blockchain.block.header", p =>
+ {
+ var height = p[0].GetInt32();
+ var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
+ header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
+ header.Bits = new Target(0x1d00ffffu);
+ return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
+ });
+ var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
+ return (server, client);
+ }
+
+ private static HashSet Owned(HdAccount account) =>
+ [
+ account.GetReceiveAddress(0).ToString(),
+ account.GetChangeAddress(0).ToString(),
+ ];
+
+ [Fact]
+ public async Task Una_spesa_confermata_riporta_fee_conferme_e_attribuzione_mine_theirs()
+ {
+ var (funding, spend, external, account) = SpendPair();
+ var (server, client) = await StartAsync(funding, spend);
+ await using var _ = server; await using var __ = client;
+
+ var details = await TransactionInspector.FetchAsync(
+ client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
+ Owned(account), netSats: -610_000, verified: true);
+
+ Assert.Equal(10_000, details.FeeSats);
+ Assert.Equal(1_000_000, details.TotalInSats);
+ Assert.Equal(990_000, details.TotalOutSats);
+ Assert.Equal(100, details.Confirmations); // 200 − 101 + 1
+ Assert.True(details.RbfSignaled);
+ Assert.True(details.Verified);
+ Assert.False(details.IsCoinbase);
+ Assert.False(details.IsIncoming);
+ Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_101), details.BlockTime);
+ Assert.Equal((double)10_000 / details.VirtualSize, details.FeeRateSatPerVb);
+
+ var input = Assert.Single(details.Inputs);
+ Assert.True(input.IsMine);
+ Assert.Equal(1_000_000, input.AmountSats);
+ Assert.Equal(account.GetReceiveAddress(0).ToString(), input.Address);
+
+ Assert.Equal(2, details.Outputs.Count);
+ Assert.Equal(600_000, details.SentToOthersSats);
+ Assert.Equal(390_000, details.ReceivedSats);
+ Assert.Contains(details.Outputs, o => o.IsMine && o.AmountSats == 390_000);
+ Assert.Contains(details.Outputs, o => !o.IsMine && o.AmountSats == 600_000);
+
+ // Outgoing tx: the counterparty is the external recipient.
+ Assert.Equal([external.ToString()], details.CounterpartyAddresses);
+ }
+
+ [Fact]
+ public async Task Una_ricezione_indica_il_mittente_come_controparte()
+ {
+ var account = Account();
+ var senderKey = new Key();
+ var senderAddr = senderKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
+
+ // The sender's coin, then the payment to us spending it.
+ var senderFunding = Net.CreateTransaction();
+ senderFunding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
+ senderFunding.Outputs.Add(Money.Satoshis(2_000_000), senderAddr);
+
+ var payment = Net.CreateTransaction();
+ payment.Inputs.Add(new TxIn(new OutPoint(senderFunding, 0)));
+ payment.Outputs.Add(Money.Satoshis(1_500_000), account.GetReceiveAddress(0));
+
+ var (server, client) = await StartAsync(senderFunding, payment);
+ await using var _ = server; await using var __ = client;
+
+ var details = await TransactionInspector.FetchAsync(
+ client, Net, payment.GetHash().ToString(), tipHeight: 200, height: 150,
+ Owned(account), netSats: 1_500_000, verified: true);
+
+ Assert.True(details.IsIncoming);
+ Assert.Equal([senderAddr.ToString()], details.CounterpartyAddresses);
+ Assert.Equal(500_000, details.FeeSats); // 2M in − 1.5M out
+ }
+
+ [Fact]
+ public async Task Una_coinbase_non_ha_fee_ne_importi_in_ingresso()
+ {
+ var account = Account();
+ var coinbase = Net.CreateTransaction();
+ coinbase.Inputs.Add(new TxIn());
+ coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
+
+ var (server, client) = await StartAsync(coinbase);
+ await using var _ = server; await using var __ = client;
+
+ var details = await TransactionInspector.FetchAsync(
+ client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
+ Owned(account), netSats: 5_000_000_000, verified: true);
+
+ Assert.True(details.IsCoinbase);
+ Assert.Null(details.FeeSats);
+ Assert.Null(details.TotalInSats);
+ Assert.Null(details.FeeRateSatPerVb);
+ Assert.Equal(11, details.Confirmations);
+ var input = Assert.Single(details.Inputs);
+ Assert.True(input.IsCoinbase);
+ Assert.Null(input.AmountSats);
+ // No previous transactions to resolve.
+ Assert.Equal(0, server.CallCount("blockchain.transaction.get") - 1); // only the coinbase itself
+ }
+
+ [Fact]
+ public async Task Se_la_tx_precedente_non_e_recuperabile_la_fee_resta_ignota()
+ {
+ var (funding, spend, _, account) = SpendPair();
+ // The server only knows the spend: the funding lookup fails.
+ var (server, client) = await StartAsync(spend);
+ await using var _ = server; await using var __ = client;
+
+ var details = await TransactionInspector.FetchAsync(
+ client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
+ Owned(account), netSats: -610_000, verified: false);
+
+ Assert.Null(details.FeeSats);
+ Assert.Null(details.TotalInSats);
+ var input = Assert.Single(details.Inputs);
+ Assert.Null(input.AmountSats);
+ Assert.False(input.IsMine); // unresolvable → not attributable
+ Assert.Equal(funding.GetHash().ToString(), input.PrevTxid);
+ }
+
+ [Fact]
+ public async Task Una_tx_in_mempool_non_ha_conferme_ne_timestamp()
+ {
+ var (funding, spend, _, account) = SpendPair();
+ var (server, client) = await StartAsync(funding, spend);
+ await using var _ = server; await using var __ = client;
+
+ var details = await TransactionInspector.FetchAsync(
+ client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
+ Owned(account), netSats: -610_000, verified: false);
+
+ Assert.Equal(0, details.Confirmations);
+ Assert.Null(details.BlockTime);
+ Assert.Equal(0, server.CallCount("blockchain.block.header"));
+ }
+
+ [Fact]
+ public async Task La_cache_delle_tx_evita_le_richieste_al_server()
+ {
+ var (funding, spend, _, account) = SpendPair();
+ var cache = new Dictionary
+ {
+ [funding.GetHash().ToString()] = funding,
+ [spend.GetHash().ToString()] = spend,
+ };
+ var (server, client) = await StartAsync();
+ await using var _ = server; await using var __ = client;
+
+ var details = await TransactionInspector.FetchAsync(
+ client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
+ Owned(account), netSats: -610_000, verified: false, cache);
+
+ Assert.Equal(10_000, details.FeeSats);
+ Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
+ }
+}