From 448f7dc65d4c5d369656b71b2b4e17c653080cdd Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 2 Jul 2026 23:20:54 +0200 Subject: [PATCH] test: cover network/SPV layer end to end (was previously untested) ElectrumClient, WalletSynchronizer, TransactionInspector, CertificatePinStore and peer discovery went from 0% coverage to close to full coverage, all against the fake ElectrumX server: - ElectrumClient: request/response correlation under load, server errors, notifications, disconnection, cancellation, SSL handshake with TOFU pinning. - WalletSynchronizer: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse, and the security-critical path where a lying server fails Merkle verification and the sync must abort. - TransactionInspector: fee calculation from resolved inputs, mine/theirs attribution, RBF flag, coinbase handling, degraded paths when a previous transaction can't be resolved. - CertificatePinStore: TOFU pin/match/mismatch/reset, corrupted-file fallback. - ServerRegistry: peer discovery and persistence via DiscoverAsync. - UpdateChecker: release-tag parsing and semver comparison. --- .../Net/CertificatePinStoreTests.cs | 160 ++++++++ .../Net/ElectrumClientTests.cs | 189 +++++++++ .../Net/ServerRegistryTests.cs | 43 ++ .../Net/UpdateCheckerTests.cs | 44 ++ .../Spv/WalletSynchronizerTests.cs | 376 ++++++++++++++++++ .../Wallet/TransactionInspectorTests.cs | 217 ++++++++++ 6 files changed, 1029 insertions(+) create mode 100644 tests/PalladiumWallet.Tests/Net/CertificatePinStoreTests.cs create mode 100644 tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs create mode 100644 tests/PalladiumWallet.Tests/Net/UpdateCheckerTests.cs create mode 100644 tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs create mode 100644 tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs 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")); + } +}