diff --git a/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs b/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs
new file mode 100644
index 0000000..c4e11f4
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Chain/ChainProfileTests.cs
@@ -0,0 +1,83 @@
+using NBitcoin;
+using NBitcoin.DataEncoders;
+using PalladiumWallet.Core.Chain;
+
+namespace PalladiumWallet.Tests.Chain;
+
+public class ChainProfileTests
+{
+ [Fact]
+ public void Mainnet_ha_le_costanti_del_blueprint()
+ {
+ var p = ChainProfiles.Mainnet;
+
+ Assert.Equal("PLM", p.CoinUnit);
+ Assert.Equal(0x80, p.WifPrefix);
+ Assert.Equal(55, p.AddrP2pkh);
+ Assert.Equal(5, p.AddrP2sh);
+ Assert.Equal("plm", p.SegwitHrp);
+ Assert.Equal("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", p.GenesisHash);
+ Assert.Equal(50001, p.DefaultTcpPort);
+ Assert.Equal(50002, p.DefaultSslPort);
+ Assert.Equal(746, p.Bip44CoinType);
+ Assert.Equal("palladium", p.UriScheme);
+ Assert.True(p.SkipPowValidation); // obbligatorio: la catena usa LWMA (§3)
+ Assert.Equal(120, p.BlockTimeSeconds);
+ }
+
+ [Fact]
+ public void Testnet_e_regtest_hanno_i_valori_del_blueprint()
+ {
+ var t = ChainProfiles.Testnet;
+ Assert.Equal(0xff, t.WifPrefix);
+ Assert.Equal(127, t.AddrP2pkh);
+ Assert.Equal(115, t.AddrP2sh);
+ Assert.Equal("tplm", t.SegwitHrp);
+ Assert.Equal(1, t.Bip44CoinType);
+
+ var r = ChainProfiles.Regtest;
+ Assert.Equal("rplm", r.SegwitHrp);
+ }
+
+ [Fact]
+ public void Selettore_di_rete_restituisce_il_profilo_giusto()
+ {
+ Assert.Same(ChainProfiles.Mainnet, ChainProfiles.For(NetKind.Mainnet));
+ Assert.Same(ChainProfiles.Testnet, ChainProfiles.For(NetKind.Testnet));
+ Assert.Same(ChainProfiles.Regtest, ChainProfiles.For(NetKind.Regtest));
+ }
+
+ [Theory]
+ [InlineData(ScriptKind.Legacy, "xprv", "xpub")]
+ [InlineData(ScriptKind.WrappedSegwit, "yprv", "ypub")]
+ [InlineData(ScriptKind.WrappedSegwitMultisig, "Yprv", "Ypub")]
+ [InlineData(ScriptKind.NativeSegwit, "zprv", "zpub")]
+ [InlineData(ScriptKind.NativeSegwitMultisig, "Zprv", "Zpub")]
+ public void Header_estesi_mainnet_producono_i_prefissi_slip132_attesi(
+ ScriptKind kind, string privPrefix, string pubPrefix)
+ {
+ var headers = ChainProfiles.Mainnet.ExtKeyHeaders[kind];
+ Assert.StartsWith(privPrefix, EncodeWithHeader(headers.Private));
+ Assert.StartsWith(pubPrefix, EncodeWithHeader(headers.Public));
+ }
+
+ [Fact]
+ public void Header_estesi_testnet_producono_tprv_tpub()
+ {
+ var headers = ChainProfiles.Testnet.ExtKeyHeaders[ScriptKind.Legacy];
+ Assert.StartsWith("tprv", EncodeWithHeader(headers.Private));
+ Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
+ }
+
+ // 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)
+ {
+ var data = new byte[78];
+ data[0] = (byte)(header >> 24);
+ data[1] = (byte)(header >> 16);
+ data[2] = (byte)(header >> 8);
+ data[3] = (byte)header;
+ return Encoders.Base58Check.EncodeData(data);
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Chain/PalladiumNetworksTests.cs b/tests/PalladiumWallet.Tests/Chain/PalladiumNetworksTests.cs
new file mode 100644
index 0000000..80a4494
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Chain/PalladiumNetworksTests.cs
@@ -0,0 +1,78 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+
+namespace PalladiumWallet.Tests.Chain;
+
+public class PalladiumNetworksTests
+{
+ // Chiave privata fissa per test deterministici (solo test, mai usarla davvero).
+ private static Key TestKey => new(Convert.FromHexString(
+ "0000000000000000000000000000000000000000000000000000000000000001"));
+
+ [Theory]
+ [InlineData(NetKind.Mainnet)]
+ [InlineData(NetKind.Testnet)]
+ [InlineData(NetKind.Regtest)]
+ public void La_genesi_della_rete_corrisponde_al_profilo(NetKind kind)
+ {
+ var network = PalladiumNetworks.For(kind);
+ var profile = ChainProfiles.For(kind);
+
+ Assert.Equal(profile.GenesisHash, network.GetGenesis().GetHash().ToString());
+ }
+
+ [Fact]
+ public void Indirizzo_p2pkh_mainnet_inizia_con_P()
+ {
+ var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Legacy, PalladiumNetworks.Mainnet);
+ Assert.StartsWith("P", addr.ToString());
+ }
+
+ [Fact]
+ public void Indirizzo_native_segwit_mainnet_inizia_con_plm1()
+ {
+ var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, PalladiumNetworks.Mainnet);
+ Assert.StartsWith("plm1", addr.ToString());
+ }
+
+ [Fact]
+ public void Indirizzo_segwit_wrapped_mainnet_inizia_con_3()
+ {
+ var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, PalladiumNetworks.Mainnet);
+ Assert.StartsWith("3", addr.ToString());
+ }
+
+ [Theory]
+ [InlineData(NetKind.Testnet, "tplm1")]
+ [InlineData(NetKind.Regtest, "rplm1")]
+ public void Indirizzi_segwit_test_e_regtest_usano_l_hrp_giusto(NetKind kind, string prefix)
+ {
+ var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, PalladiumNetworks.For(kind));
+ Assert.StartsWith(prefix, addr.ToString());
+ }
+
+ [Fact]
+ public void Wif_mainnet_fa_roundtrip_con_il_prefisso_del_profilo()
+ {
+ var wif = TestKey.GetWif(PalladiumNetworks.Mainnet);
+ var decoded = Key.Parse(wif.ToString(), PalladiumNetworks.Mainnet);
+
+ Assert.Equal(TestKey.ToHex(), decoded.ToHex());
+ }
+
+ [Fact]
+ public void Chiavi_estese_mainnet_serializzano_come_xprv_xpub()
+ {
+ var ext = new ExtKey();
+ Assert.StartsWith("xprv", ext.ToString(PalladiumNetworks.Mainnet));
+ Assert.StartsWith("xpub", ext.Neuter().ToString(PalladiumNetworks.Mainnet));
+ }
+
+ [Fact]
+ public void Le_tre_reti_sono_distinte_e_riutilizzate()
+ {
+ Assert.NotSame(PalladiumNetworks.Mainnet, PalladiumNetworks.Testnet);
+ Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
+ Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs b/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs
new file mode 100644
index 0000000..3bf9613
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs
@@ -0,0 +1,96 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+
+namespace PalladiumWallet.Tests.Crypto;
+
+public class AddressDerivationTests
+{
+ private static byte[] AbandonAboutSeed()
+ {
+ Assert.True(Bip39.TryParse(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ out var mnemonic));
+ return Bip39.ToSeed(mnemonic!);
+ }
+
+ [Fact]
+ public void Il_vettore_bip44_produce_lo_stesso_hash_su_bitcoin_e_plm()
+ {
+ // Indirizzo noto di abandon-about su m/44'/0'/0'/0/0 (riferimento pubblico).
+ var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
+ ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
+ var pubKey = account.GetPublicKey(isChange: false, 0);
+
+ var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
+ Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
+
+ var plmAddr = account.GetReceiveAddress(0);
+ Assert.StartsWith("P", plmAddr.ToString());
+ // Stesso hash160 sotto i due prefissi: la derivazione è identica,
+ // cambia solo la veste di rete.
+ Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
+ }
+
+ [Fact]
+ public void Il_vettore_bip49_produce_lo_stesso_script_hash_su_bitcoin_e_plm()
+ {
+ var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
+ ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
+ var pubKey = account.GetPublicKey(isChange: false, 0);
+
+ var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
+ Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
+
+ var plmAddr = account.GetReceiveAddress(0);
+ Assert.StartsWith("3", plmAddr.ToString());
+ Assert.Equal(((BitcoinScriptAddress)bitcoinAddr).Hash, ((BitcoinScriptAddress)plmAddr).Hash);
+ }
+
+ [Theory]
+ [InlineData(ScriptKind.Legacy, NetKind.Mainnet)]
+ [InlineData(ScriptKind.WrappedSegwit, NetKind.Mainnet)]
+ [InlineData(ScriptKind.NativeSegwit, NetKind.Mainnet)]
+ [InlineData(ScriptKind.Legacy, NetKind.Testnet)]
+ [InlineData(ScriptKind.WrappedSegwit, NetKind.Testnet)]
+ [InlineData(ScriptKind.NativeSegwit, NetKind.Testnet)]
+ [InlineData(ScriptKind.NativeSegwit, NetKind.Regtest)]
+ public void Ogni_indirizzo_derivato_fa_roundtrip_sulla_propria_rete(ScriptKind kind, NetKind net)
+ {
+ var profile = ChainProfiles.For(net);
+ var account = HdAccount.FromSeed(AbandonAboutSeed(), kind, profile);
+
+ var addr = account.GetReceiveAddress(0);
+ var parsed = BitcoinAddress.Create(addr.ToString(), PalladiumNetworks.For(net));
+
+ Assert.Equal(addr.ScriptPubKey, parsed.ScriptPubKey);
+ }
+
+ [Theory]
+ [InlineData(NetKind.Mainnet, "tplm1")]
+ [InlineData(NetKind.Mainnet, "rplm1")]
+ public void Gli_indirizzi_segwit_mainnet_non_hanno_prefissi_di_altre_reti(NetKind net, string wrongPrefix)
+ {
+ var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.NativeSegwit, ChainProfiles.For(net));
+ Assert.DoesNotContain(wrongPrefix, account.GetReceiveAddress(0).ToString());
+ }
+
+ [Fact]
+ public void Il_path_di_account_usa_il_coin_type_del_profilo()
+ {
+ Assert.Equal("84'/746'/0'",
+ DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToString());
+ Assert.Equal("84'/1'/0'",
+ DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Testnet).ToString());
+ Assert.Equal("44'/746'/2'",
+ DerivationPaths.AccountPath(ScriptKind.Legacy, ChainProfiles.Mainnet, account: 2).ToString());
+ }
+
+ [Fact]
+ public void Receiving_e_change_derivano_indirizzi_diversi()
+ {
+ var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+ Assert.NotEqual(account.GetReceiveAddress(0).ToString(), account.GetChangeAddress(0).ToString());
+ Assert.NotEqual(account.GetReceiveAddress(0).ToString(), account.GetReceiveAddress(1).ToString());
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Crypto/Bip32Tests.cs b/tests/PalladiumWallet.Tests/Crypto/Bip32Tests.cs
new file mode 100644
index 0000000..519ddb5
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Crypto/Bip32Tests.cs
@@ -0,0 +1,68 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+
+namespace PalladiumWallet.Tests.Crypto;
+
+///
+/// Vettore di test 1 della specifica BIP32 (seed 000102...0e0f). Gli header
+/// Legacy della mainnet PLM coincidono con quelli Bitcoin, quindi il confronto
+/// con le stringhe della specifica è diretto sulla rete PLM.
+///
+public class Bip32Tests
+{
+ private static ExtKey Root => ExtKey.CreateFromSeed(
+ Convert.FromHexString("000102030405060708090a0b0c0d0e0f"));
+
+ [Fact]
+ public void La_root_del_vettore_1_serializza_le_stringhe_attese()
+ {
+ Assert.Equal(
+ "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi",
+ Root.ToString(PalladiumNetworks.Mainnet));
+ Assert.Equal(
+ "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
+ Root.Neuter().ToString(PalladiumNetworks.Mainnet));
+ }
+
+ [Fact]
+ public void La_derivazione_hardened_m_0h_produce_le_chiavi_attese()
+ {
+ var derived = Root.Derive(new KeyPath("0'"));
+ Assert.Equal(
+ "xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7",
+ derived.ToString(PalladiumNetworks.Mainnet));
+ Assert.Equal(
+ "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw",
+ derived.Neuter().ToString(PalladiumNetworks.Mainnet));
+ }
+
+ [Fact]
+ public void I_path_hardened_non_sono_derivabili_da_una_xpub()
+ {
+ // Garanzia §17: da sole chiavi pubbliche niente derivazione hardened.
+ Assert.ThrowsAny(() =>
+ Root.Neuter().Derive(new KeyPath("0'")));
+ }
+
+ [Theory]
+ [InlineData("m/84'/746'/0'", true)]
+ [InlineData("84h/746h/0h", true)]
+ [InlineData("0/5", true)]
+ [InlineData("m/84'/abc", false)]
+ [InlineData("", false)]
+ public void TryParse_accetta_path_validi_e_rifiuta_malformati(string path, bool expected)
+ {
+ Assert.Equal(expected, DerivationPaths.TryParse(path, out var parsed));
+ if (expected)
+ Assert.NotNull(parsed);
+ }
+
+ [Fact]
+ public void Gli_hardened_marker_apostrofo_e_h_sono_equivalenti()
+ {
+ Assert.True(DerivationPaths.TryParse("m/84'/746'/0'", out var a));
+ Assert.True(DerivationPaths.TryParse("84h/746h/0h", out var b));
+ Assert.Equal(a!.ToString(), b!.ToString());
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Crypto/Bip39Tests.cs b/tests/PalladiumWallet.Tests/Crypto/Bip39Tests.cs
new file mode 100644
index 0000000..4e4b82f
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Crypto/Bip39Tests.cs
@@ -0,0 +1,93 @@
+using PalladiumWallet.Core.Crypto;
+
+namespace PalladiumWallet.Tests.Crypto;
+
+public class Bip39Tests
+{
+ // Vettori ufficiali Trezor (python-mnemonic/vectors.json), passphrase "TREZOR".
+ [Theory]
+ [InlineData(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04")]
+ [InlineData(
+ "legal winner thank year wave sausage worth useful legal winner thank yellow",
+ "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607")]
+ [InlineData(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
+ "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8")]
+ [InlineData(
+ "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
+ "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad")]
+ [InlineData(
+ "void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
+ "01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998")]
+ public void I_vettori_trezor_producono_il_seed_atteso(string words, string expectedSeedHex)
+ {
+ Assert.True(Bip39.TryParse(words, out var mnemonic));
+ Assert.Equal(expectedSeedHex, Convert.ToHexString(Bip39.ToSeed(mnemonic!, "TREZOR")).ToLowerInvariant());
+ }
+
+ [Fact]
+ public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
+ {
+ // Vettore ufficiale bip32JP (test_JP_BIP39.json[0]): mnemonica con spazi
+ // ideografici U+3000 e passphrase con caratteri da normalizzare NFKD.
+ const string words =
+ "あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
+ const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
+ const string expectedSeed =
+ "a262d6fb6122ecf45be09c50492b31f92e9beb7d9a845987a02cefda57a15f9c467a17872029a9e92299b5cbdf306e3a0ee620245cbd508959b6cb7ca637bd55";
+
+ Assert.True(Bip39.TryParse(words, out var mnemonic, MnemonicLanguage.Japanese));
+ Assert.Equal(expectedSeed, Convert.ToHexString(Bip39.ToSeed(mnemonic!, passphrase)).ToLowerInvariant());
+ }
+
+ [Theory]
+ [InlineData(MnemonicLength.Twelve)]
+ [InlineData(MnemonicLength.TwentyFour)]
+ public void Generate_produce_il_numero_di_parole_richiesto_con_checksum_valido(MnemonicLength length)
+ {
+ var mnemonic = Bip39.Generate(length);
+ Assert.Equal((int)length, mnemonic.Words.Length);
+ Assert.True(Bip39.TryParse(mnemonic.ToString(), out _));
+ }
+
+ [Fact]
+ public void Checksum_invalido_viene_rifiutato()
+ {
+ // 12 × "abandon": parole valide ma checksum errato — il caso che il
+ // costruttore NBitcoin non controlla da solo.
+ var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
+ Assert.False(Bip39.TryParse(words, out _));
+ }
+
+ [Fact]
+ public void Numero_di_parole_errato_viene_rifiutato()
+ {
+ var words = string.Join(' ', Enumerable.Repeat("abandon", 12)) + " about";
+ Assert.False(Bip39.TryParse(words, out _));
+ }
+
+ [Fact]
+ public void La_lingua_viene_riconosciuta_automaticamente()
+ {
+ var spanish = Bip39.Generate(MnemonicLength.Twelve, MnemonicLanguage.Spanish);
+ Assert.True(Bip39.TryParse(spanish.ToString(), out var parsed));
+ Assert.Equal(spanish.ToString(), parsed!.ToString());
+ }
+
+ [Fact]
+ public void Passphrase_diverse_producono_seed_diversi()
+ {
+ Assert.True(Bip39.TryParse(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ out var mnemonic));
+
+ var noPass = Bip39.ToSeed(mnemonic!);
+ var withPass = Bip39.ToSeed(mnemonic!, "TREZOR");
+ var otherPass = Bip39.ToSeed(mnemonic!, "trezor"); // case-sensitive (§4.1)
+
+ Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
+ Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs b/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs
new file mode 100644
index 0000000..b3118cb
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs
@@ -0,0 +1,100 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+
+namespace PalladiumWallet.Tests.Crypto;
+
+///
+/// Vettore di test della specifica BIP84 (mnemonica abandon-about, senza
+/// passphrase). Gli header SLIP-132 della mainnet PLM coincidono con quelli
+/// Bitcoin, quindi zprv/zpub si confrontano direttamente; gli indirizzi si
+/// confrontano sul witness program (chain-independent) + prefisso PLM.
+/// Il path m/84'/0'/0' è volutamente "personalizzato" (coin type 0, non 746):
+/// esercita anche l'import con path custom (§4.2).
+///
+public class Bip84Slip132Tests
+{
+ 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.FromSeed(
+ Bip39.ToSeed(mnemonic!),
+ ScriptKind.NativeSegwit,
+ ChainProfiles.Mainnet,
+ new KeyPath("84'/0'/0'"));
+ }
+
+ [Fact]
+ public void L_account_serializza_la_zprv_e_la_zpub_del_vettore()
+ {
+ var account = Account();
+ Assert.Equal(
+ "zprvAdG4iTXWBoARxkkzNpNh8r6Qag3irQB8PzEMkAFeTRXxHpbF9z4QgEvBRmfvqWvGp42t42nvgGpNgYSJA9iefm1yYNZKEm7z6qUWCroSQnE",
+ account.ToSlip132Private());
+ Assert.Equal(
+ "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs",
+ account.ToSlip132());
+ }
+
+ [Fact]
+ public void La_zpub_fa_roundtrip_e_viene_riconosciuta_come_native_segwit()
+ {
+ var account = Account();
+ var encoded = account.ToSlip132();
+
+ Assert.True(Slip132.TryDecodePublic(encoded, ChainProfiles.Mainnet, out var decoded, out var kind));
+ Assert.Equal(ScriptKind.NativeSegwit, kind);
+ Assert.Equal(account.AccountXpub.ToString(PalladiumNetworks.Mainnet),
+ decoded!.ToString(PalladiumNetworks.Mainnet));
+ }
+
+ [Fact]
+ public void La_zprv_fa_roundtrip_con_riconoscimento_del_tipo()
+ {
+ var account = Account();
+ Assert.True(Slip132.TryDecodePrivate(account.ToSlip132Private(), ChainProfiles.Mainnet,
+ out var decoded, out var kind));
+ Assert.Equal(ScriptKind.NativeSegwit, kind);
+ Assert.Equal(account.ToSlip132Private(),
+ Slip132.Encode(decoded!, kind, ChainProfiles.Mainnet));
+ }
+
+ [Fact]
+ public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
+ {
+ // xpub Bitcoin valida ma con header Legacy: non è una zpub.
+ var account = Account();
+ var asXpub = account.AccountXpub.ToString(Network.Main);
+
+ Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
+ Assert.Equal(ScriptKind.Legacy, kind); // header xpub → riconosciuto come Legacy
+ Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
+ Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
+ }
+
+ [Theory]
+ [InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
+ "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
+ [InlineData(false, 1, null, "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g")]
+ [InlineData(true, 0, null, "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el")]
+ public void Gli_indirizzi_del_vettore_hanno_lo_stesso_witness_program_su_plm(
+ bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
+ {
+ var account = Account();
+ var pubKey = account.GetPublicKey(isChange, index);
+
+ if (expectedPubKeyHex is not null)
+ Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
+
+ // Il witness program (hash160 della pubkey) è chain-independent: deve
+ // coincidere con quello dell'indirizzo bc1 del vettore ufficiale.
+ var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
+ Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
+
+ var plmAddress = account.GetAddress(isChange, index);
+ Assert.StartsWith("plm1q", plmAddress.ToString());
+ Assert.Equal(pubKey.WitHash, ((BitcoinWitPubKeyAddress)plmAddress).Hash);
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Crypto/HdAccountTests.cs b/tests/PalladiumWallet.Tests/Crypto/HdAccountTests.cs
new file mode 100644
index 0000000..cd1816c
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Crypto/HdAccountTests.cs
@@ -0,0 +1,88 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+
+namespace PalladiumWallet.Tests.Crypto;
+
+public class HdAccountTests
+{
+ private static Mnemonic AbandonAbout()
+ {
+ Assert.True(Bip39.TryParse(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ out var mnemonic));
+ return mnemonic!;
+ }
+
+ [Fact]
+ public void Il_watch_only_da_xpub_deriva_gli_stessi_indirizzi_del_seed()
+ {
+ var full = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+
+ Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), ChainProfiles.Mainnet, out var xpub, out var kind));
+ var watchOnly = HdAccount.FromAccountXpub(xpub!, kind, ChainProfiles.Mainnet);
+
+ Assert.True(watchOnly.IsWatchOnly);
+ Assert.False(full.IsWatchOnly);
+ for (var i = 0; i < 3; i++)
+ {
+ Assert.Equal(full.GetReceiveAddress(i).ToString(), watchOnly.GetReceiveAddress(i).ToString());
+ Assert.Equal(full.GetChangeAddress(i).ToString(), watchOnly.GetChangeAddress(i).ToString());
+ }
+ }
+
+ [Fact]
+ public void Il_watch_only_non_espone_chiavi_private()
+ {
+ var full = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+ Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), ChainProfiles.Mainnet, out var xpub, out _));
+ var watchOnly = HdAccount.FromAccountXpub(xpub!, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+
+ Assert.Throws(() => watchOnly.GetExtPrivateKey(false, 0));
+ Assert.Throws(() => watchOnly.ToSlip132Private());
+ }
+
+ [Fact]
+ public void La_master_fingerprint_di_abandon_about_e_quella_nota()
+ {
+ // 73c5da0a: fingerprint usata nei vettori PSBT di mezzo ecosistema.
+ var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+ Assert.Equal("73c5da0a", Convert.ToHexString(account.MasterFingerprint.ToBytes()).ToLowerInvariant());
+ }
+
+ [Fact]
+ public void La_chiave_privata_derivata_corrisponde_all_indirizzo()
+ {
+ var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+ var priv = account.GetExtPrivateKey(isChange: false, 0);
+
+ Assert.Equal(account.GetPublicKey(false, 0), priv.PrivateKey.PubKey);
+ }
+
+ [Fact]
+ public void La_passphrase_cambia_completamente_l_account()
+ {
+ var without = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+ var with = HdAccount.FromMnemonic(AbandonAbout(), "extension", ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+
+ Assert.NotEqual(without.GetReceiveAddress(0).ToString(), with.GetReceiveAddress(0).ToString());
+ Assert.NotEqual(without.MasterFingerprint, with.MasterFingerprint);
+ }
+
+ [Theory]
+ [InlineData(ScriptKind.WrappedSegwitMultisig)]
+ [InlineData(ScriptKind.NativeSegwitMultisig)]
+ public void I_tipi_multisig_non_sono_ancora_supportati(ScriptKind kind)
+ {
+ Assert.Throws(() =>
+ HdAccount.FromMnemonic(AbandonAbout(), null, kind, ChainProfiles.Mainnet));
+ Assert.Throws(() => DerivationPaths.ScriptPubKeyTypeFor(kind));
+ }
+
+ [Fact]
+ public void L_account_di_default_usa_il_path_standard_del_profilo()
+ {
+ var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
+ Assert.Equal("84'/746'/0'", account.AccountPath.ToString());
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Spv/SpvTests.cs b/tests/PalladiumWallet.Tests/Spv/SpvTests.cs
new file mode 100644
index 0000000..47b50ed
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Spv/SpvTests.cs
@@ -0,0 +1,127 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Spv;
+
+namespace PalladiumWallet.Tests.Spv;
+
+public class ScripthashTests
+{
+ // Vettori calcolati indipendentemente con Python (hashlib), non con NBitcoin.
+ [Theory]
+ [InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
+ "5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
+ [InlineData("00140102030405060708090a0b0c0d0e0f1011121314",
+ "8639a8f75edb01f890138755277a84283c26fcba6f3289725d19cead464aa78a")]
+ public void Lo_scripthash_e_sha256_dello_script_con_byte_invertiti(string scriptHex, string expected)
+ {
+ var script = Script.FromHex(scriptHex);
+ Assert.Equal(expected, Scripthash.FromScript(script));
+ }
+}
+
+public class MerkleProofTests
+{
+ // Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna
+ // per la convenzione di hashing/ordinamento.
+ private static readonly uint256[] Block100000Txids =
+ [
+ uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
+ uint256.Parse("fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4"),
+ uint256.Parse("6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4"),
+ uint256.Parse("e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d"),
+ ];
+
+ private static readonly uint256 Block100000Root =
+ uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
+
+ [Fact]
+ public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
+ {
+ Assert.Equal(Block100000Root, MerkleProof.ComputeRootFromLeaves(Block100000Txids));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ public void Ogni_prova_di_merkle_del_blocco_verifica_contro_la_radice(int position)
+ {
+ var branch = BuildBranch(Block100000Txids, position);
+ Assert.True(MerkleProof.Verify(Block100000Txids[position], position, branch, Block100000Root));
+ }
+
+ [Fact]
+ public void Una_prova_per_la_posizione_sbagliata_fallisce()
+ {
+ var branch = BuildBranch(Block100000Txids, 0);
+ Assert.False(MerkleProof.Verify(Block100000Txids[0], 1, branch, Block100000Root));
+ }
+
+ [Fact]
+ public void Un_txid_estraneo_non_verifica()
+ {
+ var branch = BuildBranch(Block100000Txids, 0);
+ Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root));
+ }
+
+ /// Costruisce il branch per una foglia ricostruendo i livelli dell'albero.
+ private static List BuildBranch(IReadOnlyList leaves, int position)
+ {
+ var branch = new List();
+ var level = leaves.ToList();
+ while (level.Count > 1)
+ {
+ var sibling = (position ^ 1) < level.Count ? level[position ^ 1] : level[position];
+ branch.Add(sibling);
+ var next = new List();
+ for (var i = 0; i < level.Count; i += 2)
+ {
+ var pair = new[] { level[i], i + 1 < level.Count ? level[i + 1] : level[i] };
+ next.Add(MerkleProof.ComputeRootFromLeaves(pair));
+ }
+ level = next;
+ position >>= 1;
+ }
+ return branch;
+ }
+}
+
+public class BlockHeaderInfoTests
+{
+ // Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3).
+ private const string GenesisHeaderHex =
+ "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
+
+ [Fact]
+ public void L_header_della_genesi_si_parsa_con_hash_e_merkle_root_attesi()
+ {
+ var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
+
+ Assert.Equal(ChainProfiles.Mainnet.GenesisHash, header.Hash.ToString());
+ Assert.Equal(
+ "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
+ header.MerkleRoot.ToString());
+ Assert.Equal(uint256.Zero, header.PrevHash);
+ Assert.Equal(1, header.Version);
+ Assert.Equal(0x1d00ffffu, header.Bits);
+ }
+
+ [Fact]
+ public void Il_collegamento_prev_hash_viene_verificato()
+ {
+ var genesis = BlockHeaderInfo.Parse(GenesisHeaderHex);
+ Assert.True(genesis.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
+ Assert.False(genesis.IsValidChild(uint256.One, ChainProfiles.Mainnet));
+ }
+
+ [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));
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs
new file mode 100644
index 0000000..1172f87
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs
@@ -0,0 +1,95 @@
+using PalladiumWallet.Core.Storage;
+
+namespace PalladiumWallet.Tests.Storage;
+
+public class StorageTests
+{
+ private static WalletDocument SampleDoc() => new()
+ {
+ Network = "regtest",
+ ScriptKind = "NativeSegwit",
+ Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ AccountPath = "84'/1'/0'",
+ AccountXpub = "vpub-fittizia-per-test",
+ Labels = { ["txid123"] = "caffè" },
+ };
+
+ [Fact]
+ public void La_cifratura_fa_roundtrip_con_la_password_giusta()
+ {
+ var cipher = EncryptedFile.Encrypt("contenuto segreto", "pass-forte");
+ Assert.True(EncryptedFile.IsEncrypted(cipher));
+ Assert.DoesNotContain("segreto", cipher);
+ Assert.Equal("contenuto segreto", EncryptedFile.Decrypt(cipher, "pass-forte"));
+ }
+
+ [Fact]
+ public void La_password_errata_viene_rifiutata()
+ {
+ var cipher = EncryptedFile.Encrypt("contenuto", "giusta");
+ Assert.Throws(() => EncryptedFile.Decrypt(cipher, "sbagliata"));
+ }
+
+ [Fact]
+ 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 data = Convert.FromBase64String(node["Data"]!.GetValue());
+ data[0] ^= 0xff;
+ node["Data"] = Convert.ToBase64String(data);
+ Assert.Throws(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
+ }
+
+ [Fact]
+ public void Il_documento_wallet_fa_roundtrip_json()
+ {
+ var doc = SampleDoc();
+ var restored = WalletDocument.FromJson(doc.ToJson());
+
+ Assert.Equal(doc.Network, restored.Network);
+ Assert.Equal(doc.Mnemonic, restored.Mnemonic);
+ Assert.Equal(doc.AccountXpub, restored.AccountXpub);
+ Assert.Equal("caffè", restored.Labels["txid123"]);
+ Assert.False(restored.IsWatchOnly);
+ }
+
+ [Fact]
+ public void Una_versione_futura_del_file_viene_rifiutata()
+ {
+ var doc = SampleDoc();
+ var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99");
+ Assert.Throws(() => WalletDocument.FromJson(json));
+ }
+
+ [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");
+ try
+ {
+ WalletStore.Save(SampleDoc(), path);
+ Assert.False(WalletStore.RequiresPassword(path));
+ Assert.Equal("regtest", WalletStore.Load(path).Network);
+
+ WalletStore.Save(SampleDoc(), path, "pwd");
+ Assert.True(WalletStore.RequiresPassword(path));
+ Assert.Throws(() => WalletStore.Load(path));
+ Assert.Throws(() => WalletStore.Load(path, "altra"));
+ Assert.Equal("regtest", WalletStore.Load(path, "pwd").Network);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Il_documento_senza_mnemonica_e_watch_only()
+ {
+ var doc = SampleDoc();
+ doc.Mnemonic = null;
+ Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
+ }
+}
diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs
new file mode 100644
index 0000000..682e702
--- /dev/null
+++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs
@@ -0,0 +1,146 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+using PalladiumWallet.Core.Crypto;
+using PalladiumWallet.Core.Storage;
+using PalladiumWallet.Core.Wallet;
+
+namespace PalladiumWallet.Tests.Wallet;
+
+public class TransactionFactoryTests
+{
+ 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);
+ }
+
+ /// Tx fittizia che accredita sull'indirizzo receiving/0.
+ private static (List, Dictionary) Fund(HdAccount account, long sats)
+ {
+ var funding = Net.CreateTransaction();
+ funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
+ funding.Outputs.Add(Money.Satoshis(sats), account.GetReceiveAddress(0));
+ var txid = funding.GetHash().ToString();
+
+ var utxos = new List
+ {
+ new()
+ {
+ Txid = txid, Vout = 0, ValueSats = sats,
+ Address = account.GetReceiveAddress(0).ToString(),
+ IsChange = false, AddressIndex = 0, Height = 100,
+ },
+ };
+ return (utxos, new Dictionary { [txid] = funding });
+ }
+
+ [Fact]
+ public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
+ {
+ var account = Account();
+ var (utxos, txs) = Fund(account, 1_000_000);
+ var destination = BitcoinAddress.Create(account.GetReceiveAddress(5).ToString(), Net);
+
+ var built = new TransactionFactory(account).Build(
+ utxos, txs, destination, amountSats: 400_000,
+ feeRateSatPerVByte: 2, changeIndex: 0);
+
+ Assert.True(built.Signed);
+ // Output: destinatario + change.
+ Assert.Equal(2, built.Transaction.Outputs.Count);
+ Assert.Contains(built.Transaction.Outputs,
+ o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
+
+ // Fee coerente col rate richiesto (±20% per gli arrotondamenti di stima).
+ var vsize = built.Transaction.GetVirtualSize();
+ var expected = vsize * 2;
+ Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
+
+ // RBF abilitato (§6.6).
+ Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
+ }
+
+ [Fact]
+ public void Invia_tutto_sottrae_la_fee_e_non_produce_change()
+ {
+ var account = Account();
+ var (utxos, txs) = Fund(account, 500_000);
+ var destination = account.GetReceiveAddress(7);
+
+ var built = new TransactionFactory(account).Build(
+ utxos, txs, destination, amountSats: 0,
+ feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
+
+ var output = Assert.Single(built.Transaction.Outputs);
+ Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
+ }
+
+ [Fact]
+ public void Fondi_insufficienti_danno_un_errore_chiaro()
+ {
+ var account = Account();
+ var (utxos, txs) = Fund(account, 1_000);
+
+ Assert.Throws(() => new TransactionFactory(account).Build(
+ utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
+ feeRateSatPerVByte: 2, changeIndex: 0));
+ }
+
+ [Fact]
+ public void Gli_utxo_congelati_sono_esclusi_dalla_spesa()
+ {
+ var account = Account();
+ var (utxos, txs) = Fund(account, 1_000_000);
+ utxos[0].Frozen = true; // freeze (§6.2)
+
+ Assert.Throws(() => new TransactionFactory(account).Build(
+ utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
+ feeRateSatPerVByte: 2, changeIndex: 0));
+ }
+
+ [Fact]
+ public void Un_account_watch_only_produce_una_psbt_non_firmata()
+ {
+ var full = Account();
+ var (utxos, txs) = Fund(full, 1_000_000);
+
+ Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), Profile, out var xpub, out var kind));
+ var watchOnly = HdAccount.FromAccountXpub(xpub!, kind, Profile);
+
+ var built = new TransactionFactory(watchOnly).Build(
+ utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
+ feeRateSatPerVByte: 2, changeIndex: 0);
+
+ Assert.False(built.Signed);
+ // Il flusso air-gapped (§6.5): la PSBT della macchina online si firma
+ // offline con le chiavi e si finalizza.
+ var psbt = built.Psbt;
+ psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
+ psbt.Finalize();
+ var tx = psbt.ExtractTransaction();
+ Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
+ }
+
+ [Theory]
+ [InlineData("0.00000001", 1L)]
+ [InlineData("1", 100_000_000L)]
+ [InlineData("0,5", 50_000_000L)]
+ [InlineData("21.12345678", 2_112_345_678L)]
+ public void Gli_importi_si_parsano_in_satoshi(string text, long expected)
+ {
+ Assert.True(CoinAmount.TryParseCoins(text, out var sats));
+ Assert.Equal(expected, sats);
+ }
+
+ [Fact]
+ public void Gli_importi_invalidi_vengono_rifiutati()
+ {
+ Assert.False(CoinAmount.TryParseCoins("abc", out _));
+ Assert.False(CoinAmount.TryParseCoins("-1", out _));
+ }
+}