test: extend wallet/storage/property coverage and fix nullable warnings

- AppPaths: data-root resolution, per-network paths, wallet file listing.
- TransactionFactory: legacy/P2SH/segwit destinations, multi-UTXO selection,
  dust change absorbed into fee, and a golden txid for the PSBT signing path
  (RFC 6979 makes it deterministic; the builder's own output shuffling for
  privacy makes anchoring the vector at the top-level Build() unreliable).
- PropertyTests: Slip132 roundtrip for every script kind and network,
  WalletDocument JSON roundtrip with arbitrary labels/contacts, Scripthash
  cross-checked against an independent SHA-256 computation.
- StorageTests: regression cases for the IsEncrypted fix.
- Removed CS8602/CS8604 nullable warnings from existing address/loader tests.
This commit is contained in:
2026-07-02 23:21:20 +02:00
parent 448f7dc65d
commit 56ce1d4259
7 changed files with 328 additions and 6 deletions
@@ -20,7 +20,7 @@ public class AddressDerivationTests
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference). // Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy, var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'")); 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); var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString()); Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
@@ -37,7 +37,7 @@ public class AddressDerivationTests
{ {
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit, var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'")); 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); var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString()); Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
@@ -83,7 +83,7 @@ public class Bip84Slip132Tests
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress) bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
{ {
var account = Account(); var account = Account();
var pubKey = account.GetPublicKey(isChange, index); var pubKey = account.GetPublicKey(isChange, index)!;
if (expectedPubKeyHex is not null) if (expectedPubKeyHex is not null)
Assert.Equal(expectedPubKeyHex, pubKey.ToHex()); Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
@@ -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<Core.Chain.ScriptKind> 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<Core.Chain.ChainProfile> 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 // helper: builds the branch for the given position
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position) private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
{ {
@@ -0,0 +1,84 @@
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Tests.Storage;
/// <summary>
/// Tests for the data-path resolution (§8), pinned to a temporary root via
/// <see cref="AppPaths.OverrideDataRoot"/> — 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.
/// </summary>
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));
}
}
@@ -77,6 +77,31 @@ public class StorageTests
Assert.False(EncryptedFile.IsEncrypted("non è json")); 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 ---- // ---- WalletDocument JSON ----
[Fact] [Fact]
@@ -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<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
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] [Fact]
public void Una_config_corrotta_torna_ai_default() public void Una_config_corrotta_torna_ai_default()
{ {
@@ -28,8 +28,8 @@ public class WalletLoaderTests
Assert.Equal("NativeSegwit", doc.ScriptKind); Assert.Equal("NativeSegwit", doc.ScriptKind);
Assert.Equal(ValidMnemonic, doc.Mnemonic); Assert.Equal(ValidMnemonic, doc.Mnemonic);
Assert.Null(doc.Passphrase); Assert.Null(doc.Passphrase);
Assert.NotEmpty(doc.AccountXpub); Assert.NotEmpty(doc.AccountXpub!);
Assert.NotEmpty(doc.MasterFingerprint); Assert.NotEmpty(doc.MasterFingerprint!);
Assert.False(doc.IsWatchOnly); Assert.False(doc.IsWatchOnly);
} }
@@ -66,7 +66,7 @@ public class WalletLoaderTests
var (doc, _) = WalletLoader.NewFromMnemonic( var (doc, _) = WalletLoader.NewFromMnemonic(
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Equal("mainnet", doc.Network); Assert.Equal("mainnet", doc.Network);
Assert.NotEmpty(doc.AccountXpub); Assert.NotEmpty(doc.AccountXpub!);
} }
[Fact] [Fact]