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).
This commit is contained in:
2026-06-13 21:59:49 +02:00
parent a8d48bedad
commit ee0b73dd52
5 changed files with 624 additions and 38 deletions
@@ -69,6 +69,42 @@ public class ChainProfileTests
Assert.StartsWith("tpub", EncodeWithHeader(headers.Public)); Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
} }
[Fact]
public void Rete_sconosciuta_lancia_ArgumentException()
{
Assert.ThrowsAny<ArgumentException>(() => 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: // Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check:
// il prefisso testuale risultante dipende solo dall'header. // il prefisso testuale risultante dipende solo dall'header.
private static string EncodeWithHeader(uint header) private static string EncodeWithHeader(uint header)
+120 -5
View File
@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NBitcoin; using NBitcoin;
using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Spv;
@@ -17,12 +20,26 @@ public class ScripthashTests
var script = Script.FromHex(scriptHex); var script = Script.FromHex(scriptHex);
Assert.Equal(expected, Scripthash.FromScript(script)); 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 public class MerkleProofTests
{ {
// Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna // Blocco Bitcoin 100000: 4 transazioni (pari), merkle root nota.
// per la convenzione di hashing/ordinamento.
private static readonly uint256[] Block100000Txids = private static readonly uint256[] Block100000Txids =
[ [
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"), uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
@@ -34,6 +51,8 @@ public class MerkleProofTests
private static readonly uint256 Block100000Root = private static readonly uint256 Block100000Root =
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766"); uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
// ---- numero pari di transazioni (4 tx) ----
[Fact] [Fact]
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco() 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)); 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] [Fact]
public void Una_prova_per_la_posizione_sbagliata_fallisce() 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)); 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<ArgumentException>(() => MerkleProof.ComputeRootFromLeaves([]));
}
/// <summary>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary> /// <summary>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary>
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position) private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
{ {
@@ -89,7 +195,6 @@ public class MerkleProofTests
public class BlockHeaderInfoTests public class BlockHeaderInfoTests
{ {
// Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3).
private const string GenesisHeaderHex = private const string GenesisHeaderHex =
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c"; "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
@@ -118,10 +223,20 @@ public class BlockHeaderInfoTests
[Fact] [Fact]
public void Con_skip_pow_la_validazione_non_controlla_il_target() 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); var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.True(ChainProfiles.Mainnet.SkipPowValidation); Assert.True(ChainProfiles.Mainnet.SkipPowValidation);
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet)); Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
} }
[Fact]
public void Header_troncato_lancia_eccezione()
{
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("0100000000"));
}
[Fact]
public void Header_hex_non_valido_lancia_eccezione()
{
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ"));
}
} }
@@ -1,3 +1,6 @@
using System;
using System.IO;
using System.Text.Json.Nodes;
using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Tests.Storage; namespace PalladiumWallet.Tests.Storage;
@@ -14,6 +17,11 @@ public class StorageTests
Labels = { ["txid123"] = "caffè" }, Labels = { ["txid123"] = "caffè" },
}; };
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
// ---- cifratura AES-GCM ----
[Fact] [Fact]
public void La_cifratura_fa_roundtrip_con_la_password_giusta() public void La_cifratura_fa_roundtrip_con_la_password_giusta()
{ {
@@ -34,14 +42,43 @@ public class StorageTests
public void Un_file_manomesso_viene_rifiutato() public void Un_file_manomesso_viene_rifiutato()
{ {
var cipher = EncryptedFile.Encrypt("contenuto", "pass"); var cipher = EncryptedFile.Encrypt("contenuto", "pass");
// Corrompe un byte del ciphertext mantenendo base64 e JSON validi. var node = JsonNode.Parse(cipher)!;
var node = System.Text.Json.Nodes.JsonNode.Parse(cipher)!;
var data = Convert.FromBase64String(node["Data"]!.GetValue<string>()); var data = Convert.FromBase64String(node["Data"]!.GetValue<string>());
data[0] ^= 0xff; data[0] ^= 0xff;
node["Data"] = Convert.ToBase64String(data); node["Data"] = Convert.ToBase64String(data);
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass")); Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
} }
[Fact]
public void Ogni_encrypt_produce_nonce_diverso()
{
var c1 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Nonce"]!.GetValue<string>();
var c2 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Nonce"]!.GetValue<string>();
Assert.NotEqual(c1, c2);
}
[Fact]
public void Ogni_encrypt_produce_salt_diverso()
{
var s1 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Salt"]!.GetValue<string>();
var s2 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Salt"]!.GetValue<string>();
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] [Fact]
public void Il_documento_wallet_fa_roundtrip_json() public void Il_documento_wallet_fa_roundtrip_json()
{ {
@@ -58,15 +95,36 @@ public class StorageTests
[Fact] [Fact]
public void Una_versione_futura_del_file_viene_rifiutata() public void Una_versione_futura_del_file_viene_rifiutata()
{ {
var doc = SampleDoc(); var json = SampleDoc().ToJson().Replace("\"Version\": 1", "\"Version\": 99");
var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99");
Assert.Throws<InvalidDataException>(() => WalletDocument.FromJson(json)); Assert.Throws<InvalidDataException>(() => 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<Exception>(() => WalletDocument.FromJson("{non è json valido}"));
}
[Fact]
public void Json_con_campi_mancanti_lancia_eccezione()
{
Assert.ThrowsAny<Exception>(() => WalletDocument.FromJson("{}"));
}
// ---- WalletStore ----
[Fact] [Fact]
public void Il_wallet_store_salva_e_riapre_con_e_senza_password() 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 try
{ {
WalletStore.Save(SampleDoc(), path); WalletStore.Save(SampleDoc(), path);
@@ -86,17 +144,59 @@ public class StorageTests
} }
[Fact] [Fact]
public void Il_documento_senza_mnemonica_e_watch_only() public void Scrittura_atomica_non_lascia_file_tmp()
{ {
var doc = SampleDoc(); var path = TempPath();
doc.Mnemonic = null; try
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly); {
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<FileNotFoundException>(() => 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<string>();
WalletStore.Save(SampleDoc(), path, "password");
var n2 = JsonNode.Parse(File.ReadAllText(path))!["Nonce"]!.GetValue<string>();
Assert.NotEqual(n1, n2);
}
finally
{
File.Delete(path);
}
}
// ---- WalletLock ----
[Fact] [Fact]
public void WalletLock_acquisisce_e_rilascia() public void WalletLock_acquisisce_e_rilascia()
{ {
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); var path = TempPath();
try try
{ {
using var lock1 = WalletLock.TryAcquire(path); using var lock1 = WalletLock.TryAcquire(path);
@@ -104,7 +204,6 @@ public class StorageTests
} }
finally finally
{ {
File.Delete(path);
File.Delete(path + ".lock"); File.Delete(path + ".lock");
} }
} }
@@ -112,18 +211,15 @@ public class StorageTests
[Fact] [Fact]
public void WalletLock_seconda_istanza_restituisce_null() public void WalletLock_seconda_istanza_restituisce_null()
{ {
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); var path = TempPath();
try try
{ {
using var lock1 = WalletLock.TryAcquire(path); using var lock1 = WalletLock.TryAcquire(path);
Assert.NotNull(lock1); Assert.NotNull(lock1);
Assert.Null(WalletLock.TryAcquire(path));
var lock2 = WalletLock.TryAcquire(path);
Assert.Null(lock2);
} }
finally finally
{ {
File.Delete(path);
File.Delete(path + ".lock"); File.Delete(path + ".lock");
} }
} }
@@ -131,7 +227,7 @@ public class StorageTests
[Fact] [Fact]
public void WalletLock_riacquisibile_dopo_rilascio() public void WalletLock_riacquisibile_dopo_rilascio()
{ {
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json"); var path = TempPath();
try try
{ {
var lock1 = WalletLock.TryAcquire(path); var lock1 = WalletLock.TryAcquire(path);
@@ -143,8 +239,37 @@ public class StorageTests
} }
finally finally
{ {
File.Delete(path);
File.Delete(path + ".lock"); 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);
}
}
} }
@@ -1,39 +1,137 @@
using System;
using PalladiumWallet.Core.Wallet; using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet; namespace PalladiumWallet.Tests.Wallet;
public class CoinAmountTests public class CoinAmountTests
{ {
// --- importi validi --- // ---- importi validi: tutte le unità ----
[Theory] [Theory]
[InlineData("1", "sat", 1)] [InlineData("1", "sat", 1)]
[InlineData("0", "sat", 0)] [InlineData("0", "sat", 0)]
[InlineData("0", "PLM", 0)]
[InlineData("0", "mPLM", 0)]
[InlineData("0", "µPLM", 0)]
[InlineData("1.5", "PLM", 150_000_000)] [InlineData("1.5", "PLM", 150_000_000)]
[InlineData("0.00000001", "PLM", 1)] [InlineData("0.00000001", "PLM", 1)]
[InlineData("1", "PLM", 100_000_000)] [InlineData("1", "PLM", 100_000_000)]
[InlineData("1.00000", "mPLM", 100_000)] [InlineData("1.00000", "mPLM", 100_000)]
[InlineData("0.00001", "mPLM", 1)]
[InlineData("1.00", "µPLM", 100)] [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) public void Importo_valido_viene_accettato(string input, string unit, long expectedSats)
{ {
Assert.True(CoinAmount.TryParseIn(input, unit, out var sats)); Assert.True(CoinAmount.TryParseIn(input, unit, out var sats));
Assert.Equal(expectedSats, sats); Assert.Equal(expectedSats, sats);
} }
// --- importi con troppi decimali: devono essere rifiutati --- // ---- decimale con virgola (locale italiano) ----
[Theory] [Theory]
[InlineData("1.9", "sat")] // sat non è divisibile [InlineData("1,5", "PLM", 150_000_000)]
[InlineData("0.001", "µPLM")] // 0.001 × 100 = 0.1 sat [InlineData("1,00000", "mPLM", 100_000)]
[InlineData("1.500000001", "PLM")] // 9 decimali, uno di troppo [InlineData("0,00000001", "PLM", 1)]
[InlineData("0.000000001", "PLM")] // sotto al satoshi 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("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) public void Importo_con_troppi_decimali_viene_rifiutato(string input, string unit)
{ {
Assert.False(CoinAmount.TryParseIn(input, unit, out _)); 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<ArgumentException>(() => CoinAmount.TryParseIn("1", unit, out _));
}
[Fact]
public void FormatIn_unita_sconosciuta_lancia_ArgumentException()
{
Assert.Throws<ArgumentException>(() => 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] [Fact]
public void TryParseCoins_accetta_precisione_massima() public void TryParseCoins_accetta_precisione_massima()
@@ -42,6 +140,13 @@ public class CoinAmountTests
Assert.Equal(1L, sats); 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] [Fact]
public void TryParseCoins_rifiuta_sotto_al_satoshi() public void TryParseCoins_rifiuta_sotto_al_satoshi()
{ {
@@ -53,4 +158,21 @@ public class CoinAmountTests
{ {
Assert.False(CoinAmount.TryParseCoins("-1", out _)); 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));
}
} }
@@ -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<InvalidDataException>(() =>
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<InvalidOperationException>(() => 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<Exception>(() => 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);
}
}