diff --git a/src/Core/Crypto/Bip39.cs b/src/Core/Crypto/Bip39.cs
new file mode 100644
index 0000000..d20b62f
--- /dev/null
+++ b/src/Core/Crypto/Bip39.cs
@@ -0,0 +1,106 @@
+using NBitcoin;
+
+namespace PalladiumWallet.Core.Crypto;
+
+///
+/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate
+/// in NBitcoin: nessun accesso a rete o filesystem.
+///
+public enum MnemonicLanguage
+{
+ English,
+ Spanish,
+ Japanese,
+ PortugueseBrazil,
+ ChineseSimplified,
+ ChineseTraditional,
+ French,
+ Czech,
+}
+
+/// Numero di parole supportato in creazione (blueprint §4.1: 12 o 24).
+public enum MnemonicLength
+{
+ Twelve = 12,
+ TwentyFour = 24,
+}
+
+///
+/// Facciata BIP39 su NBitcoin.Mnemonic (blueprint §4.1). NBitcoin copre già
+/// entropia→parole, checksum, normalizzazione NFKD e PBKDF2-HMAC-SHA512 a 2048
+/// round con salt "mnemonic"+passphrase: qui si restringe l'API ai casi del
+/// blueprint e si centralizza la mappa delle lingue. Quando arriverà il seed
+/// nativo versionato (§4.1 punto 1), il riconoscimento multi-schema sarà una
+/// catena di TryParse per schema.
+///
+public static class Bip39
+{
+ /// Genera una nuova mnemonica con entropia dal CSPRNG di sistema.
+ public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English)
+ {
+ var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
+ return new Mnemonic(ToWordlist(language), wordCount);
+ }
+
+ ///
+ /// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla
+ /// wordlist e checksum. Se è indicata viene provata
+ /// per prima; altrimenti la lingua è auto-rilevata (parole condivise tra liste
+ /// possono rendere ambiguo l'autodetect: in import l'utente può forzarla).
+ ///
+ public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
+ {
+ mnemonic = null;
+ if (string.IsNullOrWhiteSpace(text))
+ return false;
+
+ // Solo trim esterno: la normalizzazione NFKD e lo spazio ideografico
+ // giapponese li gestisce NBitcoin, il testo non va alterato (§4.1).
+ text = text.Trim();
+
+ IEnumerable candidates = language is not null
+ ? [ToWordlist(language.Value)]
+ : [Wordlist.AutoDetect(text)];
+
+ foreach (var wordlist in candidates)
+ {
+ try
+ {
+ var parsed = new Mnemonic(text, wordlist);
+ // Il costruttore NON verifica il checksum: controllo esplicito obbligatorio.
+ if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
+ {
+ mnemonic = parsed;
+ return true;
+ }
+ }
+ catch (FormatException)
+ {
+ // Parole fuori wordlist o conteggio non valido: si prova oltre.
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Mnemonica → seed di 64 byte (PBKDF2-HMAC-SHA512, 2048 round, salt
+ /// "mnemonic"+passphrase). La passphrase cambia completamente il wallet
+ /// derivato: avvisi UI obbligatori (§4.1).
+ ///
+ public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
+ mnemonic.DeriveSeed(passphrase);
+
+ internal static Wordlist ToWordlist(MnemonicLanguage language) => language switch
+ {
+ MnemonicLanguage.English => Wordlist.English,
+ MnemonicLanguage.Spanish => Wordlist.Spanish,
+ MnemonicLanguage.Japanese => Wordlist.Japanese,
+ MnemonicLanguage.PortugueseBrazil => Wordlist.PortugueseBrazil,
+ MnemonicLanguage.ChineseSimplified => Wordlist.ChineseSimplified,
+ MnemonicLanguage.ChineseTraditional => Wordlist.ChineseTraditional,
+ MnemonicLanguage.French => Wordlist.French,
+ MnemonicLanguage.Czech => Wordlist.Czech,
+ _ => throw new ArgumentOutOfRangeException(nameof(language)),
+ };
+}
diff --git a/src/Core/Crypto/DerivationPaths.cs b/src/Core/Crypto/DerivationPaths.cs
new file mode 100644
index 0000000..40731bb
--- /dev/null
+++ b/src/Core/Crypto/DerivationPaths.cs
@@ -0,0 +1,89 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+
+namespace PalladiumWallet.Core.Crypto;
+
+///
+/// Costruzione dei derivation path BIP44/49/84 (blueprint §4.2) e mappatura
+/// ScriptKind → purpose / ScriptPubKeyType. I purpose sono costanti di protocollo
+/// BIP (chain-independent); il coin_type viene sempre dal ChainProfile.
+///
+public static class DerivationPaths
+{
+ /// Purpose BIP44 (P2PKH legacy).
+ public const int PurposeLegacy = 44;
+
+ /// Purpose BIP49 (P2SH-P2WPKH).
+ public const int PurposeWrappedSegwit = 49;
+
+ /// Purpose BIP84 (P2WPKH nativo).
+ public const int PurposeNativeSegwit = 84;
+
+ /// Purpose BIP48 (multisig — fase successiva, §16 passo 8).
+ public const int PurposeMultisig = 48;
+
+ public static int PurposeFor(ScriptKind kind) => kind switch
+ {
+ ScriptKind.Legacy => PurposeLegacy,
+ ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
+ ScriptKind.NativeSegwit => PurposeNativeSegwit,
+ ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
+ _ => throw new ArgumentOutOfRangeException(nameof(kind)),
+ };
+
+ ///
+ /// Tipo di scriptPubKey NBitcoin per la derivazione da pubkey singola.
+ /// I tipi multisig derivano da redeem script, non da una pubkey: arriveranno
+ /// con i wallet M-di-N (§4.5).
+ ///
+ public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
+ {
+ ScriptKind.Legacy => ScriptPubKeyType.Legacy,
+ ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
+ ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
+ ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
+ throw new NotSupportedException(
+ "I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
+ _ => throw new ArgumentOutOfRangeException(nameof(kind)),
+ };
+
+ ///
+ /// Path di account relativo alla root: purpose'/coin'/account' (§4.2).
+ /// Il coin_type è quello del profilo (746 mainnet, 1 testnet).
+ ///
+ public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
+ {
+ if (account < 0)
+ throw new ArgumentOutOfRangeException(nameof(account));
+ return new KeyPath(
+ $"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'");
+ }
+
+ /// Sottopath non-hardened change/index sotto l'account (change=0 receiving, change=1 change).
+ public static KeyPath AddressSubPath(bool isChange, int index)
+ {
+ if (index < 0)
+ throw new ArgumentOutOfRangeException(nameof(index));
+ return new KeyPath($"{(isChange ? 1 : 0)}/{index}");
+ }
+
+ ///
+ /// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2):
+ /// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h.
+ ///
+ public static bool TryParse(string path, out KeyPath? keyPath)
+ {
+ keyPath = null;
+ if (string.IsNullOrWhiteSpace(path))
+ return false;
+ try
+ {
+ keyPath = KeyPath.Parse(path.Trim());
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/Core/Crypto/HdAccount.cs b/src/Core/Crypto/HdAccount.cs
new file mode 100644
index 0000000..045d0ca
--- /dev/null
+++ b/src/Core/Crypto/HdAccount.cs
@@ -0,0 +1,116 @@
+using NBitcoin;
+using PalladiumWallet.Core.Chain;
+
+namespace PalladiumWallet.Core.Crypto;
+
+///
+/// Account HD single-sig (blueprint §4.2/§4.4): chiave estesa di account (xprv,
+/// oppure solo xpub = watch-only) + tipo di script + profilo di rete. Deriva
+/// indirizzi receiving (change=0) e change (change=1) on-demand per indice.
+/// Gli indirizzi si derivano sempre dall'xpub di account (sottopath non-hardened),
+/// 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
+{
+ private readonly ExtKey? _accountXprv;
+
+ public ScriptKind Kind { get; }
+ public ChainProfile Profile { get; }
+
+ /// Path dell'account relativo alla root (es. 84'/746'/0', o personalizzato).
+ public KeyPath AccountPath { get; }
+
+ ///
+ /// Fingerprint della chiave master: con forma le
+ /// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata
+ /// senza metadati).
+ ///
+ public HDFingerprint MasterFingerprint { get; }
+
+ public ExtPubKey AccountXpub { get; }
+
+ /// True se l'account conosce solo la xpub: costruisce ma non firma (§4.5).
+ public bool IsWatchOnly => _accountXprv is null;
+
+ private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
+ ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint)
+ {
+ // Valida subito la mappatura (i tipi multisig non sono supportati qui).
+ DerivationPaths.ScriptPubKeyTypeFor(kind);
+ _accountXprv = accountXprv;
+ AccountXpub = accountXpub;
+ Kind = kind;
+ Profile = profile;
+ AccountPath = accountPath;
+ MasterFingerprint = masterFingerprint;
+ }
+
+ /// Caso principale: mnemonica BIP39 (+ passphrase opzionale) → account standard.
+ 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 con derivation path personalizzato (§4.2, Sparrow-like):
+ /// determina solo il tipo di indirizzo generato.
+ ///
+ public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
+ {
+ var root = ExtKey.CreateFromSeed(seed);
+ // L'account si deriva sempre dalla xprv: i livelli hardened del path
+ // non sono derivabili da una xpub.
+ var accountXprv = root.Derive(accountPath);
+ return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
+ accountPath, root.Neuter().PubKey.GetHDFingerPrint());
+ }
+
+ ///
+ /// Watch-only da xpub di account (§4.4): fingerprint e path sono noti solo
+ /// se forniti da chi importa (servono per le origin info PSBT).
+ ///
+ 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);
+
+ /// Import spendibile da xprv di account.
+ 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;
+
+ ///
+ /// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
+ /// chiave privata è derivabile dalle sole pubbliche (§17).
+ ///
+ public ExtKey GetExtPrivateKey(bool isChange, int index) =>
+ (_accountXprv ?? throw new InvalidOperationException("Account watch-only: non può firmare."))
+ .Derive(DerivationPaths.AddressSubPath(isChange, index));
+
+ /// Xpub di account in formato SLIP-132 (xpub/ypub/zpub secondo Kind).
+ public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
+
+ /// Xprv di account in formato SLIP-132. Lancia se watch-only.
+ public string ToSlip132Private() =>
+ Slip132.Encode(
+ _accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."),
+ Kind, Profile);
+}
diff --git a/src/Core/Crypto/Slip132.cs b/src/Core/Crypto/Slip132.cs
new file mode 100644
index 0000000..97bdd35
--- /dev/null
+++ b/src/Core/Crypto/Slip132.cs
@@ -0,0 +1,96 @@
+using NBitcoin;
+using NBitcoin.DataEncoders;
+using PalladiumWallet.Core.Chain;
+
+namespace PalladiumWallet.Core.Crypto;
+
+///
+/// Serializzazione e parsing SLIP-132 delle chiavi estese (xprv/yprv/zprv e
+/// varianti pubbliche) con gli header di
+/// (blueprint §3). NBitcoin serializza con i soli header registrati nella Network
+/// (xprv/xpub): le altre varianti si compongono qui come
+/// Base58Check( header 4 byte BE || payload BIP32 74 byte ).
+///
+public static class Slip132
+{
+ public static string Encode(ExtPubKey key, ScriptKind kind, ChainProfile profile) =>
+ Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PublicBytes(), .. key.ToBytes()]);
+
+ public static string Encode(ExtKey key, ScriptKind kind, ChainProfile profile) =>
+ Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
+
+ ///
+ /// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa:
+ /// è la via dell'import watch-only (§4.4).
+ ///
+ public static bool TryDecodePublic(string encoded, ChainProfile profile,
+ out ExtPubKey? key, out ScriptKind kind)
+ {
+ key = null;
+ kind = default;
+ if (!TryDecodePayload(encoded, profile, isPrivate: false, out var payload, out kind))
+ return false;
+ try
+ {
+ key = new ExtPubKey(payload!);
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ /// Come , per chiavi private (import spendibile).
+ public static bool TryDecodePrivate(string encoded, ChainProfile profile,
+ out ExtKey? key, out ScriptKind kind)
+ {
+ key = null;
+ kind = default;
+ if (!TryDecodePayload(encoded, profile, isPrivate: true, out var payload, out kind))
+ return false;
+ try
+ {
+ key = ExtKey.CreateFromBytes(payload!);
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ private static bool TryDecodePayload(string encoded, ChainProfile profile, bool isPrivate,
+ out byte[]? payload, out ScriptKind kind)
+ {
+ payload = null;
+ kind = default;
+ if (string.IsNullOrWhiteSpace(encoded))
+ return false;
+
+ byte[] data;
+ try
+ {
+ data = Encoders.Base58Check.DecodeData(encoded.Trim());
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+
+ if (data.Length != 78)
+ return false;
+
+ var header = (uint)(data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]);
+ foreach (var (candidate, headers) in profile.ExtKeyHeaders)
+ {
+ if (header != (isPrivate ? headers.Private : headers.Public))
+ continue;
+ kind = candidate;
+ payload = data[4..];
+ return true;
+ }
+
+ return false;
+ }
+}