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]