From ee0b73dd52bed1a5280fe035ff2517655b7342cb Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sat, 13 Jun 2026 21:59:49 +0200 Subject: [PATCH] test: expand coverage to 209 tests across all core modules Added tests for CoinAmount (all units, comma decimal, sub-satoshi rejection, overflow, unknown unit ArgumentException, roundtrip), Storage (nonce/salt uniqueness per encrypt, atomic write, WalletLock acquire/release/stale-file), WalletLoader (NewFromMnemonic, passphrase isolation, watch-only, invalid inputs, ProfileOf), SPV/Merkle (single tx, odd/even counts, altered proof, empty list), and Chain (unknown NetKind, distinct profiles, port constants). --- .../Chain/ChainProfileTests.cs | 36 ++++ tests/PalladiumWallet.Tests/Spv/SpvTests.cs | 125 +++++++++++- .../Storage/StorageTests.cs | 161 +++++++++++++-- .../Wallet/CoinAmountTests.cs | 152 ++++++++++++-- .../Wallet/WalletLoaderTests.cs | 188 ++++++++++++++++++ 5 files changed, 624 insertions(+), 38 deletions(-) create mode 100644 tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs diff --git a/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs b/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs index c4e11f4..5b6757e 100644 --- a/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs +++ b/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs @@ -69,6 +69,42 @@ public class ChainProfileTests Assert.StartsWith("tpub", EncodeWithHeader(headers.Public)); } + [Fact] + public void Rete_sconosciuta_lancia_ArgumentException() + { + Assert.ThrowsAny(() => ChainProfiles.For((NetKind)99)); + } + + [Fact] + public void I_tre_profili_sono_istanze_distinte() + { + Assert.NotSame(ChainProfiles.Mainnet, ChainProfiles.Testnet); + Assert.NotSame(ChainProfiles.Mainnet, ChainProfiles.Regtest); + Assert.NotSame(ChainProfiles.Testnet, ChainProfiles.Regtest); + } + + [Fact] + public void Tutti_i_profili_hanno_gli_stessi_porti_tcp_ssl() + { + foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest }) + { + Assert.Equal(50001, profile.DefaultTcpPort); + Assert.Equal(50002, profile.DefaultSslPort); + } + } + + [Fact] + public void Coin_type_mainnet_e_746() + { + Assert.Equal(746, ChainProfiles.Mainnet.Bip44CoinType); + } + + [Fact] + public void Coin_type_testnet_e_1() + { + Assert.Equal(1, ChainProfiles.Testnet.Bip44CoinType); + } + // Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check: // il prefisso testuale risultante dipende solo dall'header. private static string EncodeWithHeader(uint header) diff --git a/tests/PalladiumWallet.Tests/Spv/SpvTests.cs b/tests/PalladiumWallet.Tests/Spv/SpvTests.cs index 47b50ed..d719787 100644 --- a/tests/PalladiumWallet.Tests/Spv/SpvTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/SpvTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; using NBitcoin; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Spv; @@ -17,12 +20,26 @@ public class ScripthashTests var script = Script.FromHex(scriptHex); Assert.Equal(expected, Scripthash.FromScript(script)); } + + [Fact] + public void Script_identici_producono_scripthash_identico() + { + var script = Script.FromHex("76a9140102030405060708090a0b0c0d0e0f101112131488ac"); + Assert.Equal(Scripthash.FromScript(script), Scripthash.FromScript(script)); + } + + [Fact] + public void Script_diversi_producono_scripthash_diversi() + { + var s1 = Script.FromHex("76a9140102030405060708090a0b0c0d0e0f101112131488ac"); + var s2 = Script.FromHex("00140102030405060708090a0b0c0d0e0f1011121314"); + Assert.NotEqual(Scripthash.FromScript(s1), Scripthash.FromScript(s2)); + } } public class MerkleProofTests { - // Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna - // per la convenzione di hashing/ordinamento. + // Blocco Bitcoin 100000: 4 transazioni (pari), merkle root nota. private static readonly uint256[] Block100000Txids = [ uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"), @@ -34,6 +51,8 @@ public class MerkleProofTests private static readonly uint256 Block100000Root = uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766"); + // ---- numero pari di transazioni (4 tx) ---- + [Fact] public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco() { @@ -51,6 +70,70 @@ public class MerkleProofTests Assert.True(MerkleProof.Verify(Block100000Txids[position], position, branch, Block100000Root)); } + // ---- singola transazione ---- + + [Fact] + public void Radice_con_singola_tx_e_la_tx_stessa() + { + var txid = uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"); + var root = MerkleProof.ComputeRootFromLeaves([txid]); + Assert.Equal(txid, root); + } + + [Fact] + public void Verify_con_branch_vuoto_e_posizione_0_e_la_tx_stessa() + { + var txid = uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"); + var root = MerkleProof.ComputeRootFromLeaves([txid]); + Assert.True(MerkleProof.Verify(txid, 0, [], root)); + } + + // ---- numero dispari di transazioni (duplicazione dell'ultimo) ---- + + [Fact] + public void Tre_tx_dispari_la_terza_viene_duplicata() + { + // Con 3 tx: livello 1 = [SHA256d(tx0||tx1), SHA256d(tx2||tx2)] + // Verifica che la radice sia deterministica e corretta. + var txids = Block100000Txids.Take(3).ToArray(); + var root = MerkleProof.ComputeRootFromLeaves(txids); + var branch2 = BuildBranch(txids, 2); + Assert.True(MerkleProof.Verify(txids[2], 2, branch2, root)); + } + + [Fact] + public void Cinque_tx_dispari_verifica_tutte_le_posizioni() + { + // 5 tx → livello pari (4) → livello pari (2) → radice + var txids = new uint256[5]; + for (var i = 0; i < 5; i++) + txids[i] = new uint256(new byte[32].Select((_, j) => (byte)(i * 17 + j)).ToArray()); + var root = MerkleProof.ComputeRootFromLeaves(txids); + + for (var pos = 0; pos < 5; pos++) + { + var branch = BuildBranch(txids, pos); + Assert.True(MerkleProof.Verify(txids[pos], pos, branch, root), + $"posizione {pos} non verifica"); + } + } + + // ---- due transazioni (pari minimo) ---- + + [Fact] + public void Due_tx_verifica_entrambe_le_posizioni() + { + var txids = Block100000Txids.Take(2).ToArray(); + var root = MerkleProof.ComputeRootFromLeaves(txids); + for (var pos = 0; pos < 2; pos++) + { + var branch = BuildBranch(txids, pos); + Assert.True(MerkleProof.Verify(txids[pos], pos, branch, root)); + } + } + + // ---- proof errate ---- + [Fact] public void Una_prova_per_la_posizione_sbagliata_fallisce() { @@ -65,6 +148,29 @@ public class MerkleProofTests Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root)); } + [Fact] + public void Branch_alterato_non_verifica() + { + var branch = BuildBranch(Block100000Txids, 0); + branch[0] = uint256.One; // corrompe il primo elemento del branch + Assert.False(MerkleProof.Verify(Block100000Txids[0], 0, branch, Block100000Root)); + } + + [Fact] + public void Radice_alterata_non_verifica() + { + var branch = BuildBranch(Block100000Txids, 0); + Assert.False(MerkleProof.Verify(Block100000Txids[0], 0, branch, uint256.One)); + } + + // ---- lista vuota lancia eccezione ---- + + [Fact] + public void Lista_vuota_lancia_ArgumentException() + { + Assert.Throws(() => MerkleProof.ComputeRootFromLeaves([])); + } + /// Costruisce il branch per una foglia ricostruendo i livelli dell'albero. private static List BuildBranch(IReadOnlyList leaves, int position) { @@ -89,7 +195,6 @@ public class MerkleProofTests public class BlockHeaderInfoTests { - // Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3). private const string GenesisHeaderHex = "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c"; @@ -118,10 +223,20 @@ public class BlockHeaderInfoTests [Fact] public void Con_skip_pow_la_validazione_non_controlla_il_target() { - // La genesi ha PoW valido, ma il punto è che con SkipPowValidation=true - // (LWMA, §3) il check si limita al collegamento. var header = BlockHeaderInfo.Parse(GenesisHeaderHex); Assert.True(ChainProfiles.Mainnet.SkipPowValidation); Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet)); } + + [Fact] + public void Header_troncato_lancia_eccezione() + { + Assert.ThrowsAny(() => BlockHeaderInfo.Parse("0100000000")); + } + + [Fact] + public void Header_hex_non_valido_lancia_eccezione() + { + Assert.ThrowsAny(() => BlockHeaderInfo.Parse("ZZZ")); + } } diff --git a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs index 98ab989..be25b3e 100644 --- a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs +++ b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs @@ -1,3 +1,6 @@ +using System; +using System.IO; +using System.Text.Json.Nodes; using PalladiumWallet.Core.Storage; namespace PalladiumWallet.Tests.Storage; @@ -14,6 +17,11 @@ public class StorageTests Labels = { ["txid123"] = "caffè" }, }; + private static string TempPath() => + Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json"); + + // ---- cifratura AES-GCM ---- + [Fact] public void La_cifratura_fa_roundtrip_con_la_password_giusta() { @@ -34,14 +42,43 @@ public class StorageTests public void Un_file_manomesso_viene_rifiutato() { var cipher = EncryptedFile.Encrypt("contenuto", "pass"); - // Corrompe un byte del ciphertext mantenendo base64 e JSON validi. - var node = System.Text.Json.Nodes.JsonNode.Parse(cipher)!; + var node = JsonNode.Parse(cipher)!; var data = Convert.FromBase64String(node["Data"]!.GetValue()); data[0] ^= 0xff; node["Data"] = Convert.ToBase64String(data); Assert.Throws(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass")); } + [Fact] + public void Ogni_encrypt_produce_nonce_diverso() + { + var c1 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Nonce"]!.GetValue(); + var c2 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Nonce"]!.GetValue(); + Assert.NotEqual(c1, c2); + } + + [Fact] + public void Ogni_encrypt_produce_salt_diverso() + { + var s1 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Salt"]!.GetValue(); + var s2 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Salt"]!.GetValue(); + Assert.NotEqual(s1, s2); + } + + [Fact] + public void IsEncrypted_restituisce_false_per_json_non_cifrato() + { + Assert.False(EncryptedFile.IsEncrypted("{\"Version\": 1}")); + } + + [Fact] + public void IsEncrypted_restituisce_false_per_testo_non_json() + { + Assert.False(EncryptedFile.IsEncrypted("non è json")); + } + + // ---- WalletDocument JSON ---- + [Fact] public void Il_documento_wallet_fa_roundtrip_json() { @@ -58,15 +95,36 @@ public class StorageTests [Fact] public void Una_versione_futura_del_file_viene_rifiutata() { - var doc = SampleDoc(); - var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99"); + var json = SampleDoc().ToJson().Replace("\"Version\": 1", "\"Version\": 99"); Assert.Throws(() => WalletDocument.FromJson(json)); } + [Fact] + public void Il_documento_senza_mnemonica_e_watch_only() + { + var doc = SampleDoc(); + doc.Mnemonic = null; + Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly); + } + + [Fact] + public void Json_corrotto_lancia_eccezione() + { + Assert.ThrowsAny(() => WalletDocument.FromJson("{non è json valido}")); + } + + [Fact] + public void Json_con_campi_mancanti_lancia_eccezione() + { + Assert.ThrowsAny(() => WalletDocument.FromJson("{}")); + } + + // ---- WalletStore ---- + [Fact] public void Il_wallet_store_salva_e_riapre_con_e_senza_password() { - var path = Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json"); + var path = TempPath(); try { WalletStore.Save(SampleDoc(), path); @@ -86,17 +144,59 @@ public class StorageTests } [Fact] - public void Il_documento_senza_mnemonica_e_watch_only() + public void Scrittura_atomica_non_lascia_file_tmp() { - var doc = SampleDoc(); - doc.Mnemonic = null; - Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly); + var path = TempPath(); + try + { + WalletStore.Save(SampleDoc(), path); + Assert.True(File.Exists(path)); + Assert.False(File.Exists(path + ".tmp")); + } + finally + { + File.Delete(path); + } } + [Fact] + public void Load_da_path_inesistente_lancia_eccezione() + { + var path = Path.Combine(Path.GetTempPath(), $"plm-noexist-{Guid.NewGuid()}.wallet.json"); + Assert.Throws(() => WalletStore.Load(path)); + } + + [Fact] + public void Exists_restituisce_false_per_path_inesistente() + { + var path = Path.Combine(Path.GetTempPath(), $"plm-noexist-{Guid.NewGuid()}.wallet.json"); + Assert.False(WalletStore.Exists(path)); + } + + [Fact] + public void Due_save_successivi_producono_nonce_diversi() + { + var path = TempPath(); + try + { + WalletStore.Save(SampleDoc(), path, "password"); + var n1 = JsonNode.Parse(File.ReadAllText(path))!["Nonce"]!.GetValue(); + WalletStore.Save(SampleDoc(), path, "password"); + var n2 = JsonNode.Parse(File.ReadAllText(path))!["Nonce"]!.GetValue(); + Assert.NotEqual(n1, n2); + } + finally + { + File.Delete(path); + } + } + + // ---- WalletLock ---- + [Fact] public void WalletLock_acquisisce_e_rilascia() { - var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); + var path = TempPath(); try { using var lock1 = WalletLock.TryAcquire(path); @@ -104,7 +204,6 @@ public class StorageTests } finally { - File.Delete(path); File.Delete(path + ".lock"); } } @@ -112,18 +211,15 @@ public class StorageTests [Fact] public void WalletLock_seconda_istanza_restituisce_null() { - var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); + var path = TempPath(); try { using var lock1 = WalletLock.TryAcquire(path); Assert.NotNull(lock1); - - var lock2 = WalletLock.TryAcquire(path); - Assert.Null(lock2); + Assert.Null(WalletLock.TryAcquire(path)); } finally { - File.Delete(path); File.Delete(path + ".lock"); } } @@ -131,7 +227,7 @@ public class StorageTests [Fact] public void WalletLock_riacquisibile_dopo_rilascio() { - var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); + var path = TempPath(); try { var lock1 = WalletLock.TryAcquire(path); @@ -143,8 +239,37 @@ public class StorageTests } finally { - File.Delete(path); File.Delete(path + ".lock"); } } + + [Fact] + public void WalletLock_dispose_rimuove_il_file_lock() + { + var path = TempPath(); + var lockPath = path + ".lock"; + var lock1 = WalletLock.TryAcquire(path); + Assert.NotNull(lock1); + lock1!.Dispose(); + Assert.False(File.Exists(lockPath)); + } + + [Fact] + public void WalletLock_file_lock_preesistente_ma_non_bloccato_viene_acquisito() + { + // Un .lock rimasto da un crash precedente (file esiste ma nessuno lo tiene) + // non deve bloccare l'apertura del wallet. + var path = TempPath(); + var lockPath = path + ".lock"; + try + { + File.WriteAllText(lockPath, "stale"); + using var lock1 = WalletLock.TryAcquire(path); + Assert.NotNull(lock1); + } + finally + { + File.Delete(lockPath); + } + } } diff --git a/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs b/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs index b7b84f7..f2176db 100644 --- a/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/CoinAmountTests.cs @@ -1,39 +1,137 @@ +using System; using PalladiumWallet.Core.Wallet; namespace PalladiumWallet.Tests.Wallet; public class CoinAmountTests { - // --- importi validi --- + // ---- importi validi: tutte le unità ---- [Theory] - [InlineData("1", "sat", 1)] - [InlineData("0", "sat", 0)] - [InlineData("1.5", "PLM", 150_000_000)] - [InlineData("0.00000001", "PLM", 1)] - [InlineData("1", "PLM", 100_000_000)] - [InlineData("1.00000", "mPLM", 100_000)] - [InlineData("1.00", "µPLM", 100)] + [InlineData("1", "sat", 1)] + [InlineData("0", "sat", 0)] + [InlineData("0", "PLM", 0)] + [InlineData("0", "mPLM", 0)] + [InlineData("0", "µPLM", 0)] + [InlineData("1.5", "PLM", 150_000_000)] + [InlineData("0.00000001", "PLM", 1)] + [InlineData("1", "PLM", 100_000_000)] + [InlineData("1.00000", "mPLM", 100_000)] + [InlineData("0.00001", "mPLM", 1)] + [InlineData("1.00", "µPLM", 100)] + [InlineData("0.01", "µPLM", 1)] + [InlineData("100000000", "sat", 100_000_000)] public void Importo_valido_viene_accettato(string input, string unit, long expectedSats) { Assert.True(CoinAmount.TryParseIn(input, unit, out var sats)); Assert.Equal(expectedSats, sats); } - // --- importi con troppi decimali: devono essere rifiutati --- + // ---- decimale con virgola (locale italiano) ---- [Theory] - [InlineData("1.9", "sat")] // sat non è divisibile - [InlineData("0.001", "µPLM")] // 0.001 × 100 = 0.1 sat - [InlineData("1.500000001", "PLM")] // 9 decimali, uno di troppo - [InlineData("0.000000001", "PLM")] // sotto al satoshi - [InlineData("1.1", "sat")] + [InlineData("1,5", "PLM", 150_000_000)] + [InlineData("1,00000", "mPLM", 100_000)] + [InlineData("0,00000001", "PLM", 1)] + public void Virgola_italiana_viene_accettata(string input, string unit, long expectedSats) + { + Assert.True(CoinAmount.TryParseIn(input, unit, out var sats)); + Assert.Equal(expectedSats, sats); + } + + // ---- spazi iniziali/finali ---- + + [Theory] + [InlineData(" 1 ", "sat", 1)] + [InlineData(" 1.5 ", "PLM", 150_000_000)] + public void Spazi_iniziali_e_finali_vengono_ignorati(string input, string unit, long expectedSats) + { + Assert.True(CoinAmount.TryParseIn(input, unit, out var sats)); + Assert.Equal(expectedSats, sats); + } + + // ---- importi con troppi decimali ---- + + [Theory] + [InlineData("1.9", "sat")] + [InlineData("1.1", "sat")] + [InlineData("0.001", "µPLM")] + [InlineData("1.500000001", "PLM")] + [InlineData("0.000000001", "PLM")] + [InlineData("0.000001", "mPLM")] public void Importo_con_troppi_decimali_viene_rifiutato(string input, string unit) { Assert.False(CoinAmount.TryParseIn(input, unit, out _)); } - // --- TryParseCoins --- + // ---- negativi ---- + + [Theory] + [InlineData("-1", "sat")] + [InlineData("-0.1", "PLM")] + [InlineData("-1", "PLM")] + public void Importo_negativo_viene_rifiutato(string input, string unit) + { + Assert.False(CoinAmount.TryParseIn(input, unit, out _)); + } + + // ---- stringa vuota e non numerica ---- + + [Theory] + [InlineData("")] + [InlineData("abc")] + [InlineData("1e5")] + [InlineData("∞")] + public void Stringa_non_numerica_viene_rifiutata(string input) + { + Assert.False(CoinAmount.TryParseIn(input, "PLM", out _)); + } + + // ---- overflow ---- + + [Fact] + public void Overflow_viene_rifiutato() + { + // 92233720368.54775807 PLM supera long.MaxValue in satoshi + Assert.False(CoinAmount.TryParseIn("99999999999", "PLM", out _)); + } + + // ---- unità sconosciuta lancia ArgumentException ---- + + [Theory] + [InlineData("banana")] + [InlineData("BTC")] + [InlineData("")] + [InlineData("plm")] // case-sensitive + public void Unita_sconosciuta_lancia_ArgumentException(string unit) + { + Assert.Throws(() => CoinAmount.TryParseIn("1", unit, out _)); + } + + [Fact] + public void FormatIn_unita_sconosciuta_lancia_ArgumentException() + { + Assert.Throws(() => CoinAmount.FormatIn(1, "banana")); + } + + // ---- roundtrip FormatIn → TryParseIn ---- + + [Theory] + [InlineData(0, "PLM")] + [InlineData(1, "sat")] + [InlineData(1, "PLM")] + [InlineData(150_000_000, "PLM")] + [InlineData(100_000, "mPLM")] + [InlineData(100, "µPLM")] + [InlineData(99_999_999, "PLM")] + public void Roundtrip_format_parse_conserva_i_satoshi(long sats, string unit) + { + var formatted = CoinAmount.FormatIn(sats, unit, withLabel: false); + Assert.True(CoinAmount.TryParseIn(formatted, unit, out var parsed)); + Assert.Equal(sats, parsed); + } + + // ---- TryParseCoins ---- [Fact] public void TryParseCoins_accetta_precisione_massima() @@ -42,6 +140,13 @@ public class CoinAmountTests Assert.Equal(1L, sats); } + [Fact] + public void TryParseCoins_virgola_italiana() + { + Assert.True(CoinAmount.TryParseCoins("1,5", out var sats)); + Assert.Equal(150_000_000L, sats); + } + [Fact] public void TryParseCoins_rifiuta_sotto_al_satoshi() { @@ -53,4 +158,21 @@ public class CoinAmountTests { Assert.False(CoinAmount.TryParseCoins("-1", out _)); } + + [Fact] + public void TryParseCoins_rifiuta_overflow() + { + Assert.False(CoinAmount.TryParseCoins("99999999999", out _)); + } + + // ---- Format (PLM con 8 decimali) ---- + + [Theory] + [InlineData(100_000_000, "1.00000000")] + [InlineData(1, "0.00000001")] + [InlineData(0, "0.00000000")] + public void Format_produce_stringa_con_8_decimali(long sats, string expected) + { + Assert.Equal(expected, CoinAmount.Format(sats)); + } } diff --git a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs new file mode 100644 index 0000000..a51c3d1 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs @@ -0,0 +1,188 @@ +using System; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.Tests.Wallet; + +public class WalletLoaderTests +{ + private const string ValidMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + private const string ValidMnemonic24 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + // ---- NewFromMnemonic ---- + + [Fact] + public void NewFromMnemonic_crea_documento_con_rete_e_tipo_corretti() + { + var profile = ChainProfiles.Mainnet; + var (doc, account) = WalletLoader.NewFromMnemonic( + ValidMnemonic, passphrase: null, ScriptKind.NativeSegwit, profile); + + Assert.Equal("mainnet", doc.Network); + Assert.Equal("NativeSegwit", doc.ScriptKind); + Assert.Equal(ValidMnemonic, doc.Mnemonic); + Assert.Null(doc.Passphrase); + Assert.NotEmpty(doc.AccountXpub); + Assert.NotEmpty(doc.MasterFingerprint); + Assert.False(doc.IsWatchOnly); + } + + [Fact] + public void NewFromMnemonic_stessa_mnemonica_produce_stesso_xpub() + { + var profile = ChainProfiles.Mainnet; + var (doc1, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile); + var (doc2, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile); + + Assert.Equal(doc1.AccountXpub, doc2.AccountXpub); + } + + [Fact] + public void NewFromMnemonic_passphrase_diversa_produce_xpub_diverso() + { + var profile = ChainProfiles.Mainnet; + var (doc1, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile); + var (doc2, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, "passphrase", ScriptKind.NativeSegwit, profile); + + Assert.NotEqual(doc1.AccountXpub, doc2.AccountXpub); + } + + [Fact] + public void NewFromMnemonic_mnemonica_invalida_lancia_eccezione() + { + Assert.Throws(() => + WalletLoader.NewFromMnemonic("parole non valide foo bar", null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)); + } + + [Fact] + public void NewFromMnemonic_mnemonica_24_parole_funziona() + { + var (doc, _) = WalletLoader.NewFromMnemonic( + ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + Assert.Equal("mainnet", doc.Network); + Assert.NotEmpty(doc.AccountXpub); + } + + [Fact] + public void NewFromMnemonic_tipi_script_producono_xpub_diversi() + { + var profile = ChainProfiles.Mainnet; + var (docNative, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile); + var (docWrapped, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.WrappedSegwit, profile); + var (docLegacy, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.Legacy, profile); + + Assert.NotEqual(docNative.AccountXpub, docWrapped.AccountXpub); + Assert.NotEqual(docNative.AccountXpub, docLegacy.AccountXpub); + } + + [Fact] + public void NewFromMnemonic_reti_diverse_producono_xpub_diverse() + { + var (docMain, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + var (docTest, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Testnet); + + Assert.NotEqual(docMain.AccountXpub, docTest.AccountXpub); + } + + // ---- ToAccount ---- + + [Fact] + public void ToAccount_da_mnemonica_deriva_gli_stessi_indirizzi_ad_ogni_caricamento() + { + var (doc, _) = WalletLoader.NewFromMnemonic( + ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + var account1 = WalletLoader.ToAccount(doc); + var account2 = WalletLoader.ToAccount(doc); + + Assert.Equal( + account1.GetReceiveAddress(0).ToString(), + account2.GetReceiveAddress(0).ToString()); + } + + [Fact] + public void ToAccount_da_mnemonica_non_e_watch_only() + { + var (doc, _) = WalletLoader.NewFromMnemonic( + ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + var account = WalletLoader.ToAccount(doc); + Assert.False(account.IsWatchOnly); + } + + [Fact] + public void ToAccount_da_xpub_e_watch_only_e_produce_stessi_indirizzi() + { + var (doc, accountSeed) = WalletLoader.NewFromMnemonic( + ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + + // Crea documento watch-only rimuovendo la mnemonica + var docWo = new WalletDocument + { + Network = doc.Network, + ScriptKind = doc.ScriptKind, + AccountPath = doc.AccountPath, + AccountXpub = doc.AccountXpub, + }; + + var accountWo = WalletLoader.ToAccount(docWo); + + Assert.True(accountWo.IsWatchOnly); + Assert.Equal( + accountSeed.GetReceiveAddress(0).ToString(), + accountWo.GetReceiveAddress(0).ToString()); + Assert.Equal( + accountSeed.GetReceiveAddress(9).ToString(), + accountWo.GetReceiveAddress(9).ToString()); + } + + [Fact] + public void ToAccount_watch_only_non_espone_chiavi_private() + { + var (doc, _) = WalletLoader.NewFromMnemonic( + ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + var docWo = new WalletDocument + { + Network = doc.Network, + ScriptKind = doc.ScriptKind, + AccountPath = doc.AccountPath, + AccountXpub = doc.AccountXpub, + }; + var account = WalletLoader.ToAccount(docWo); + Assert.Throws(() => account.GetExtPrivateKey(false, 0)); + } + + [Fact] + public void ToAccount_rete_sconosciuta_lancia_eccezione() + { + var (doc, _) = WalletLoader.NewFromMnemonic( + ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + var docBad = new WalletDocument + { + Network = "fantanet", + ScriptKind = doc.ScriptKind, + AccountPath = doc.AccountPath, + AccountXpub = doc.AccountXpub, + Mnemonic = doc.Mnemonic, + }; + Assert.ThrowsAny(() => WalletLoader.ToAccount(docBad)); + } + + // ---- ProfileOf ---- + + [Theory] + [InlineData("mainnet", NetKind.Mainnet)] + [InlineData("testnet", NetKind.Testnet)] + [InlineData("regtest", NetKind.Regtest)] + [InlineData("Mainnet", NetKind.Mainnet)] + public void ProfileOf_riconosce_le_reti_note(string network, NetKind expected) + { + var doc = new WalletDocument { Network = network, ScriptKind = "NativeSegwit", + AccountPath = "84'/0'/0'", AccountXpub = "xpub" }; + Assert.Equal(expected, WalletLoader.ProfileOf(doc).Kind); + } +}