using NBitcoin; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Wallet; // CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di // comando, per scripting, test e confronto col wallet di riferimento. try { return args switch { ["newseed", .. var rest] => NewSeed(rest), ["addresses", var words, .. var rest] => Addresses(words, rest), ["create", .. var rest] => Create(rest), ["restore", var words, .. var rest] => Restore(words, rest), ["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest), ["info", .. var rest] => Info(rest), ["sync", .. var rest] => await Sync(rest), ["send", .. var rest] => await Send(rest), ["reset-certs", .. var rest] => ResetCerts(rest), ["servers", .. var rest] => await Servers(rest), _ => Usage(), }; } catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException) { Console.Error.WriteLine($"Errore: {ex.Message}"); return 1; } static int NewSeed(string[] o) { var length = Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve; Console.WriteLine(Bip39.Generate(length)); return 0; } static int Addresses(string words, string[] o) { var account = AccountFromWords(words, o); if (account is null) return 1; var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5; Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}"); Console.WriteLine($"account: {account.ToSlip132()}"); for (var i = 0; i < count; i++) Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}"); for (var i = 0; i < Math.Min(count, 2); i++) Console.WriteLine($" change/{i}: {account.GetChangeAddress(i)}"); return 0; } static int Create(string[] o) { var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve); Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):"); Console.WriteLine($" {mnemonic}"); return SaveWallet(mnemonic.ToString(), o); } static int Restore(string words, string[] o) { if (!Bip39.TryParse(words, out _)) { Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati)."); return 1; } return SaveWallet(words.Trim(), o); } static int RestoreXpub(string xpubText, string[] o) { var profile = Profile(o); if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind)) { Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete."); return 1; } var account = HdAccount.FromAccountXpub(xpub!, kind, profile); var doc = new WalletDocument { Network = profile.NetName, ScriptKind = kind.ToString(), AccountPath = account.AccountPath.ToString(), AccountXpub = account.ToSlip132(), }; var path = WalletPath(o, profile); WalletStore.Save(doc, path, Opt(o, "--password")); Console.WriteLine($"Wallet watch-only salvato in {path}"); return 0; } static int Info(string[] o) { var (doc, account, path) = OpenWallet(o); Console.WriteLine($"file: {path}"); Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}"); Console.WriteLine($"path: m/{doc.AccountPath}"); Console.WriteLine($"xpub: {doc.AccountXpub}"); if (doc.Cache is { } cache) { Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato" + (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} non conf." : "")); Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni"); Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}"); } else { Console.WriteLine($"ricezione: {account.GetReceiveAddress(0)} (mai sincronizzato)"); } if (o.Contains("--addresses") && doc.Cache is { } c) { Console.WriteLine("indirizzi:"); foreach (var a in c.Addresses) Console.WriteLine($" {(a.IsChange ? "change " : "ricev. ")}{a.Index,3} {a.Address} " + $"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)"); } return 0; } static async Task Sync(string[] o) { var (doc, account, path) = OpenWallet(o); await using var client = await Connect(o, account.Profile); var sync = new WalletSynchronizer(account, client, doc.GapLimit); sync.Progress += msg => Console.WriteLine($" {msg}"); var result = await sync.SyncOnceAsync(); doc.Cache = new SyncCache { TipHeight = result.TipHeight, ConfirmedSats = result.ConfirmedSats, UnconfirmedSats = result.UnconfirmedSats, NextReceiveIndex = result.NextReceiveIndex, NextChangeIndex = result.NextChangeIndex, History = [.. result.History], Utxos = [.. result.Utxos], Addresses = [.. result.AddressRows], }; WalletStore.Save(doc, path, Opt(o, "--password")); Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato" + (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} non confermato" : "")); Console.WriteLine($"Storico ({result.History.Count}):"); foreach (var tx in result.History) Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " + $"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" + (tx.Verified ? "" : " (non verificata)")); return 0; } static async Task Send(string[] o) { var (doc, account, path) = OpenWallet(o); if (doc.Cache is null) { Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'."); return 1; } var to = Opt(o, "--to") ?? throw new WalletSpendException("--to mancante."); var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind)); var sendAll = o.Contains("--all"); long amount = 0; if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount)) throw new WalletSpendException("--amount mancante o non valido (oppure usa --all)."); var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m; // Tx di provenienza degli UTXO: servono per importi e firma. await using var client = await Connect(o, account.Profile); var network = PalladiumNetworks.For(account.Profile.Kind); var transactions = new Dictionary(); foreach (var txid in doc.Cache.Utxos.Select(u => u.Txid).Distinct()) transactions[txid] = Transaction.Parse(await client.GetTransactionAsync(txid), network); var built = new TransactionFactory(account).Build( doc.Cache.Utxos, transactions, destination, amount, feeRate, doc.Cache.NextChangeIndex, sendAll); Console.WriteLine($"txid: {built.Txid}"); Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " + $"({feeRate} sat/vB, {built.Transaction.GetVirtualSize()} vB)"); if (!built.Signed) { Console.WriteLine("Wallet watch-only: PSBT da firmare offline (§6.5):"); Console.WriteLine(built.Psbt.ToBase64()); return 0; } if (!o.Contains("--broadcast")) { Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):"); Console.WriteLine(built.ToHex()); return 0; } var txid2 = await client.BroadcastAsync(built.ToHex()); Console.WriteLine($"Trasmessa: {txid2}"); return 0; } static int ResetCerts(string[] o) { var profile = Profile(o); new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll(); Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati."); return 0; } static async Task Servers(string[] o) { var profile = Profile(o); var registry = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)); if (o.Contains("--discover")) { await using var client = await Connect(o, profile); var added = await registry.DiscoverAsync(client); Console.WriteLine($"Scoperti {added} nuovi server dai peer."); } Console.WriteLine($"Server noti ({profile.NetName}):"); foreach (var server in registry.All) Console.WriteLine($" {server}"); return 0; } // ----- helper comuni ----- static ChainProfile Profile(string[] o) => Opt(o, "--net") switch { "testnet" => ChainProfiles.Testnet, "regtest" => ChainProfiles.Regtest, _ => ChainProfiles.Mainnet, }; static ScriptKind Kind(string[] o) => Opt(o, "--kind") switch { "legacy" => ScriptKind.Legacy, "wrapped" => ScriptKind.WrappedSegwit, _ => ScriptKind.NativeSegwit, }; static string WalletPath(string[] o, ChainProfile profile) => Opt(o, "--file") ?? AppPaths.DefaultWalletPath(profile.Kind); static HdAccount? AccountFromWords(string words, string[] o) { if (!Bip39.TryParse(words, out var mnemonic)) { Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati)."); return null; } var profile = Profile(o); var kind = Kind(o); var passphrase = Opt(o, "--passphrase"); return Opt(o, "--path") is { } path ? HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, passphrase), kind, profile, KeyPath.Parse(path)) : HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile); } static int SaveWallet(string words, string[] o) { var profile = Profile(o); var (doc, account) = WalletLoader.NewFromMnemonic( words, Opt(o, "--passphrase"), Kind(o), profile, Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null); var password = Opt(o, "--password"); if (string.IsNullOrEmpty(password)) Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17)."); var path = WalletPath(o, profile); WalletStore.Save(doc, path, password); Console.WriteLine($"Wallet salvato in {path}"); Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}"); return 0; } static (WalletDocument, HdAccount, string) OpenWallet(string[] o) { var path = WalletPath(o, Profile(o)); if (!WalletStore.Exists(path)) throw new WalletSpendException($"Nessun wallet in {path}: usa 'create' o 'restore'."); var doc = WalletStore.Load(path, Opt(o, "--password")); return (doc, WalletLoader.ToAccount(doc), path); } static async Task Connect(string[] o, ChainProfile profile) { var useSsl = o.Contains("--ssl"); string host; int port; if (Opt(o, "--server") is { } server) { var parts = server.Split(':'); host = parts[0]; port = parts.Length > 1 ? int.Parse(parts[1]) : useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort; } else { // Senza --server si usa il primo server noto (bootstrap §3 o scoperto §9). var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default ?? throw new WalletSpendException("Nessun server noto per questa rete: usa --server host:porta."); host = known.Host; port = known.PortFor(useSsl); } var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)); Console.WriteLine($"Connessione a {host}:{port}{(useSsl ? " (TLS)" : "")}…"); return await ElectrumClient.ConnectAsync(host, port, useSsl, pins); } static string? Opt(string[] options, string name) { var i = Array.IndexOf(options, name); return i >= 0 && i + 1 < options.Length ? options[i + 1] : null; } static int Usage() { Console.WriteLine(""" PalladiumWallet CLI Wallet: create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--passphrase W] [--password P] [--file PATH] restore "" [stesse opzioni di create] [--path m/...] restore-xpub [--net ...] [--password P] [--file PATH] (watch-only) info [--net ...] [--password P] [--file PATH] Rete (server di indicizzazione; senza --server usa il primo server noto): sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH] send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB] [--server host[:porta]] [--ssl] [--broadcast] [...] servers [--discover] [--server host[:porta]] [--ssl] [--net ...] reset-certs [--net ...] Strumenti: newseed [--words 12|24] addresses "" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...] """); return 1; }