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:
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
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"));
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
||||
@@ -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]
|
||||
public void Una_config_corrotta_torna_ai_default()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user