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.
This commit is contained in:
+1
-1
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Chiave privata di un indirizzo; null se watch-only (§17).</summary>
|
||||
public Key? GetPrivateKey(bool isChange, int index) =>
|
||||
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
||||
|
||||
/// <summary>Gli account HD usano il gap limit: nessuna lista fissa di indirizzi.</summary>
|
||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
||||
|
||||
/// <summary>
|
||||
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
||||
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public interface IWalletAccount
|
||||
{
|
||||
ScriptKind Kind { get; }
|
||||
ChainProfile Profile { get; }
|
||||
|
||||
/// <summary>True se l'account non può firmare (assenza di chiavi private).</summary>
|
||||
bool IsWatchOnly { get; }
|
||||
|
||||
BitcoinAddress GetAddress(bool isChange, int index);
|
||||
BitcoinAddress GetReceiveAddress(int index);
|
||||
BitcoinAddress GetChangeAddress(int index);
|
||||
|
||||
/// <summary>Null se la chiave pubblica non è ricavabile dall'account (indirizzi puri watch-only).</summary>
|
||||
PubKey? GetPublicKey(bool isChange, int index);
|
||||
|
||||
/// <summary>Null se l'account è watch-only o l'indice è fuori range.</summary>
|
||||
Key? GetPrivateKey(bool isChange, int index);
|
||||
|
||||
/// <summary>
|
||||
/// Per gli account con indirizzi fissi (WIF importati) restituisce la lista
|
||||
/// completa da scansionare; null per gli account HD che usano il gap limit.
|
||||
/// </summary>
|
||||
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Il change torna sempre al primo indirizzo importato.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,20 @@ public sealed class WalletDocument
|
||||
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
||||
public string? Passphrase { get; set; }
|
||||
|
||||
/// <summary>Path di account (es. "84'/746'/0'").</summary>
|
||||
public required string AccountPath { get; set; }
|
||||
/// <summary>Path di account (es. "84'/746'/0'"); null per importati WIF.</summary>
|
||||
public string? AccountPath { get; set; }
|
||||
|
||||
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
|
||||
public required string AccountXpub { get; set; }
|
||||
/// <summary>Xpub di account in SLIP-132 per i wallet HD; null per importati WIF.</summary>
|
||||
public string? AccountXpub { get; set; }
|
||||
|
||||
/// <summary>Xprv di account in SLIP-132; presente solo per import da xprv senza seed.</summary>
|
||||
public string? AccountXprv { get; set; }
|
||||
|
||||
public string? MasterFingerprint { get; set; }
|
||||
|
||||
/// <summary>Chiavi WIF importate (in chiaro nel documento — va cifrato!).</summary>
|
||||
public List<string>? WifKeys { get; set; }
|
||||
|
||||
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
||||
public int GapLimit { get; set; } = 20;
|
||||
|
||||
@@ -46,7 +52,10 @@ public sealed class WalletDocument
|
||||
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
||||
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<CachedTx> History { get; set; } = [];
|
||||
public List<CachedUtxo> Utxos { get; set; } = [];
|
||||
public List<CachedAddress> Addresses { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? RawTxHex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prove di Merkle già verificate (txid → altezza blocco). Evita di
|
||||
/// riverificare le stesse prove ad ogni avvio: le conferme sono immutabili.
|
||||
/// </summary>
|
||||
public Dictionary<string, int>? VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
||||
|
||||
@@ -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).
|
||||
/// </summary>
|
||||
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<Key>()
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,31 +6,58 @@ using PalladiumWallet.Core.Storage;
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static class WalletLoader
|
||||
{
|
||||
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
||||
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
||||
|
||||
public static HdAccount ToAccount(WalletDocument doc)
|
||||
public static IWalletAccount ToAccount(WalletDocument doc)
|
||||
{
|
||||
var profile = ProfileOf(doc);
|
||||
var kind = Enum.Parse<ScriptKind>(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);
|
||||
}
|
||||
|
||||
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
||||
@@ -56,4 +83,91 @@ public static class WalletLoader
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea il documento da una xpub SLIP-132 importata (watch-only).
|
||||
/// Rileva il ScriptKind dagli header SLIP-132; <paramref name="kindOverride"/> lo sovrascrive
|
||||
/// per i prefissi ambigui (xpub può essere Legacy o Taproot).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea il documento da una xprv SLIP-132 importata (spendibile senza seed).
|
||||
/// Il documento contiene la xprv in chiaro: deve essere obbligatoriamente cifrato.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea il documento da una o più chiavi WIF importate.
|
||||
/// Il documento contiene le chiavi WIF in chiaro: deve essere obbligatoriamente cifrato.
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
||||
IReadOnlyList<string> 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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Tests.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Test per ImportedKeyAccount e i nuovi percorsi factory in WalletLoader
|
||||
/// (blueprint §4.4 — importati WIF, xpub, xprv).
|
||||
/// </summary>
|
||||
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<ImportedKeyAccount>(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<InvalidDataException>(
|
||||
() => 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<HdAccount>(reloaded);
|
||||
Assert.False(reloaded.IsWatchOnly);
|
||||
Assert.Equal(
|
||||
hdFull.GetReceiveAddress(0).ToString(),
|
||||
reloaded.GetReceiveAddress(0).ToString());
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,8 @@ public class WalletLoaderTests
|
||||
AccountXpub = doc.AccountXpub,
|
||||
};
|
||||
var account = WalletLoader.ToAccount(docWo);
|
||||
Assert.Throws<InvalidOperationException>(() => account.GetExtPrivateKey(false, 0));
|
||||
Assert.True(account.IsWatchOnly);
|
||||
Assert.Null(account.GetPrivateKey(false, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user