From 0e32b9bc9b0c06c515036481facf2b3196dd6de7 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 11 Jun 2026 10:48:07 +0200 Subject: [PATCH] feat(cli): command-line interface --- src/Cli/Program.cs | 310 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/Cli/Program.cs diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs new file mode 100644 index 0000000..b751b88 --- /dev/null +++ b/src/Cli/Program.cs @@ -0,0 +1,310 @@ +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), + _ => 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)"); + } + 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], + }; + 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; +} + +// ----- 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 server = Opt(o, "--server") + ?? throw new WalletSpendException("--server host:porta mancante."); + var parts = server.Split(':'); + var host = parts[0]; + var useSsl = o.Contains("--ssl"); + var port = parts.Length > 1 ? int.Parse(parts[1]) + : useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort; + + 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): + sync --server host[:porta] [--ssl] [--net ...] [--password P] [--file PATH] + send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB] + --server host[:porta] [--ssl] [--broadcast] [...] + reset-certs [--net ...] + + Strumenti: + newseed [--words 12|24] + addresses "" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...] + """); + return 1; +}