From 47b6064964af272d41e7b042f5e89da0d2bc0c65 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 15 Jun 2026 14:20:10 +0200 Subject: [PATCH] feat(wallet): IWalletAccount abstraction and WIF/xpub/xprv keystore import types Introduce IWalletAccount interface to abstract HD, imported-xprv, WIF, and watch-only account types. HdAccount implements the interface; the new ImportedKeyAccount handles lists of WIF keys with fixed addresses (no gap scanning, change returns to first address). WalletLoader gains three factory methods: - NewFromXpub: watch-only from SLIP-132 xpub/ypub/zpub - NewFromXprv: spendable from SLIP-132 xprv/yprv/zprv - NewFromWif: spendable from one or more WIF private keys WalletDocument gains optional AccountXprv and WifKeys fields; AccountPath and AccountXpub become nullable (absent for WIF wallets). IsWatchOnly updated to cover all non-signing wallet kinds. TransactionFactory and CLI OpenWallet() updated to IWalletAccount. 12 new unit tests in ImportedKeyAccountTests cover factory round-trips, address derivation, and watch-only detection. --- src/Cli/Program.cs | 2 +- src/Core/Crypto/HdAccount.cs | 13 +- src/Core/Crypto/IWalletAccount.cs | 34 +++ src/Core/Crypto/ImportedKeyAccount.cs | 58 +++++ src/Core/Storage/WalletDocument.cs | 33 ++- src/Core/Wallet/TransactionFactory.cs | 5 +- src/Core/Wallet/WalletLoader.cs | 126 ++++++++++- .../Wallet/ImportedKeyAccountTests.cs | 203 ++++++++++++++++++ .../Wallet/WalletLoaderTests.cs | 3 +- 9 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 src/Core/Crypto/IWalletAccount.cs create mode 100644 src/Core/Crypto/ImportedKeyAccount.cs create mode 100644 tests/PalladiumWallet.Tests/Wallet/ImportedKeyAccountTests.cs diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 4eeac97..6754ca5 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -282,7 +282,7 @@ static int SaveWallet(string words, string[] o) return 0; } -static (WalletDocument, HdAccount, string) OpenWallet(string[] o) +static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o) { var path = WalletPath(o, Profile(o)); if (!WalletStore.Exists(path)) diff --git a/src/Core/Crypto/HdAccount.cs b/src/Core/Crypto/HdAccount.cs index 045d0ca..820fb27 100644 --- a/src/Core/Crypto/HdAccount.cs +++ b/src/Core/Crypto/HdAccount.cs @@ -11,7 +11,7 @@ namespace PalladiumWallet.Core.Crypto; /// quindi il watch-only funziona per costruzione. Il keystore completo /// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8). /// -public sealed class HdAccount +public sealed class HdAccount : IWalletAccount { private readonly ExtKey? _accountXprv; @@ -86,7 +86,7 @@ public sealed class HdAccount masterFingerprint ?? default); public BitcoinAddress GetAddress(bool isChange, int index) => - GetPublicKey(isChange, index).GetAddress( + GetPublicKey(isChange, index)!.GetAddress( DerivationPaths.ScriptPubKeyTypeFor(Kind), PalladiumNetworks.For(Profile.Kind)); @@ -94,9 +94,16 @@ public sealed class HdAccount public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index); - public PubKey GetPublicKey(bool isChange, int index) => + public PubKey? GetPublicKey(bool isChange, int index) => AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey; + /// Chiave privata di un indirizzo; null se watch-only (§17). + public Key? GetPrivateKey(bool isChange, int index) => + IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey; + + /// Gli account HD usano il gap limit: nessuna lista fissa di indirizzi. + public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null; + /// /// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna /// chiave privata è derivabile dalle sole pubbliche (§17). diff --git a/src/Core/Crypto/IWalletAccount.cs b/src/Core/Crypto/IWalletAccount.cs new file mode 100644 index 0000000..1a66057 --- /dev/null +++ b/src/Core/Crypto/IWalletAccount.cs @@ -0,0 +1,34 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Crypto; + +/// +/// Astrazione su tutti i tipi di account wallet (HD da seed, HD da xpub/xprv importata, +/// chiavi WIF importate). Consente a WalletSynchronizer e TransactionFactory di operare +/// indipendentemente dal tipo di keystore sottostante (blueprint §4.4–§4.5). +/// +public interface IWalletAccount +{ + ScriptKind Kind { get; } + ChainProfile Profile { get; } + + /// True se l'account non può firmare (assenza di chiavi private). + bool IsWatchOnly { get; } + + BitcoinAddress GetAddress(bool isChange, int index); + BitcoinAddress GetReceiveAddress(int index); + BitcoinAddress GetChangeAddress(int index); + + /// Null se la chiave pubblica non è ricavabile dall'account (indirizzi puri watch-only). + PubKey? GetPublicKey(bool isChange, int index); + + /// Null se l'account è watch-only o l'indice è fuori range. + Key? GetPrivateKey(bool isChange, int index); + + /// + /// Per gli account con indirizzi fissi (WIF importati) restituisce la lista + /// completa da scansionare; null per gli account HD che usano il gap limit. + /// + IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; } +} diff --git a/src/Core/Crypto/ImportedKeyAccount.cs b/src/Core/Crypto/ImportedKeyAccount.cs new file mode 100644 index 0000000..1f280cc --- /dev/null +++ b/src/Core/Crypto/ImportedKeyAccount.cs @@ -0,0 +1,58 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Crypto; + +/// +/// Account da chiavi WIF singole importate (blueprint §4.4 — "Imported"): +/// lista fissa di indirizzi, nessuna derivazione HD, nessuna catena di change. +/// Il change va sempre al primo indirizzo importato. +/// +public sealed class ImportedKeyAccount : IWalletAccount +{ + private readonly (BitcoinAddress Address, Key? PrivateKey)[] _entries; + + public ScriptKind Kind { get; } + public ChainProfile Profile { get; } + public bool IsWatchOnly => _entries.All(e => e.PrivateKey is null); + + public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => + _entries.Select((e, i) => (e.Address, false, i)).ToList(); + + public ImportedKeyAccount( + IReadOnlyList<(BitcoinAddress Address, Key? PrivateKey)> entries, + ScriptKind kind, ChainProfile profile) + { + if (entries.Count == 0) + throw new ArgumentException("Almeno un indirizzo richiesto.", nameof(entries)); + _entries = [.. entries]; + Kind = kind; + Profile = profile; + } + + public BitcoinAddress GetAddress(bool isChange, int index) + { + if (isChange || index < 0 || index >= _entries.Length) + return _entries[0].Address; + return _entries[index].Address; + } + + public BitcoinAddress GetReceiveAddress(int index) => GetAddress(false, index); + + /// Il change torna sempre al primo indirizzo importato. + public BitcoinAddress GetChangeAddress(int index) => _entries[0].Address; + + public PubKey? GetPublicKey(bool isChange, int index) + { + if (isChange || index < 0 || index >= _entries.Length) + return null; + return _entries[index].PrivateKey?.PubKey; + } + + public Key? GetPrivateKey(bool isChange, int index) + { + if (isChange || index < 0 || index >= _entries.Length) + return null; + return _entries[index].PrivateKey; + } +} diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index 984df42..67a63bd 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -26,14 +26,20 @@ public sealed class WalletDocument /// Extension word BIP39 (§4.1); null se assente. public string? Passphrase { get; set; } - /// Path di account (es. "84'/746'/0'"). - public required string AccountPath { get; set; } + /// Path di account (es. "84'/746'/0'"); null per importati WIF. + public string? AccountPath { get; set; } - /// Xpub di account in SLIP-132: basta da sola per il watch-only. - public required string AccountXpub { get; set; } + /// Xpub di account in SLIP-132 per i wallet HD; null per importati WIF. + public string? AccountXpub { get; set; } + + /// Xprv di account in SLIP-132; presente solo per import da xprv senza seed. + public string? AccountXprv { get; set; } public string? MasterFingerprint { get; set; } + /// Chiavi WIF importate (in chiaro nel documento — va cifrato!). + public List? WifKeys { get; set; } + /// Gap limit per la scansione indirizzi (§5), configurabile. public int GapLimit { get; set; } = 20; @@ -46,7 +52,10 @@ public sealed class WalletDocument /// Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline). public SyncCache? Cache { get; set; } - public bool IsWatchOnly => Mnemonic is null; + public bool IsWatchOnly => + Mnemonic is null && + AccountXprv is null && + (WifKeys is null || WifKeys.Count == 0); private static readonly JsonSerializerOptions JsonOptions = new() { @@ -90,6 +99,20 @@ public sealed class SyncCache public List History { get; set; } = []; public List Utxos { get; set; } = []; public List Addresses { get; set; } = []; + + /// + /// Cache raw delle transazioni confermate (txid → hex). Evita di riscaricale + /// ad ogni avvio dell'app: le tx confermate sono immutabili per definizione. + /// Popolata anche parzialmente in caso di sync interrotta (es. -101): + /// il synchronizer riprende dal punto in cui si era fermato. + /// + public Dictionary? RawTxHex { get; set; } + + /// + /// Prove di Merkle già verificate (txid → altezza blocco). Evita di + /// riverificare le stesse prove ad ogni avvio: le conferme sono immutabili. + /// + public Dictionary? VerifiedAt { get; set; } } /// Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi). diff --git a/src/Core/Wallet/TransactionFactory.cs b/src/Core/Wallet/TransactionFactory.cs index 67217ab..b22241d 100644 --- a/src/Core/Wallet/TransactionFactory.cs +++ b/src/Core/Wallet/TransactionFactory.cs @@ -27,7 +27,7 @@ public sealed class BuiltTransaction /// invia-tutto con fee sottratta, change sulla catena interna, RBF di default. /// Con un account watch-only produce la PSBT non firmata (§6.5). /// -public sealed class TransactionFactory(HdAccount account) +public sealed class TransactionFactory(IWalletAccount account) { private Network Network => PalladiumNetworks.For(account.Profile.Kind); @@ -82,7 +82,8 @@ public sealed class TransactionFactory(HdAccount account) if (!account.IsWatchOnly) { builder.AddKeys(spendable - .Select(u => account.GetExtPrivateKey(u.IsChange, u.AddressIndex)) + .Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex)) + .OfType() .ToArray()); } diff --git a/src/Core/Wallet/WalletLoader.cs b/src/Core/Wallet/WalletLoader.cs index 2395b83..4c8ee2f 100644 --- a/src/Core/Wallet/WalletLoader.cs +++ b/src/Core/Wallet/WalletLoader.cs @@ -6,31 +6,58 @@ using PalladiumWallet.Core.Storage; namespace PalladiumWallet.Core.Wallet; /// -/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce -/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della -/// factory dei tipi di wallet; crescerà con multisig e importati. +/// Factory dei tipi di wallet (blueprint §4.5): dal documento ricostruisce il tipo +/// di account corretto (HD da seed, HD da xprv importata, importato WIF, watch-only). /// public static class WalletLoader { public static ChainProfile ProfileOf(WalletDocument doc) => ChainProfiles.For(Enum.Parse(doc.Network, ignoreCase: true)); - public static HdAccount ToAccount(WalletDocument doc) + public static IWalletAccount ToAccount(WalletDocument doc) { var profile = ProfileOf(doc); var kind = Enum.Parse(doc.ScriptKind); - var path = KeyPath.Parse(doc.AccountPath); + var network = PalladiumNetworks.For(profile.Kind); + // 1. HD da seed (caso più comune) if (doc.Mnemonic is { } words) { if (!Bip39.TryParse(words, out var mnemonic)) throw new InvalidDataException("Mnemonica del file wallet non valida."); + var path = KeyPath.Parse(doc.AccountPath ?? DerivationPaths.AccountPath(kind, profile).ToString()); return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path); } + // 2. HD da xprv importata (spendibile, senza seed) + if (doc.AccountXprv is { } xprvStr) + { + if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _)) + throw new InvalidDataException("Xprv del file wallet non valida per questa rete."); + var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null; + return HdAccount.FromAccountXprv(xprv!, kind, profile, path); + } + + // 3. Chiavi WIF importate + if (doc.WifKeys is { Count: > 0 } wifKeys) + { + var entries = wifKeys.Select(wif => + { + var secret = new BitcoinSecret(wif, network); + var addr = secret.PrivateKey.PubKey + .GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network); + return (addr, (Key?)secret.PrivateKey); + }).ToList(); + return new ImportedKeyAccount(entries, kind, profile); + } + + // 4. Watch-only da xpub + if (doc.AccountXpub is null) + throw new InvalidDataException("File wallet senza xpub e senza seed."); if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _)) throw new InvalidDataException("Xpub del file wallet non valida per questa rete."); - return HdAccount.FromAccountXpub(xpub!, kind, profile, path); + var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null; + return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath); } /// Crea il documento per un nuovo wallet da seed. @@ -56,4 +83,91 @@ public static class WalletLoader }; return (doc, account); } + + /// + /// Crea il documento da una xpub SLIP-132 importata (watch-only). + /// Rileva il ScriptKind dagli header SLIP-132; lo sovrascrive + /// per i prefissi ambigui (xpub può essere Legacy o Taproot). + /// + public static (WalletDocument Doc, HdAccount Account) NewFromXpub( + string slip132, ChainProfile profile, ScriptKind? kindOverride = null) + { + if (!Slip132.TryDecodePublic(slip132.Trim(), profile, out var xpub, out var detectedKind)) + throw new InvalidDataException("Chiave pubblica estesa non valida o non riconosciuta per questa rete."); + var kind = kindOverride ?? detectedKind; + var account = HdAccount.FromAccountXpub(xpub!, kind, profile); + + var doc = new WalletDocument + { + Network = profile.NetName, + ScriptKind = kind.ToString(), + AccountPath = account.AccountPath.ToString(), + AccountXpub = slip132.Trim(), + }; + return (doc, account); + } + + /// + /// Crea il documento da una xprv SLIP-132 importata (spendibile senza seed). + /// Il documento contiene la xprv in chiaro: deve essere obbligatoriamente cifrato. + /// + public static (WalletDocument Doc, HdAccount Account) NewFromXprv( + string slip132, ChainProfile profile, ScriptKind? kindOverride = null) + { + if (!Slip132.TryDecodePrivate(slip132.Trim(), profile, out var xprv, out var detectedKind)) + throw new InvalidDataException("Chiave privata estesa non valida o non riconosciuta per questa rete."); + var kind = kindOverride ?? detectedKind; + var account = HdAccount.FromAccountXprv(xprv!, kind, profile); + + var doc = new WalletDocument + { + Network = profile.NetName, + ScriptKind = kind.ToString(), + AccountPath = account.AccountPath.ToString(), + AccountXpub = account.ToSlip132(), + AccountXprv = slip132.Trim(), + }; + return (doc, account); + } + + /// + /// Crea il documento da una o più chiavi WIF importate. + /// Il documento contiene le chiavi WIF in chiaro: deve essere obbligatoriamente cifrato. + /// + public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif( + IReadOnlyList wifKeys, ScriptKind kind, ChainProfile profile) + { + if (wifKeys.Count == 0) + throw new InvalidDataException("Almeno una chiave WIF richiesta."); + + var network = PalladiumNetworks.For(profile.Kind); + var entries = new List<(BitcoinAddress, Key?)>(); + var wifStrings = new List(); + + foreach (var raw in wifKeys) + { + BitcoinSecret secret; + try + { + secret = new BitcoinSecret(raw.Trim(), network); + } + catch (Exception ex) + { + throw new InvalidDataException($"Chiave WIF non valida: {ex.Message}"); + } + var addr = secret.PrivateKey.PubKey + .GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network); + entries.Add((addr, secret.PrivateKey)); + wifStrings.Add(raw.Trim()); + } + + var account = new ImportedKeyAccount(entries, kind, profile); + var doc = new WalletDocument + { + Network = profile.NetName, + ScriptKind = kind.ToString(), + WifKeys = wifStrings, + }; + return (doc, account); + } } diff --git a/tests/PalladiumWallet.Tests/Wallet/ImportedKeyAccountTests.cs b/tests/PalladiumWallet.Tests/Wallet/ImportedKeyAccountTests.cs new file mode 100644 index 0000000..9df0de3 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Wallet/ImportedKeyAccountTests.cs @@ -0,0 +1,203 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.Tests.Wallet; + +/// +/// Test per ImportedKeyAccount e i nuovi percorsi factory in WalletLoader +/// (blueprint §4.4 — importati WIF, xpub, xprv). +/// +public class ImportedKeyAccountTests +{ + private static ChainProfile Profile => ChainProfiles.Mainnet; + private static Network Network => PalladiumNetworks.For(Profile.Kind); + + // Chiave WIF valida per PLM mainnet (prefix 0x80 = Compressed WIF "K"/"L") + private static Key GenerateKey() => new Key(); + + private static string ToWif(Key key) => key.GetWif(Network).ToString(); + + // ---- ImportedKeyAccount ---- + + [Fact] + public void Account_importato_non_e_hd() + { + var key = GenerateKey(); + var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + var account = new ImportedKeyAccount( + [(addr, key)], ScriptKind.NativeSegwit, Profile); + + Assert.False(account.IsWatchOnly); + Assert.Equal(ScriptKind.NativeSegwit, account.Kind); + Assert.NotNull(account.FixedAddresses); + } + + [Fact] + public void GetAddress_restituisce_indirizzo_corretto() + { + var key = GenerateKey(); + var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile); + + Assert.Equal(addr.ToString(), account.GetAddress(false, 0).ToString()); + Assert.Equal(addr.ToString(), account.GetReceiveAddress(0).ToString()); + // Change → primo indirizzo + Assert.Equal(addr.ToString(), account.GetChangeAddress(0).ToString()); + } + + [Fact] + public void GetPrivateKey_restituisce_chiave_corretta() + { + var key = GenerateKey(); + var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile); + + var retrieved = account.GetPrivateKey(false, 0); + Assert.NotNull(retrieved); + Assert.Equal(key.ToBytes(), retrieved!.ToBytes()); + } + + [Fact] + public void GetPrivateKey_fuori_range_restituisce_null() + { + var key = GenerateKey(); + var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile); + + Assert.Null(account.GetPrivateKey(false, 99)); + Assert.Null(account.GetPrivateKey(true, 0)); + } + + [Fact] + public void Account_watch_only_se_nessuna_chiave_privata() + { + var key = GenerateKey(); + var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network); + var account = new ImportedKeyAccount( + [(addr, (Key?)null)], ScriptKind.NativeSegwit, Profile); + + Assert.True(account.IsWatchOnly); + Assert.Null(account.GetPrivateKey(false, 0)); + } + + [Fact] + public void FixedAddresses_copre_tutti_gli_indirizzi() + { + var keys = Enumerable.Range(0, 3).Select(_ => GenerateKey()).ToList(); + var entries = keys + .Select(k => (k.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network), (Key?)k)) + .ToList(); + var account = new ImportedKeyAccount(entries, ScriptKind.NativeSegwit, Profile); + + var fixed_ = account.FixedAddresses!; + Assert.Equal(3, fixed_.Count); + for (var i = 0; i < 3; i++) + { + Assert.Equal(entries[i].Item1.ToString(), fixed_[i].Address.ToString()); + Assert.False(fixed_[i].IsChange); + Assert.Equal(i, fixed_[i].Index); + } + } + + // ---- WalletLoader.NewFromWif ---- + + [Fact] + public void NewFromWif_crea_account_con_indirizzi_corretti() + { + var key = GenerateKey(); + var wif = ToWif(key); + var (doc, account) = WalletLoader.NewFromWif([wif], ScriptKind.NativeSegwit, Profile); + + Assert.Null(doc.Mnemonic); + Assert.NotNull(doc.WifKeys); + Assert.Single(doc.WifKeys!); + Assert.False(doc.IsWatchOnly); + Assert.Equal(ScriptKind.NativeSegwit.ToString(), doc.ScriptKind); + + var expected = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network).ToString(); + Assert.Equal(expected, account.GetReceiveAddress(0).ToString()); + } + + [Fact] + public void NewFromWif_roundtrip_persiste_e_ricarica() + { + var key = GenerateKey(); + var wif = ToWif(key); + var (doc, original) = WalletLoader.NewFromWif([wif], ScriptKind.NativeSegwit, Profile); + + var reloaded = WalletLoader.ToAccount(doc); + Assert.IsType(reloaded); + Assert.Equal( + original.GetReceiveAddress(0).ToString(), + reloaded.GetReceiveAddress(0).ToString()); + Assert.False(reloaded.IsWatchOnly); + } + + [Fact] + public void NewFromWif_wif_invalido_lancia_eccezione() + { + Assert.Throws( + () => WalletLoader.NewFromWif(["not-a-wif"], ScriptKind.NativeSegwit, Profile)); + } + + // ---- WalletLoader.NewFromXpub ---- + + [Fact] + public void NewFromXpub_produce_account_watch_only() + { + // Crea un HD account, esporta la zpub, reimporta come xpub watch-only. + var (_, hdFull) = WalletLoader.NewFromMnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + null, ScriptKind.NativeSegwit, Profile); + + var zpub = hdFull.ToSlip132(); + var (doc, woAccount) = WalletLoader.NewFromXpub(zpub, Profile); + + Assert.True(woAccount.IsWatchOnly); + Assert.Null(doc.Mnemonic); + Assert.Equal( + hdFull.GetReceiveAddress(0).ToString(), + woAccount.GetReceiveAddress(0).ToString()); + } + + // ---- WalletLoader.NewFromXprv ---- + + [Fact] + public void NewFromXprv_produce_account_spendibile() + { + var (_, hdFull) = WalletLoader.NewFromMnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + null, ScriptKind.NativeSegwit, Profile); + + var zprv = hdFull.ToSlip132Private(); + var (doc, xprvAccount) = WalletLoader.NewFromXprv(zprv, Profile); + + Assert.False(xprvAccount.IsWatchOnly); + Assert.NotNull(doc.AccountXprv); + Assert.Null(doc.Mnemonic); + Assert.Equal( + hdFull.GetReceiveAddress(0).ToString(), + xprvAccount.GetReceiveAddress(0).ToString()); + Assert.NotNull(xprvAccount.GetPrivateKey(false, 0)); + } + + [Fact] + public void NewFromXprv_roundtrip_persiste_e_ricarica() + { + var (_, hdFull) = WalletLoader.NewFromMnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + null, ScriptKind.NativeSegwit, Profile); + + var zprv = hdFull.ToSlip132Private(); + var (doc, _) = WalletLoader.NewFromXprv(zprv, Profile); + + var reloaded = WalletLoader.ToAccount(doc); + Assert.IsType(reloaded); + Assert.False(reloaded.IsWatchOnly); + Assert.Equal( + hdFull.GetReceiveAddress(0).ToString(), + reloaded.GetReceiveAddress(0).ToString()); + } +} diff --git a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs index a51c3d1..ff484a7 100644 --- a/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/WalletLoaderTests.cs @@ -153,7 +153,8 @@ public class WalletLoaderTests AccountXpub = doc.AccountXpub, }; var account = WalletLoader.ToAccount(docWo); - Assert.Throws(() => account.GetExtPrivateKey(false, 0)); + Assert.True(account.IsWatchOnly); + Assert.Null(account.GetPrivateKey(false, 0)); } [Fact]