test: unit tests for all core modules

This commit is contained in:
2026-06-11 10:48:21 +02:00
parent 0e32b9bc9b
commit 34c588c6f4
10 changed files with 974 additions and 0 deletions
@@ -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());
}
}
@@ -0,0 +1,68 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
/// <summary>
/// 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.
/// </summary>
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<InvalidOperationException>(() =>
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());
}
}
@@ -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));
}
}
@@ -0,0 +1,100 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
/// <summary>
/// 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).
/// </summary>
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);
}
}
@@ -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<InvalidOperationException>(() => watchOnly.GetExtPrivateKey(false, 0));
Assert.Throws<InvalidOperationException>(() => 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<NotSupportedException>(() =>
HdAccount.FromMnemonic(AbandonAbout(), null, kind, ChainProfiles.Mainnet));
Assert.Throws<NotSupportedException>(() => 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());
}
}