using NBitcoin; using PalladiumWallet.Core.Chain; namespace PalladiumWallet.Core.Crypto; /// /// Single-sig HD account (blueprint §4.2/§4.4): extended account key (xprv, /// or xpub-only = watch-only) + script type + network profile. Derives receiving /// (change=0) and change (change=1) addresses on-demand by index. /// Addresses are always derived from the account xpub (non-hardened sub-path), /// so watch-only works by construction. The full keystore /// (encryption, factory from wallet file, §4.5) arrives with persistence (§8). /// public sealed class HdAccount : IWalletAccount { private readonly ExtKey? _accountXprv; public ScriptKind Kind { get; } public ChainProfile Profile { get; } /// Account path relative to the root (e.g. 84'/746'/0', or custom). public KeyPath AccountPath { get; } /// /// Master key fingerprint: together with forms the /// origin info required by PSBTs (§6.5). Zero if unknown (imported xpub /// without metadata). /// public HDFingerprint MasterFingerprint { get; } public ExtPubKey AccountXpub { get; } /// True if the account only knows the xpub: can build but not sign (§4.5). public bool IsWatchOnly => _accountXprv is null; private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind, ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint) { // Validate the mapping immediately (multisig types are not supported here). DerivationPaths.ScriptPubKeyTypeFor(kind); _accountXprv = accountXprv; AccountXpub = accountXpub; Kind = kind; Profile = profile; AccountPath = accountPath; MasterFingerprint = masterFingerprint; } /// Main case: BIP39 mnemonic (+ optional passphrase) → standard account. public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase, ScriptKind kind, ChainProfile profile, int account = 0) => FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account); public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, int account = 0) => FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account)); /// /// Import with a custom derivation path (§4.2, Sparrow-like): /// only determines the generated address type. /// public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath) { var root = ExtKey.CreateFromSeed(seed); // The account is always derived from the xprv: hardened path levels // cannot be derived from an xpub alone. var accountXprv = root.Derive(accountPath); return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile, accountPath, root.Neuter().PubKey.GetHDFingerPrint()); } /// /// Watch-only from account xpub (§4.4): fingerprint and path are known only /// if supplied by the importer (required for PSBT origin info). /// public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind, ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) => new(null, accountXpub, kind, profile, accountPath ?? DerivationPaths.AccountPath(kind, profile), masterFingerprint ?? default); /// Spendable import from account xprv. public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind, ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) => new(accountXprv, accountXprv.Neuter(), kind, profile, accountPath ?? DerivationPaths.AccountPath(kind, profile), masterFingerprint ?? default); public BitcoinAddress GetAddress(bool isChange, int index) => GetPublicKey(isChange, index)!.GetAddress( DerivationPaths.ScriptPubKeyTypeFor(Kind), PalladiumNetworks.For(Profile.Kind)); public BitcoinAddress GetReceiveAddress(int index) => GetAddress(isChange: false, index); public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index); public PubKey? GetPublicKey(bool isChange, int index) => AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey; /// Private key for an address; null if watch-only (§17). public Key? GetPrivateKey(bool isChange, int index) => IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey; /// HD accounts use the gap limit: no fixed address list. public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null; /// /// Extended private key for an address. Throws if watch-only: no private key /// can be derived from public keys alone (§17). /// public ExtKey GetExtPrivateKey(bool isChange, int index) => (_accountXprv ?? throw new InvalidOperationException("Watch-only account: cannot sign.")) .Derive(DerivationPaths.AddressSubPath(isChange, index)); /// Account xpub in SLIP-132 format (xpub/ypub/zpub according to Kind). public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile); /// Account xprv in SLIP-132 format. Throws if watch-only. public string ToSlip132Private() => Slip132.Encode( _accountXprv ?? throw new InvalidOperationException("Watch-only account: no private key."), Kind, Profile); }