diff --git a/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs b/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs index ec7a6cd..704e0a4 100644 --- a/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs +++ b/tests/PalladiumWallet.Tests/Crypto/AddressDerivationTests.cs @@ -20,7 +20,7 @@ public class AddressDerivationTests // Known abandon-about address at m/44'/0'/0'/0/0 (public reference). var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy, ChainProfiles.Mainnet, new KeyPath("44'/0'/0'")); - var pubKey = account.GetPublicKey(isChange: false, 0); + var pubKey = account.GetPublicKey(isChange: false, 0)!; var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main); Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString()); @@ -37,7 +37,7 @@ public class AddressDerivationTests { var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit, ChainProfiles.Mainnet, new KeyPath("49'/0'/0'")); - var pubKey = account.GetPublicKey(isChange: false, 0); + var pubKey = account.GetPublicKey(isChange: false, 0)!; var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main); Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString()); diff --git a/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs b/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs index 119fe6b..939c3e2 100644 --- a/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs +++ b/tests/PalladiumWallet.Tests/Crypto/Bip84Slip132Tests.cs @@ -83,7 +83,7 @@ public class Bip84Slip132Tests bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress) { var account = Account(); - var pubKey = account.GetPublicKey(isChange, index); + var pubKey = account.GetPublicKey(isChange, index)!; if (expectedPubKeyHex is not null) Assert.Equal(expectedPubKeyHex, pubKey.ToHex()); diff --git a/tests/PalladiumWallet.Tests/PropertyTests.cs b/tests/PalladiumWallet.Tests/PropertyTests.cs index 2465b3f..3f7c91c 100644 --- a/tests/PalladiumWallet.Tests/PropertyTests.cs +++ b/tests/PalladiumWallet.Tests/PropertyTests.cs @@ -182,6 +182,121 @@ public class PropertyTests }); } + // ── Scripthash ──────────────────────────────────────────────────────────── + + /// The scripthash must equal an independent SHA-256-and-reverse computation + /// for any script bytes (cross-check, not a re-run of the same code). + [Fact] + public void Scripthash_coincide_con_il_calcolo_indipendente_per_ogni_script() + { + Gen.Byte.Array[0, 64].Sample(bytes => + { + var expected = System.Security.Cryptography.SHA256.HashData(bytes); + Array.Reverse(expected); + Assert.Equal( + Convert.ToHexString(expected).ToLowerInvariant(), + Core.Spv.Scripthash.FromScript(Script.FromBytesUnsafe(bytes))); + }); + } + + // ── Slip132 ─────────────────────────────────────────────────────────────── + + private static readonly Gen GenScriptKind = Gen.OneOfConst( + Core.Chain.ScriptKind.Legacy, + Core.Chain.ScriptKind.WrappedSegwit, + Core.Chain.ScriptKind.NativeSegwit, + Core.Chain.ScriptKind.WrappedSegwitMultisig, + Core.Chain.ScriptKind.NativeSegwitMultisig); + + private static readonly Gen GenProfile = Gen.OneOfConst( + Core.Chain.ChainProfiles.Mainnet, Core.Chain.ChainProfiles.Testnet); + + /// Encode → TryDecodePrivate must roundtrip key bytes and header family + /// for any key, script kind, and network. + [Fact] + public void Slip132_roundtrip_chiave_privata_per_ogni_kind_e_rete() + { + Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) => + { + var key = ExtKey.CreateFromSeed(seed); + var encoded = Core.Crypto.Slip132.Encode(key, kind, profile); + + Assert.True(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out var decoded, out var decodedKind)); + Assert.Equal(key.ToBytes(), decoded!.ToBytes()); + // Legacy and Taproot share the BIP32 header: compare header families, not enum values. + Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]); + // A private key must never decode as public. + Assert.False(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out _, out _)); + }); + } + + /// Same roundtrip for the public (watch-only import) side. + [Fact] + public void Slip132_roundtrip_chiave_pubblica_per_ogni_kind_e_rete() + { + Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) => + { + var xpub = ExtKey.CreateFromSeed(seed).Neuter(); + var encoded = Core.Crypto.Slip132.Encode(xpub, kind, profile); + + Assert.True(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out var decoded, out var decodedKind)); + Assert.Equal(xpub.ToBytes(), decoded!.ToBytes()); + Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]); + Assert.False(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out _, out _)); + }); + } + + /// TryDecode must never throw on arbitrary input strings. + [Fact] + public void Slip132_TryDecode_non_lancia_mai_su_input_arbitrario() + { + Gen.String.Sample(text => + { + try + { + Core.Crypto.Slip132.TryDecodePublic(text, Core.Chain.ChainProfiles.Mainnet, out _, out _); + Core.Crypto.Slip132.TryDecodePrivate(text, Core.Chain.ChainProfiles.Mainnet, out _, out _); + } + catch (Exception ex) + { + Assert.Fail($"TryDecode threw {ex.GetType().Name} for '{text}'"); + } + }); + } + + // ── WalletDocument ──────────────────────────────────────────────────────── + + /// ToJson → FromJson must preserve labels and contacts for arbitrary strings + /// (unicode, quotes, control characters…). + [Fact] + public void WalletDocument_roundtrip_json_con_etichette_e_contatti_arbitrari() + { + Gen.Select(Gen.Select(Gen.String, Gen.String).Array[0, 8], Gen.String).Sample((pairs, contactName) => + { + // Lone UTF-16 surrogates are not representable in JSON (the writer + // replaces them): an exact roundtrip is impossible by design, skip. + if (pairs.Any(p => p.Item1.Any(char.IsSurrogate) || p.Item2.Any(char.IsSurrogate)) + || contactName.Any(char.IsSurrogate)) + return; + + var doc = new WalletDocument + { + Network = "regtest", + ScriptKind = "NativeSegwit", + AccountPath = "84'/1'/0'", + AccountXpub = "vpub-test", + Contacts = { new StoredContact { Name = contactName, Address = "rplm1qtest" } }, + }; + foreach (var (k, v) in pairs) + doc.Labels[k] = v; + + var restored = WalletDocument.FromJson(doc.ToJson()); + + Assert.Equal(doc.Labels, restored.Labels); + Assert.Equal(contactName, Assert.Single(restored.Contacts).Name); + }); + } + // helper: builds the branch for the given position private static List BuildBranch(IReadOnlyList leaves, int position) { diff --git a/tests/PalladiumWallet.Tests/Storage/AppPathsTests.cs b/tests/PalladiumWallet.Tests/Storage/AppPathsTests.cs new file mode 100644 index 0000000..b5e3be1 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Storage/AppPathsTests.cs @@ -0,0 +1,84 @@ +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.Tests.Storage; + +/// +/// Tests for the data-path resolution (§8), pinned to a temporary root via +/// — the same seam the Android head and +/// the CLI --data-dir use — so nothing outside the temp folder is touched. +/// The pointer-file and portable-mode branches read machine-global locations +/// and are deliberately not exercised here. +/// +public class AppPathsTests : IDisposable +{ + private readonly string _root; + private readonly string? _savedOverride; + + public AppPathsTests() + { + _root = Path.Combine(Path.GetTempPath(), $"plm-data-{Guid.NewGuid()}"); + _savedOverride = AppPaths.OverrideDataRoot; + AppPaths.OverrideDataRoot = _root; + } + + public void Dispose() + { + AppPaths.OverrideDataRoot = _savedOverride; + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + + [Fact] + public void L_override_ha_priorita_su_tutto() + { + Assert.Equal(_root, AppPaths.DataRoot()); + Assert.True(AppPaths.IsDataLocationConfigured()); + } + + [Fact] + public void Ogni_rete_ha_la_propria_sottocartella_con_il_nome_del_profilo() + { + foreach (var net in new[] { NetKind.Mainnet, NetKind.Testnet, NetKind.Regtest }) + { + var dir = AppPaths.ForNetwork(net); + Assert.Equal(Path.Combine(_root, ChainProfiles.For(net).NetName), dir); + Assert.True(Directory.Exists(dir)); + } + } + + [Fact] + public void I_percorsi_dei_file_di_rete_stanno_sotto_la_cartella_della_rete() + { + var netDir = AppPaths.ForNetwork(NetKind.Mainnet); + + Assert.Equal(Path.Combine(netDir, "server-certs.json"), AppPaths.CertificatePinsPath(NetKind.Mainnet)); + Assert.Equal(Path.Combine(netDir, "servers.json"), AppPaths.ServersPath(NetKind.Mainnet)); + Assert.Equal(Path.Combine(netDir, "wallets", "default.wallet.json"), + AppPaths.DefaultWalletPath(NetKind.Mainnet)); + Assert.Equal(Path.Combine(_root, "config.json"), AppPaths.ConfigPath()); + } + + [Fact] + public void WalletFiles_elenca_solo_i_wallet_in_ordine_alfabetico() + { + var dir = AppPaths.WalletsDir(NetKind.Regtest); + File.WriteAllText(Path.Combine(dir, "zeta.wallet.json"), "{}"); + File.WriteAllText(Path.Combine(dir, "alfa.wallet.json"), "{}"); + File.WriteAllText(Path.Combine(dir, "non-un-wallet.txt"), "x"); + File.WriteAllText(Path.Combine(dir, "default.wallet.json.lock"), "x"); + + var files = AppPaths.WalletFiles(NetKind.Regtest); + + Assert.Equal(2, files.Count); + Assert.EndsWith("alfa.wallet.json", files[0]); + Assert.EndsWith("zeta.wallet.json", files[1]); + } + + [Fact] + public void Le_reti_non_condividono_le_cartelle_wallet() + { + Assert.NotEqual(AppPaths.WalletsDir(NetKind.Mainnet), AppPaths.WalletsDir(NetKind.Testnet)); + Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest)); + } +} diff --git a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs index fb067f2..0921ddc 100644 --- a/tests/PalladiumWallet.Tests/Storage/StorageTests.cs +++ b/tests/PalladiumWallet.Tests/Storage/StorageTests.cs @@ -77,6 +77,31 @@ public class StorageTests Assert.False(EncryptedFile.IsEncrypted("non è json")); } + // Regression: valid JSON whose root is not an object (found by the + // property test) must not throw from TryGetProperty. + [Theory] + [InlineData("5")] + [InlineData("true")] + [InlineData("\"stringa\"")] + [InlineData("[1, 2]")] + public void IsEncrypted_restituisce_false_per_json_con_radice_non_oggetto(string content) + { + Assert.False(EncryptedFile.IsEncrypted(content)); + } + + [Fact] + public void IsEncrypted_restituisce_false_per_utf16_invalido() + { + // Lone surrogate: cannot be transcoded to UTF-8 for JSON parsing. + Assert.False(EncryptedFile.IsEncrypted("\ud800")); + } + + [Fact] + public void IsEncrypted_restituisce_false_se_Format_non_e_una_stringa() + { + Assert.False(EncryptedFile.IsEncrypted("{\"Format\": 42}")); + } + // ---- WalletDocument JSON ---- [Fact] diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs index b6d2160..668bb2b 100644 --- a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs @@ -244,6 +244,104 @@ public class TransactionFactoryTests } } + [Theory] + [InlineData(ScriptPubKeyType.Legacy)] + [InlineData(ScriptPubKeyType.SegwitP2SH)] + [InlineData(ScriptPubKeyType.Segwit)] + public void Si_puo_pagare_ogni_tipo_di_indirizzo_standard(ScriptPubKeyType kind) + { + var account = Account(); + var (utxos, txs) = Fund(account, 1_000_000); + var destination = new Key().PubKey.GetAddress(kind, Net); + + var built = new TransactionFactory(account).Build( + utxos, txs, destination, amountSats: 400_000, + feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100); + + Assert.True(built.Signed); + Assert.Contains(built.Transaction.Outputs, + o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000); + } + + [Fact] + public void Piu_utxo_vengono_combinati_quando_uno_solo_non_basta() + { + var account = Account(); + var allUtxos = new List(); + var allTxs = new Dictionary(); + for (var i = 0; i < 3; i++) + { + var funding = Net.CreateTransaction(); + funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)i))); + funding.Outputs.Add(Money.Satoshis(300_000), account.GetReceiveAddress(i)); + var txid = funding.GetHash().ToString(); + allTxs[txid] = funding; + allUtxos.Add(new CachedUtxo + { + Txid = txid, Vout = 0, ValueSats = 300_000, + Address = account.GetReceiveAddress(i).ToString(), + IsChange = false, AddressIndex = i, Height = 100, + }); + } + + var built = new TransactionFactory(account).Build( + allUtxos, allTxs, account.GetReceiveAddress(5), amountSats: 700_000, + feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100); + + // 700k > any pair? No: it needs all three 300k coins (600k < 700k + fee). + Assert.Equal(3, built.Transaction.Inputs.Count); + Assert.True(built.Signed); + Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000); + } + + [Fact] + public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee() + { + var account = Account(); + var (utxos, txs) = Fund(account, 100_000); + + // Leaves ~200 sats after the fee: below the P2WPKH dust threshold, + // so no change output must be created and the remainder goes to the fee. + var built = new TransactionFactory(account).Build( + utxos, txs, account.GetReceiveAddress(5), amountSats: 99_650, + feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100); + + var output = Assert.Single(built.Transaction.Outputs); + Assert.Equal(99_650, output.Value.Satoshi); + Assert.Equal(100_000 - 99_650, built.Fee.Satoshi); + } + + [Fact] + public void La_firma_di_una_tx_fissa_produce_il_txid_golden() + { + // Golden vector for the signing path (derivation → sighash → witness): + // the factory shuffles outputs for privacy, so the vector is anchored one + // level below, on a transaction with fixed structure signed through PSBT + // (the same flow used air-gapped, §6.5). RFC 6979 makes it deterministic: + // any change in this txid is a blocking regression. + var account = Account(); + + var funding = Net.CreateTransaction(); + funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0)); + + var spend = Net.CreateTransaction(); + spend.Version = 2; + spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd }); + spend.Outputs.Add(Money.Satoshis(600_000), account.GetReceiveAddress(5)); + spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0)); + + var psbt = PSBT.FromTransaction(spend, Net); + psbt.AddCoins(new Coin(funding, 0)); + psbt.SignWithKeys(account.GetExtPrivateKey(isChange: false, 0)); + psbt.Finalize(); + var signed = psbt.ExtractTransaction(); + + Assert.Equal( + "a943cf6bf606fa0050e490cb76ed9313959d228fb0ffa235b7e8b7f6834610b6", + signed.GetHash().ToString()); + } + [Fact] public void Una_config_corrotta_torna_ai_default() { diff --git a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs index de7ef4c..e126ce2 100644 --- a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs @@ -28,8 +28,8 @@ public class WalletLoaderTests Assert.Equal("NativeSegwit", doc.ScriptKind); Assert.Equal(ValidMnemonic, doc.Mnemonic); Assert.Null(doc.Passphrase); - Assert.NotEmpty(doc.AccountXpub); - Assert.NotEmpty(doc.MasterFingerprint); + Assert.NotEmpty(doc.AccountXpub!); + Assert.NotEmpty(doc.MasterFingerprint!); Assert.False(doc.IsWatchOnly); } @@ -66,7 +66,7 @@ public class WalletLoaderTests var (doc, _) = WalletLoader.NewFromMnemonic( ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); Assert.Equal("mainnet", doc.Network); - Assert.NotEmpty(doc.AccountXpub); + Assert.NotEmpty(doc.AccountXpub!); } [Fact]