diff --git a/src/App/ViewModels/MainWindowViewModel.Contacts.cs b/src/App/ViewModels/MainWindowViewModel.Contacts.cs new file mode 100644 index 0000000..7eba20c --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Contacts.cs @@ -0,0 +1,70 @@ +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + public ObservableCollection Contacts { get; } = []; + + [ObservableProperty] + private ContactEntry? selectedContactInList; + + /// Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo. + [ObservableProperty] + private ContactEntry? sendToContact; + + partial void OnSendToContactChanged(ContactEntry? value) + { + if (value is not null) + SendTo = value.Address; + } + + [ObservableProperty] + private string newContactName = ""; + + [ObservableProperty] + private string newContactAddress = ""; + + [RelayCommand] + private void AddContact() + { + var name = NewContactName.Trim(); + var addr = NewContactAddress.Trim(); + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return; + Contacts.Add(new ContactEntry(name, addr)); + NewContactName = NewContactAddress = ""; + PersistContacts(); + } + + [RelayCommand] + private void RemoveSelectedContact() + { + if (SelectedContactInList is { } c) + { + Contacts.Remove(c); + SelectedContactInList = null; + PersistContacts(); + } + } + + private void PersistContacts() + { + if (_doc is null || _walletPath is null) return; + _doc.Contacts = Contacts + .Select(c => new StoredContact { Name = c.Name, Address = c.Address }) + .ToList(); + WalletStore.Save(_doc, _walletPath, _password); + } + + private void LoadContacts() + { + Contacts.Clear(); + if (_doc is null) return; + foreach (var c in _doc.Contacts) + Contacts.Add(new ContactEntry(c.Name, c.Address)); + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Receive.cs b/src/App/ViewModels/MainWindowViewModel.Receive.cs new file mode 100644 index 0000000..ec40e6b --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Receive.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; +using QRCoder; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- saldo e info wallet ---- + + [ObservableProperty] + private string balanceText = "—"; + + [ObservableProperty] + private string unconfirmedText = ""; + + [ObservableProperty] + private string networkInfo = ""; + + // ---- indirizzo di ricezione e QR ---- + + [ObservableProperty] + private string receiveAddress = ""; + + [ObservableProperty] + private Bitmap? receiveQr; + + partial void OnReceiveAddressChanged(string value) + { + var previous = ReceiveQr; + ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value); + previous?.Dispose(); + } + + public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied"); + + private static Bitmap? GenerateQr(string text) + { + try + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M); + var png = new PngByteQRCode(data).GetGraphic(8); + return new Bitmap(new MemoryStream(png)); + } + catch + { + return null; + } + } + + // ---- tab indirizzi ---- + + [ObservableProperty] + private AddressRow? selectedAddressRow; + + [ObservableProperty] + private AddressInfo? addressInfo; + + public void ShowAddressInfo(AddressRow row) => + AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey); + + [RelayCommand] + private void CloseAddressInfo() => AddressInfo = null; + + // ---- overlay dettaglio transazione ---- + + [ObservableProperty] + private bool isTxDetailsOpen; + + [ObservableProperty] + private bool isTxDetailsLoading; + + [ObservableProperty] + private TransactionDetailsViewModel? txDetails; + + public async Task ShowTransactionDetailsAsync(string txid) + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("tx.needconnection"); + return; + } + + _txDetailsCts?.Cancel(); + _txDetailsCts = new CancellationTokenSource(); + var ct = _txDetailsCts.Token; + + TxDetails = null; + IsTxDetailsLoading = true; + IsTxDetailsOpen = true; + + var details = await BuildTransactionDetailsAsync(txid, ct); + + if (ct.IsCancellationRequested || !IsTxDetailsOpen) + return; + + if (details is null) + { + IsTxDetailsOpen = false; + IsTxDetailsLoading = false; + return; + } + + TxDetails = details; + IsTxDetailsLoading = false; + } + + [RelayCommand] + private void CloseTransactionDetails() + { + _txDetailsCts?.Cancel(); + IsTxDetailsOpen = false; + IsTxDetailsLoading = false; + TxDetails = null; + } + + public async Task BuildTransactionDetailsAsync( + string txid, CancellationToken ct = default) + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("tx.needconnection"); + return null; + } + if (_doc?.Cache is not { } cache) + return null; + + var client = _client; + var network = PalladiumNetworks.For(Net); + var row = cache.History.FirstOrDefault(t => t.Txid == txid); + var owned = cache.Addresses.Select(a => a.Address).ToHashSet(); + var tipHeight = cache.TipHeight; + var height = row?.Height ?? 0; + var delta = row?.DeltaSats ?? 0; + var verified = row?.Verified ?? false; + var transactions = _lastTransactions; + var loc = _loc; + var unit = _config.Unit; + + try + { + return await Task.Run(async () => + { + var details = await TransactionInspector.FetchAsync( + client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct); + return new TransactionDetailsViewModel(details, loc, unit); + }, ct); + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; + return null; + } + } + + // ---- aggiorna display dal risultato della sync ---- + + private void ApplyCache(SyncCache? cache) + { + if (_account is null) + return; + if (cache is null) + { + BalanceText = $"0.00000000 {Profile.CoinUnit}"; + UnconfirmedText = ""; + ReceiveAddress = _account.GetReceiveAddress(0).ToString(); + History.Clear(); + Addresses.Clear(); + for (var i = 0; i < 10; i++) + Addresses.Add(new AddressRow(_loc["addr.receive"], i, + _account.GetReceiveAddress(i).ToString(), "—", "—", + false, + _account.GetPublicKey(false, i).ToHex(), + KeyWif(false, i), + $"m/{_doc!.AccountPath}/0/{i}")); + return; + } + BalanceText = Fmt(cache.ConfirmedSats); + var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); + UnconfirmedText = pending != 0 + ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" + : ""; + ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); + History.Clear(); + foreach (var tx in cache.History) + History.Add(new HistoryRow( + tx.Height > 0 ? tx.Height.ToString() : "mempool", + (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false), + tx.Txid, + tx.Verified ? "✓ SPV" : "—")); + + Addresses.Clear(); + foreach (var a in cache.Addresses) + Addresses.Add(new AddressRow( + a.IsChange ? _loc["addr.change"] : _loc["addr.receive"], + a.Index, + a.Address, + a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"), + a.TxCount > 0 ? a.TxCount.ToString() : "—", + a.IsChange, + _account.GetPublicKey(a.IsChange, a.Index).ToHex(), + KeyWif(a.IsChange, a.Index), + $"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}")); + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Send.cs b/src/App/ViewModels/MainWindowViewModel.Send.cs new file mode 100644 index 0000000..d2e341c --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Send.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + [ObservableProperty] + private string sendTo = ""; + + [ObservableProperty] + private string sendAmount = ""; + + [ObservableProperty] + private string sendFeeRate = "1"; + + [ObservableProperty] + private bool sendAll; + + [ObservableProperty] + private string sendPreview = ""; + + [ObservableProperty] + private bool hasPendingSend; + + [RelayCommand] + private async Task PrepareSend() + { + if (_account is null || _doc?.Cache is null) + { + SendPreview = "Sincronizza prima di inviare."; + return; + } + try + { + if (_lastTransactions is null) + { + SendPreview = "Connettiti al server e sincronizza prima di inviare."; + return; + } + + var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net)); + long amount = 0; + if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount)) + { + SendPreview = "Importo non valido."; + return; + } + if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0) + { + SendPreview = "Fee rate non valido."; + return; + } + + _pendingSend = new TransactionFactory(_account).Build( + _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, + _doc.Cache.NextChangeIndex, SendAll); + + SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + + $"fee {Fmt(_pendingSend.Fee.Satoshi)} " + + $"({_pendingSend.Transaction.GetVirtualSize()} vB)" + + (_pendingSend.Signed ? "" : " · NON firmata (watch-only)"); + HasPendingSend = _pendingSend.Signed; + } + catch (Exception ex) + { + _pendingSend = null; + HasPendingSend = false; + SendPreview = $"Errore: {ex.Message}"; + } + await Task.CompletedTask; + } + + [RelayCommand] + private async Task ConfirmSend() + { + if (_pendingSend is null || _client is null) + return; + try + { + var txid = await _client.BroadcastAsync(_pendingSend.ToHex()); + SendPreview = $"Trasmessa: {txid}"; + SendTo = SendAmount = ""; + _pendingSend = null; + HasPendingSend = false; + await ConnectAndSync(); + } + catch (Exception ex) + { + SendPreview = $"Errore broadcast: {ex.Message}"; + } + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Settings.cs b/src/App/ViewModels/MainWindowViewModel.Settings.cs new file mode 100644 index 0000000..778e591 --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Settings.cs @@ -0,0 +1,74 @@ +using CommunityToolkit.Mvvm.Input; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- spunte del menu Impostazioni (ToggleType Radio) ---- + + public bool IsLangIt => _config.Language == "it"; + public bool IsLangEn => _config.Language == "en"; + public bool IsLangEs => _config.Language == "es"; + public bool IsLangFr => _config.Language == "fr"; + public bool IsLangPt => _config.Language == "pt"; + public bool IsLangDe => _config.Language == "de"; + public bool IsUnitPlm => _config.Unit == "PLM"; + public bool IsUnitMilli => _config.Unit == "mPLM"; + public bool IsUnitMicro => _config.Unit == "µPLM"; + public bool IsUnitSat => _config.Unit == "sat"; + + [RelayCommand] + private void SetLanguage(string language) + { + _config.Language = language; + ApplySettings(_config); + } + + [RelayCommand] + private void SetUnit(string unit) + { + _config.Unit = unit; + ApplySettings(_config); + } + + public void ApplySettings(PalladiumWallet.Core.Storage.AppConfig config) + { + _config = config; + _config.Save(); + _loc = Localization.Loc.SwitchTo(config.Language); + OnPropertyChanged(nameof(Loc)); + OnPropertyChanged(nameof(UnitLabel)); + OnPropertyChanged(nameof(IsLangIt)); + OnPropertyChanged(nameof(IsLangEn)); + OnPropertyChanged(nameof(IsLangEs)); + OnPropertyChanged(nameof(IsLangFr)); + OnPropertyChanged(nameof(IsLangPt)); + OnPropertyChanged(nameof(IsLangDe)); + OnPropertyChanged(nameof(IsUnitPlm)); + OnPropertyChanged(nameof(IsUnitMilli)); + OnPropertyChanged(nameof(IsUnitMicro)); + OnPropertyChanged(nameof(IsUnitSat)); + ApplyCache(_doc?.Cache); + StatusMessage = Localization.Loc.Tr("msg.settings.saved"); + } + + // ---- overlay impostazioni, server, help ---- + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private bool isSettingsOpen; + + [RelayCommand] + private void OpenSettings() => IsSettingsOpen = true; + + [RelayCommand] + private void CloseSettings() => IsSettingsOpen = false; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private bool isHelpOpen; + + [RelayCommand] + private void OpenHelp() => IsHelpOpen = true; + + [RelayCommand] + private void CloseHelp() => IsHelpOpen = false; +} diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs new file mode 100644 index 0000000..9d85df2 --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- server e connessione ---- + + [ObservableProperty] + private string serverHost = ""; + + [ObservableProperty] + private string serverPort = ""; + + [ObservableProperty] + private bool useSsl = true; + + [ObservableProperty] + private bool isServerSettingsOpen; + + [RelayCommand] + private void OpenServerSettings() + { + IsSettingsOpen = false; + IsServerSettingsOpen = true; + } + + [RelayCommand] + private void CloseServerSettings() => IsServerSettingsOpen = false; + + [ObservableProperty] + private string connectionStatus = Loc.Tr("conn.none"); + + [ObservableProperty] + private bool isConnected; + + [ObservableProperty] + private bool isSyncing; + + public ObservableCollection KnownServers { get; } = []; + + [ObservableProperty] + private KnownServer? selectedKnownServer; + + partial void OnSelectedKnownServerChanged(KnownServer? value) + { + if (value is null) + return; + _syncingServerFields = true; + ServerHost = value.Host; + ServerPort = value.PortFor(UseSsl).ToString(); + _syncingServerFields = false; + } + + partial void OnUseSslChanged(bool value) + { + if (_syncingServerFields) + return; + _syncingServerFields = true; + ServerPort = SelectedKnownServer is { } server + ? server.PortFor(value).ToString() + : (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); + _syncingServerFields = false; + } + + partial void OnServerPortChanged(string value) + { + if (_syncingServerFields) + return; + if (!int.TryParse(value.Trim(), out var port)) + return; + bool? wantSsl = + SelectedKnownServer is { } s && port == s.SslPort ? true : + SelectedKnownServer is { } t && port == t.TcpPort ? false : + port == Profile.DefaultSslPort ? true : + port == Profile.DefaultTcpPort ? false : + null; + if (wantSsl is bool b && b != UseSsl) + { + _syncingServerFields = true; + UseSsl = b; + _syncingServerFields = false; + } + } + + private void RefreshServers() + { + KnownServers.Clear(); + foreach (var server in Registry.All) + KnownServers.Add(server); + SelectedKnownServer = KnownServers.FirstOrDefault(); + if (SelectedKnownServer is null) + { + ServerHost = "127.0.0.1"; + ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); + } + } + + private (string Host, int Port) ParseServer() + { + var host = ServerHost.Trim(); + var port = int.TryParse(ServerPort.Trim(), out var p) + ? p + : UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort; + return (host, port); + } + + [RelayCommand] + private async Task ConnectAndSync() + { + if (IsSyncing) + { + _resyncRequested = true; + return; + } + IsSyncing = true; + StatusMessage = ""; + try + { + var (host, port) = ParseServer(); + + if (_client is { } current && + (current.Host != host || current.Port != port || current.UseSsl != UseSsl)) + { + await DisconnectAsync(); + } + + if (_client is null || !_client.IsConnected) + { + ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…"; + var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); + _client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins); + _client.NotificationReceived += OnServerNotification; + _client.Disconnected += _ => Dispatcher.UIThread.Post(() => + { + IsConnected = false; + ConnectionStatus = Loc.Tr("conn.disconnected"); + }); + IsConnected = true; + _autoReconnect = true; + ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}"; + } + + if (_account is null || _doc is null) + return; + + if (_synchronizer is null) + { + _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); + _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); + } + + do + { + _resyncRequested = false; + var result = await _synchronizer.SyncOnceAsync(); + _lastTransactions = result.Transactions; + + _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, _walletPath!, _password); + ApplyCache(_doc.Cache); + StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + + $"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; + } while (_resyncRequested); + } + catch (CertificatePinMismatchException ex) + { + IsConnected = false; + ConnectionStatus = Loc.Tr("conn.certchanged"); + StatusMessage = ex.Message; + } + catch (Exception ex) + { + IsConnected = _client?.IsConnected == true; + ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error"); + StatusMessage = $"Errore: {ex.Message}"; + } + finally + { + IsSyncing = false; + } + } + + [RelayCommand] + private async Task DiscoverServers() + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("conn.none") + "."; + return; + } + try + { + var added = await Registry.DiscoverAsync(_client); + var selected = SelectedKnownServer; + RefreshServers(); + SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host) + ?? KnownServers.FirstOrDefault(); + StatusMessage = added > 0 + ? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})." + : $"Nessun nuovo server annunciato (totale {KnownServers.Count})."; + } + catch (Exception ex) + { + StatusMessage = $"Errore nella scoperta peer: {ex.Message}"; + } + } + + private void OnServerNotification(string method, System.Text.Json.JsonElement payload) + { + if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe") + Dispatcher.UIThread.Post(() => + { + if (IsSyncing) + _resyncRequested = true; + else + _ = ConnectAndSync(); + }); + } + + [RelayCommand] + private void ResetCertificates() + { + new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll(); + StatusMessage = Loc.Tr("msg.certreset"); + } + + private async Task DisconnectAsync() + { + if (_client is { } client) + { + _client = null; + _synchronizer = null; + try { await client.DisposeAsync(); } catch { } + } + IsConnected = false; + } +} diff --git a/src/App/ViewModels/MainWindowViewModel.Wizard.cs b/src/App/ViewModels/MainWindowViewModel.Wizard.cs new file mode 100644 index 0000000..595cb5c --- /dev/null +++ b/src/App/ViewModels/MainWindowViewModel.Wizard.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.App.ViewModels; + +public partial class MainWindowViewModel +{ + // ---- wizard di setup + + public const string StepDataLocation = "data-location"; + public const string StepStart = "start"; + public const string StepChooseWallet = "choose-wallet"; + public const string StepOpen = "open"; + public const string StepShowSeed = "show-seed"; + public const string StepConfirmSeed = "confirm-seed"; + public const string StepWords = "words"; + public const string StepPassphrase = "passphrase"; + public const string StepPassword = "password"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsStepDataLocation))] + [NotifyPropertyChangedFor(nameof(IsStepStart))] + [NotifyPropertyChangedFor(nameof(IsStepChooseWallet))] + [NotifyPropertyChangedFor(nameof(IsStepOpen))] + [NotifyPropertyChangedFor(nameof(IsStepShowSeed))] + [NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))] + [NotifyPropertyChangedFor(nameof(IsStepWords))] + [NotifyPropertyChangedFor(nameof(IsStepPassphrase))] + [NotifyPropertyChangedFor(nameof(IsStepPassword))] + private string setupStep = StepStart; + + public bool IsStepDataLocation => SetupStep == StepDataLocation; + public bool IsStepStart => SetupStep == StepStart; + public bool IsStepChooseWallet => SetupStep == StepChooseWallet; + public bool IsStepOpen => SetupStep == StepOpen; + public bool IsStepShowSeed => SetupStep == StepShowSeed; + public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed; + public bool IsStepWords => SetupStep == StepWords; + public bool IsStepPassphrase => SetupStep == StepPassphrase; + public bool IsStepPassword => SetupStep == StepPassword; + + public string DefaultDataPath => AppPaths.DefaultDataRoot(); + + [ObservableProperty] + private string mnemonicInput = ""; + + [ObservableProperty] + private string confirmMnemonicInput = ""; + + [ObservableProperty] + private string passphraseInput = ""; + + [ObservableProperty] + private string passwordInput = ""; + + [ObservableProperty] + private string confirmPasswordInput = ""; + + [ObservableProperty] + private bool encryptWallet = true; + + [ObservableProperty] + private bool walletFileExists; + + public ObservableCollection WalletList { get; } = []; + + [RelayCommand] + private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot()); + + public void ApplyDataLocation(string root) + { + try + { + AppPaths.ConfigureDataLocation(root); + } + catch (Exception ex) + { + StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; + return; + } + _config = AppConfig.Load(); + _loc = Loc.SwitchTo(_config.Language); + OnPropertyChanged(nameof(Loc)); + OnPropertyChanged(nameof(UnitLabel)); + RefreshSetupState(); + } + + private void RefreshSetupState() + { + SetupStep = StepStart; + MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; + WalletFileExists = AppPaths.WalletFiles(Net).Count > 0; + StatusMessage = ""; + RefreshServers(); + _ = ConnectAndSync(); + } + + [RelayCommand] + private void WizardStartOpen() + { + var files = AppPaths.WalletFiles(Net); + if (files.Count > 1) + { + WalletList.Clear(); + foreach (var path in files) + WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path)); + SetupStep = StepChooseWallet; + StatusMessage = ""; + return; + } + _pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net); + PasswordInput = ""; + SetupStep = StepOpen; + StatusMessage = ""; + } + + [RelayCommand] + private void ChooseWallet(WalletFileEntry? entry) + { + if (entry is null) + return; + _pendingOpenPath = entry.Path; + PasswordInput = ""; + SetupStep = StepOpen; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardStartNew() + { + _isRestoreFlow = false; + MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString(); + SetupStep = StepShowSeed; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardStartRestore() + { + _isRestoreFlow = true; + MnemonicInput = ""; + SetupStep = StepWords; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardNextFromShowSeed() + { + ConfirmMnemonicInput = ""; + SetupStep = StepConfirmSeed; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardNextFromConfirmSeed() + { + var normalized = string.Join(' ', + ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase)) + { + StatusMessage = Loc.Tr("msg.seed.mismatch"); + return; + } + GoToPassphraseStep(); + } + + [RelayCommand] + private void WizardNextFromWords() + { + if (!Bip39.TryParse(MnemonicInput, out _)) + { + StatusMessage = Loc.Tr("msg.words.invalid"); + return; + } + GoToPassphraseStep(); + } + + private void GoToPassphraseStep() + { + PassphraseInput = ""; + SetupStep = StepPassphrase; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardNextFromPassphrase() + { + PasswordInput = ConfirmPasswordInput = ""; + EncryptWallet = true; + SetupStep = StepPassword; + StatusMessage = ""; + } + + [RelayCommand] + private void WizardBack() + { + SetupStep = SetupStep switch + { + StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, + StepChooseWallet or StepShowSeed or StepWords => StepStart, + StepConfirmSeed => StepShowSeed, + StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed, + StepPassword => StepPassphrase, + _ => StepStart, + }; + if (SetupStep == StepStart) + RefreshSetupState(); + } + + [RelayCommand] + private void CreateOrRestore() + { + string? password; + if (EncryptWallet) + { + if (string.IsNullOrEmpty(PasswordInput)) + { + StatusMessage = Loc.Tr("msg.password.required"); + return; + } + if (PasswordInput != ConfirmPasswordInput) + { + StatusMessage = Loc.Tr("msg.password.mismatch"); + return; + } + password = PasswordInput; + } + else + { + password = null; + } + + try + { + var (doc, account) = WalletLoader.NewFromMnemonic( + MnemonicInput, + string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput, + ScriptKind.NativeSegwit, + Profile); + var path = AppPaths.DefaultWalletPath(Net); + for (var n = 2; WalletStore.Exists(path); n++) + path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json"); + WalletStore.Save(doc, path, password); + var newLock = WalletLock.TryAcquire(path); + if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } + OpenLoaded(doc, account, path, password, newLock); + } + catch (Exception ex) + { + StatusMessage = $"Errore: {ex.Message}"; + } + } + + [RelayCommand] + private void OpenExisting() + { + var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net); + var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput; + + var newLock = WalletLock.TryAcquire(path); + if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } + + try + { + var doc = WalletStore.Load(path, password); + _pendingOpenPath = null; + OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password, newLock); + } + catch (WrongPasswordException) + { + newLock.Dispose(); + StatusMessage = Loc.Tr("msg.wrongpassword"); + } + catch (UnauthorizedAccessException) + { + newLock.Dispose(); + StatusMessage = Loc.Tr("msg.wallet.noaccess"); + } + catch (Exception ex) + { + newLock.Dispose(); + StatusMessage = $"Errore: {ex.Message}"; + } + } + + public void OpenFromPath(string path) + { + var newLock = WalletLock.TryAcquire(path); + if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } + + try + { + var doc = WalletStore.Load(path); + if (IsWalletOpen) + CloseWallet(); + OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock); + } + catch (WrongPasswordException) + { + newLock.Dispose(); + if (IsWalletOpen) + CloseWallet(); + _pendingOpenPath = path; + WalletFileExists = true; + PasswordInput = ""; + SetupStep = StepOpen; + StatusMessage = ""; + } + catch (UnauthorizedAccessException) + { + newLock.Dispose(); + StatusMessage = Loc.Tr("msg.wallet.noaccess"); + } + catch (Exception ex) + { + newLock.Dispose(); + StatusMessage = $"Errore: {ex.Message}"; + } + } + + [RelayCommand] + private void NewWallet() + { + if (IsWalletOpen) + CloseWallet(); + _pendingOpenPath = null; + StatusMessage = ""; + } + + private void OpenLoaded( + WalletDocument doc, HdAccount account, string path, string? password, WalletLock walletLock) + { + _walletLock?.Dispose(); + _walletLock = walletLock; + + SelectedNetwork = doc.Network; + _doc = doc; + _account = account; + _walletPath = path; + _password = password; + MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; + SetupStep = StepStart; + + NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}" + + (doc.IsWatchOnly ? " · watch-only" : ""); + LoadContacts(); + ApplyCache(doc.Cache); + IsWalletOpen = true; + StatusMessage = Loc.Tr("msg.opened"); + _ = ConnectAndSync(); + } +} \ No newline at end of file diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index e32cd33..17916fb 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -1,11 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.IO; -using System.Linq; using System.Threading; -using System.Threading.Tasks; -using Avalonia.Media.Imaging; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -17,7 +13,6 @@ using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Wallet; -using QRCoder; namespace PalladiumWallet.App.ViewModels; @@ -34,7 +29,7 @@ public sealed record AddressRow( /// Dati completi di un indirizzo passati alla finestra di dettaglio. public sealed record AddressInfo( - Localization.Loc Loc, + Loc Loc, string Address, string DerivPath, string PubKey, string PrivKey) { public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey); @@ -47,105 +42,93 @@ public sealed record ContactEntry(string Name, string Address); public sealed record WalletFileEntry(string Name, string Path); /// -/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard): -/// pannello di setup (crea/ripristina/apri) e pannello wallet -/// (saldo, ricevi, storico, invia, server). +/// ViewModel unico dell'applicazione (wizard + dashboard). Suddiviso in file +/// partial per area: Wizard, Settings, Sync, Send, Contacts, Receive. /// public partial class MainWindowViewModel : ViewModelBase { + // ---- stato sessione wallet ---- private WalletDocument? _doc; private HdAccount? _account; private string? _walletPath; private string? _password; private WalletLock? _walletLock; + + // ---- rete ---- private ElectrumClient? _client; private WalletSynchronizer? _synchronizer; private IReadOnlyDictionary? _lastTransactions; - private BuiltTransaction? _pendingSend; - - /// Notifica arrivata durante una sync: si risincronizza appena finita. private bool _resyncRequested; - /// Configurazione globale (§8): lingua e unità. - private AppConfig _config = AppConfig.Load(); + // ---- invio ---- + private BuiltTransaction? _pendingSend; - /// Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua - /// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}. + // ---- dettaglio transazione ---- + private CancellationTokenSource? _txDetailsCts; + + // ---- configurazione e localizzazione ---- + private AppConfig _config = AppConfig.Load(); private Loc _loc = Loc.Instance; public Loc Loc => _loc; - /// Versione dell'app (da <Version> nel csproj), formato Major.Minor.Patch. + // ---- wizard ---- + private string? _pendingOpenPath; + private bool _isRestoreFlow; + + // ---- keep-alive ---- + private bool _autoReconnect; + private readonly DispatcherTimer _keepAliveTimer; + + // ---- server UI sync ---- + private bool _syncingServerFields; + + // ---- proprietà di app ---- + public static string AppVersion => typeof(MainWindowViewModel).Assembly.GetName().Version is { } v ? $"{v.Major}.{v.Minor}.{v.Build}" : ""; - /// Titolo della finestra con la versione, es. "Palladium Wallet 0.9.0". public string WindowTitle => $"Palladium Wallet {AppVersion}"; - /// true su desktop (Windows/Linux); false su mobile. Nasconde le funzioni - /// legate al filesystem libero (apri-da-file, scelta cartella dati) non valide nel - /// sandbox Android. + /// true su desktop; false su Android/iOS — nasconde le funzioni legate al filesystem libero. public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS(); - /// Unità corrente per il campo importo del pannello Invia. public string UnitLabel => _config.Unit; - public AppConfig CurrentConfig => _config; - // Spunte del menu Impostazioni (ToggleType Radio). - public bool IsLangIt => _config.Language == "it"; - public bool IsLangEn => _config.Language == "en"; - public bool IsLangEs => _config.Language == "es"; - public bool IsLangFr => _config.Language == "fr"; - public bool IsLangPt => _config.Language == "pt"; - public bool IsLangDe => _config.Language == "de"; - public bool IsUnitPlm => _config.Unit == "PLM"; - public bool IsUnitMilli => _config.Unit == "mPLM"; - public bool IsUnitMicro => _config.Unit == "µPLM"; - public bool IsUnitSat => _config.Unit == "sat"; + // ---- stato pannelli ---- - [RelayCommand] - private void SetLanguage(string language) - { - _config.Language = language; - ApplySettings(_config); - } + public string[] Networks { get; } = ["mainnet", "testnet", "regtest"]; - [RelayCommand] - private void SetUnit(string unit) - { - _config.Unit = unit; - ApplySettings(_config); - } + [ObservableProperty] + private string selectedNetwork = "mainnet"; - /// Applica e persiste le impostazioni (§8). - public void ApplySettings(AppConfig config) - { - _config = config; - _config.Save(); - _loc = Loc.SwitchTo(config.Language); - OnPropertyChanged(nameof(Loc)); - OnPropertyChanged(nameof(UnitLabel)); - OnPropertyChanged(nameof(IsLangIt)); - OnPropertyChanged(nameof(IsLangEn)); - OnPropertyChanged(nameof(IsLangEs)); - OnPropertyChanged(nameof(IsLangFr)); - OnPropertyChanged(nameof(IsLangPt)); - OnPropertyChanged(nameof(IsLangDe)); - OnPropertyChanged(nameof(IsUnitPlm)); - OnPropertyChanged(nameof(IsUnitMilli)); - OnPropertyChanged(nameof(IsUnitMicro)); - OnPropertyChanged(nameof(IsUnitSat)); - ApplyCache(_doc?.Cache); - StatusMessage = Loc.Tr("msg.settings.saved"); - } + partial void OnSelectedNetworkChanged(string value) => RefreshSetupState(); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSetupVisible))] + private bool isWalletOpen; + + public bool IsSetupVisible => !IsWalletOpen; + + [ObservableProperty] + private string statusMessage = ""; + + // ---- collections per la dashboard ---- + + public ObservableCollection History { get; } = []; + public ObservableCollection Addresses { get; } = []; + + // ---- helpers ---- + + private NetKind Net => System.Enum.Parse(SelectedNetwork, ignoreCase: true); + private ChainProfile Profile => ChainProfiles.For(Net); + private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); - /// Formatta un importo nell'unità scelta nelle impostazioni. private string Fmt(long sats, bool withLabel = true) => CoinAmount.FormatIn(sats, _config.Unit, withLabel); - /// Chiave privata WIF per un indirizzo; stringa vuota se watch-only. private string KeyWif(bool isChange, int index) { if (_account is null or { IsWatchOnly: true }) return ""; @@ -157,451 +140,28 @@ public partial class MainWindowViewModel : ViewModelBase catch { return ""; } } - /// File in attesa di password (apertura da menu File → Apri). - private string? _pendingOpenPath; - - /// Dopo la prima connessione riuscita il timer riconnette da solo. - private bool _autoReconnect; - - private readonly DispatcherTimer _keepAliveTimer; - - // ---- selezione rete e stato pannelli ---- - - public string[] Networks { get; } = ["mainnet", "testnet", "regtest"]; - - [ObservableProperty] - private string selectedNetwork = "mainnet"; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsSetupVisible))] - private bool isWalletOpen; - - public bool IsSetupVisible => !IsWalletOpen; - - [ObservableProperty] - private bool walletFileExists; - - [ObservableProperty] - private string statusMessage = ""; - - // ---- wizard di setup (§15): un passo alla volta ---- - - public const string StepDataLocation = "data-location"; - public const string StepStart = "start"; - public const string StepChooseWallet = "choose-wallet"; - public const string StepOpen = "open"; - public const string StepShowSeed = "show-seed"; - public const string StepConfirmSeed = "confirm-seed"; - public const string StepWords = "words"; - public const string StepPassphrase = "passphrase"; - public const string StepPassword = "password"; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsStepDataLocation))] - [NotifyPropertyChangedFor(nameof(IsStepStart))] - [NotifyPropertyChangedFor(nameof(IsStepChooseWallet))] - [NotifyPropertyChangedFor(nameof(IsStepOpen))] - [NotifyPropertyChangedFor(nameof(IsStepShowSeed))] - [NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))] - [NotifyPropertyChangedFor(nameof(IsStepWords))] - [NotifyPropertyChangedFor(nameof(IsStepPassphrase))] - [NotifyPropertyChangedFor(nameof(IsStepPassword))] - private string setupStep = StepStart; - - public bool IsStepDataLocation => SetupStep == StepDataLocation; - public bool IsStepStart => SetupStep == StepStart; - public bool IsStepChooseWallet => SetupStep == StepChooseWallet; - public bool IsStepOpen => SetupStep == StepOpen; - public bool IsStepShowSeed => SetupStep == StepShowSeed; - public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed; - public bool IsStepWords => SetupStep == StepWords; - public bool IsStepPassphrase => SetupStep == StepPassphrase; - public bool IsStepPassword => SetupStep == StepPassword; - - /// Percorso dati predefinito, mostrato al primo avvio. - public string DefaultDataPath => AppPaths.DefaultDataRoot(); - - /// True quando il flusso è "ripristina" (parole inserite dall'utente). - private bool _isRestoreFlow; - - [ObservableProperty] - private string mnemonicInput = ""; - - [ObservableProperty] - private string confirmMnemonicInput = ""; - - [ObservableProperty] - private string passphraseInput = ""; - - [ObservableProperty] - private string passwordInput = ""; - - /// Conferma password alla creazione (stile Electrum: digitata due volte). - [ObservableProperty] - private string confirmPasswordInput = ""; - - /// Se true il file wallet viene cifrato con la password (default, stile Electrum). - [ObservableProperty] - private bool encryptWallet = true; - - // ---- pannello wallet ---- - - [ObservableProperty] - private string balanceText = "—"; - - [ObservableProperty] - private string unconfirmedText = ""; - - [ObservableProperty] - private string networkInfo = ""; - - [ObservableProperty] - private string receiveAddress = ""; - - /// QR code dell'indirizzo di ricezione corrente (PNG in-memory). - [ObservableProperty] - private Bitmap? receiveQr; - - // Rigenera il QR ogni volta che l'indirizzo di ricezione cambia. - partial void OnReceiveAddressChanged(string value) - { - var previous = ReceiveQr; - ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value); - previous?.Dispose(); - } - - /// Feedback dopo la copia dell'indirizzo negli appunti (chiamato dal code-behind). - public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied"); - - private static Bitmap? GenerateQr(string text) - { - try - { - using var generator = new QRCodeGenerator(); - using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M); - var png = new PngByteQRCode(data).GetGraphic(8); - return new Bitmap(new MemoryStream(png)); - } - catch - { - return null; - } - } - - [ObservableProperty] - private string serverHost = ""; - - [ObservableProperty] - private string serverPort = ""; - - /// Si predilige TLS: la connessione automatica all'apertura del - /// wallet usa la porta SSL del server. L'utente può disattivarlo. - [ObservableProperty] - private bool useSsl = true; - - /// Overlay impostazioni server: aperto dall'overlay Impostazioni. - [ObservableProperty] - private bool isServerSettingsOpen; - - [RelayCommand] - private void OpenServerSettings() - { - IsSettingsOpen = false; - IsServerSettingsOpen = true; - } - - [RelayCommand] - private void CloseServerSettings() => IsServerSettingsOpen = false; - - /// Overlay impostazioni (lingua, unità, server): in-app per evitare - /// i popup di menu annidati, lenti su WSLg. - [ObservableProperty] - private bool isSettingsOpen; - - [RelayCommand] - private void OpenSettings() => IsSettingsOpen = true; - - [RelayCommand] - private void CloseSettings() => IsSettingsOpen = false; - - /// Overlay informazioni sul software (menu Help). - [ObservableProperty] - private bool isHelpOpen; - - [RelayCommand] - private void OpenHelp() => IsHelpOpen = true; - - [RelayCommand] - private void CloseHelp() => IsHelpOpen = false; - - [ObservableProperty] - private string connectionStatus = Loc.Tr("conn.none"); - - [ObservableProperty] - private bool isConnected; - - [ObservableProperty] - private bool isSyncing; - - public ObservableCollection KnownServers { get; } = []; - - [ObservableProperty] - private KnownServer? selectedKnownServer; - - public ObservableCollection History { get; } = []; - - public ObservableCollection Addresses { get; } = []; - - /// Wallet disponibili nella cartella della rete, per la schermata di scelta. - public ObservableCollection WalletList { get; } = []; - - // ---- tab indirizzi ---- - - [ObservableProperty] - private AddressRow? selectedAddressRow; - - /// Dettaglio indirizzo mostrato nell'overlay in-app; null = nascosto. - [ObservableProperty] - private AddressInfo? addressInfo; - - /// Apre l'overlay con i dati dell'indirizzo passato. - public void ShowAddressInfo(AddressRow row) => - AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey); - - [RelayCommand] - private void CloseAddressInfo() => AddressInfo = null; - - // ---- overlay dettaglio transazione ---- - - /// Overlay dettaglio transazione aperto. - [ObservableProperty] - private bool isTxDetailsOpen; - - /// Caricamento dei dati in corso (mostra lo spinner nell'overlay). - [ObservableProperty] - private bool isTxDetailsLoading; - - /// Dati della transazione mostrata; null finché non sono pronti. - [ObservableProperty] - private TransactionDetailsViewModel? txDetails; - - private CancellationTokenSource? _txDetailsCts; - - /// - /// Apre l'overlay dettaglio transazione: appare subito con lo spinner e i - /// dati arrivano dal server in background. Overlay in-app (come indirizzo e - /// impostazioni) per apertura/chiusura istantanee, senza una top-level window. - /// - public async Task ShowTransactionDetailsAsync(string txid) - { - if (_client is null || !_client.IsConnected) - { - StatusMessage = Loc.Tr("tx.needconnection"); - return; - } - - _txDetailsCts?.Cancel(); - _txDetailsCts = new CancellationTokenSource(); - var ct = _txDetailsCts.Token; - - TxDetails = null; - IsTxDetailsLoading = true; - IsTxDetailsOpen = true; - - var details = await BuildTransactionDetailsAsync(txid, ct); - - // L'utente potrebbe aver chiuso l'overlay (o aperto un'altra tx) mentre - // caricava: non sovrascrivere lo stato in quel caso. - if (ct.IsCancellationRequested || !IsTxDetailsOpen) - return; - - if (details is null) - { - IsTxDetailsOpen = false; - IsTxDetailsLoading = false; - return; - } - - TxDetails = details; - IsTxDetailsLoading = false; - } - - [RelayCommand] - private void CloseTransactionDetails() - { - _txDetailsCts?.Cancel(); - IsTxDetailsOpen = false; - IsTxDetailsLoading = false; - TxDetails = null; - } - - /// - /// Recupera dal server tutti i dati della transazione - /// (importi, fee, indirizzi, dimensioni, data) e li impacchetta per la - /// finestra di dettaglio. Restituisce null se non c'è connessione o in errore. - /// - public async Task BuildTransactionDetailsAsync( - string txid, CancellationToken ct = default) - { - if (_client is null || !_client.IsConnected) - { - StatusMessage = Loc.Tr("tx.needconnection"); - return null; - } - if (_doc?.Cache is not { } cache) - return null; - - // Snapshot dei dati sull'UI thread, poi tutto il lavoro (round-trip al - // server + parsing delle transazioni) va su un thread pool: altrimenti i - // continuation dei fetch riprendono sull'UI thread e bloccano la finestra - // (spinner fermo, chiusura ritardata). - var client = _client; - var network = PalladiumNetworks.For(Net); - var row = cache.History.FirstOrDefault(t => t.Txid == txid); - var owned = cache.Addresses.Select(a => a.Address).ToHashSet(); - var tipHeight = cache.TipHeight; - var height = row?.Height ?? 0; - var delta = row?.DeltaSats ?? 0; - var verified = row?.Verified ?? false; - var transactions = _lastTransactions; - var loc = _loc; - var unit = _config.Unit; - - try - { - return await Task.Run(async () => - { - var details = await TransactionInspector.FetchAsync( - client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct); - return new TransactionDetailsViewModel(details, loc, unit); - }, ct); - } - catch (OperationCanceledException) - { - return null; - } - catch (Exception ex) - { - StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; - return null; - } - } - - // ---- rubrica contatti ---- - - public ObservableCollection Contacts { get; } = []; - - [ObservableProperty] - private ContactEntry? selectedContactInList; - - /// Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo. - [ObservableProperty] - private ContactEntry? sendToContact; - - partial void OnSendToContactChanged(ContactEntry? value) - { - if (value is not null) - SendTo = value.Address; - } - - [ObservableProperty] - private string newContactName = ""; - - [ObservableProperty] - private string newContactAddress = ""; - - [RelayCommand] - private void AddContact() - { - var name = NewContactName.Trim(); - var addr = NewContactAddress.Trim(); - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return; - Contacts.Add(new ContactEntry(name, addr)); - NewContactName = NewContactAddress = ""; - PersistContacts(); - } - - [RelayCommand] - private void RemoveSelectedContact() - { - if (SelectedContactInList is { } c) - { - Contacts.Remove(c); - SelectedContactInList = null; - PersistContacts(); - } - } - - private void PersistContacts() - { - if (_doc is null || _walletPath is null) return; - _doc.Contacts = Contacts - .Select(c => new PalladiumWallet.Core.Storage.StoredContact { Name = c.Name, Address = c.Address }) - .ToList(); - WalletStore.Save(_doc, _walletPath, _password); - } - - private void LoadContacts() - { - Contacts.Clear(); - if (_doc is null) return; - foreach (var c in _doc.Contacts) - Contacts.Add(new ContactEntry(c.Name, c.Address)); - } - - // ---- pannello invia ---- - - [ObservableProperty] - private string sendTo = ""; - - [ObservableProperty] - private string sendAmount = ""; - - [ObservableProperty] - private string sendFeeRate = "1"; - - [ObservableProperty] - private bool sendAll; - - [ObservableProperty] - private string sendPreview = ""; - - [ObservableProperty] - private bool hasPendingSend; + // ---- costruttore ---- public MainWindowViewModel() { _loc = Loc.SwitchTo(_config.Language); - // Primo avvio: se la posizione dei dati non è ancora determinata, - // chiediamola prima di tutto il resto del wizard. if (!AppPaths.IsDataLocationConfigured()) SetupStep = StepDataLocation; else RefreshSetupState(); - // Aggiornamenti continui (§9): ping periodico per tenere viva la - // connessione e accorgersi subito della caduta; se cade, riconnette - // e risincronizza da solo. Le notifiche push restano la via principale. - _keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) }; + _keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) }; _keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync(); _keepAliveTimer.Start(); } - private async Task KeepAliveTickAsync() + private async System.Threading.Tasks.Task KeepAliveTickAsync() { - // Vale anche senza wallet aperto: se l'utente si è connesso dalle - // impostazioni server, la connessione va mantenuta e riconnessa. if (IsSyncing) return; if (_client is { IsConnected: true }) { - try - { - await _client.PingAsync(); - } - catch - { - // La caduta viene gestita dall'evento Disconnected. - } + try { await _client.PingAsync(); } + catch { } } else if (_autoReconnect) { @@ -610,669 +170,7 @@ public partial class MainWindowViewModel : ViewModelBase } } - private NetKind Net => Enum.Parse(SelectedNetwork, ignoreCase: true); - private ChainProfile Profile => ChainProfiles.For(Net); - - partial void OnSelectedNetworkChanged(string value) => RefreshSetupState(); - - private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); - - /// Primo avvio: usa il percorso dati predefinito di piattaforma. - [RelayCommand] - private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot()); - - /// - /// Memorizza la radice dati scelta (default o personalizzata), ricarica la - /// configurazione dalla nuova posizione e prosegue col wizard. Chiamata anche - /// dalla View dopo la scelta di una cartella. - /// - public void ApplyDataLocation(string root) - { - try - { - AppPaths.ConfigureDataLocation(root); - } - catch (Exception ex) - { - StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; - return; - } - // La config globale vive nella radice dati: ricaricala da lì. - _config = AppConfig.Load(); - _loc = Loc.SwitchTo(_config.Language); - OnPropertyChanged(nameof(Loc)); - OnPropertyChanged(nameof(UnitLabel)); - RefreshSetupState(); - } - - private void RefreshSetupState() - { - SetupStep = StepStart; - MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; - WalletFileExists = AppPaths.WalletFiles(Net).Count > 0; - StatusMessage = ""; - RefreshServers(); - // Come Electrum: ci si connette al server già durante il setup, senza - // aspettare l'apertura di un wallet (la sync parte poi all'apertura). - _ = ConnectAndSync(); - } - - private void RefreshServers() - { - KnownServers.Clear(); - foreach (var server in Registry.All) - KnownServers.Add(server); - SelectedKnownServer = KnownServers.FirstOrDefault(); - if (SelectedKnownServer is null) - { - ServerHost = "127.0.0.1"; - ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); - } - } - - /// Evita la ricorsione tra OnUseSslChanged e OnServerPortChanged - /// mentre tengono coerenti porta e flag TLS. - private bool _syncingServerFields; - - partial void OnSelectedKnownServerChanged(KnownServer? value) - { - if (value is null) - return; - _syncingServerFields = true; - ServerHost = value.Host; - ServerPort = value.PortFor(UseSsl).ToString(); - _syncingServerFields = false; - } - - partial void OnUseSslChanged(bool value) - { - if (_syncingServerFields) - return; - // Attivare/disattivare TLS allinea la porta: porta SSL del server - // selezionato (o default di profilo) quando TLS è on, porta TCP quando off. - _syncingServerFields = true; - ServerPort = SelectedKnownServer is { } server - ? server.PortFor(value).ToString() - : (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString(); - _syncingServerFields = false; - } - - partial void OnServerPortChanged(string value) - { - if (_syncingServerFields) - return; - if (!int.TryParse(value.Trim(), out var port)) - return; - // Scegliere una porta nota allinea il flag TLS, così non si tenta mai - // una connessione in chiaro sulla porta SSL (causa di "connection error"). - bool? wantSsl = - SelectedKnownServer is { } s && port == s.SslPort ? true : - SelectedKnownServer is { } t && port == t.TcpPort ? false : - port == Profile.DefaultSslPort ? true : - port == Profile.DefaultTcpPort ? false : - null; - if (wantSsl is bool b && b != UseSsl) - { - _syncingServerFields = true; - UseSsl = b; - _syncingServerFields = false; - } - } - - // ---------- comandi del wizard (§15): un passo alla volta ---------- - - [RelayCommand] - private void WizardStartOpen() - { - // Con più wallet nella cartella della rete si sceglie quale aprire; - // con uno solo si va dritti alla password (multi-wallet §8). - var files = AppPaths.WalletFiles(Net); - if (files.Count > 1) - { - WalletList.Clear(); - foreach (var path in files) - WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path)); - SetupStep = StepChooseWallet; - StatusMessage = ""; - return; - } - _pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net); - PasswordInput = ""; - SetupStep = StepOpen; - StatusMessage = ""; - } - - /// Sceglie quale wallet aprire dalla lista e passa alla password. - [RelayCommand] - private void ChooseWallet(WalletFileEntry? entry) - { - if (entry is null) - return; - _pendingOpenPath = entry.Path; - PasswordInput = ""; - SetupStep = StepOpen; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardStartNew() - { - _isRestoreFlow = false; - MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString(); - SetupStep = StepShowSeed; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardStartRestore() - { - _isRestoreFlow = true; - MnemonicInput = ""; - SetupStep = StepWords; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardNextFromShowSeed() - { - ConfirmMnemonicInput = ""; - SetupStep = StepConfirmSeed; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardNextFromConfirmSeed() - { - var normalized = string.Join(' ', - ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase)) - { - StatusMessage = Loc.Tr("msg.seed.mismatch"); - return; - } - GoToPassphraseStep(); - } - - [RelayCommand] - private void WizardNextFromWords() - { - if (!Bip39.TryParse(MnemonicInput, out _)) - { - StatusMessage = Loc.Tr("msg.words.invalid"); - return; - } - GoToPassphraseStep(); - } - - private void GoToPassphraseStep() - { - PassphraseInput = ""; - SetupStep = StepPassphrase; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardNextFromPassphrase() - { - PasswordInput = ConfirmPasswordInput = ""; - EncryptWallet = true; - SetupStep = StepPassword; - StatusMessage = ""; - } - - [RelayCommand] - private void WizardBack() - { - SetupStep = SetupStep switch - { - StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart, - StepChooseWallet or StepShowSeed or StepWords => StepStart, - StepConfirmSeed => StepShowSeed, - StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed, - StepPassword => StepPassphrase, - _ => StepStart, - }; - if (SetupStep == StepStart) - RefreshSetupState(); - } - - [RelayCommand] - private void CreateOrRestore() - { - // Scelta cifratura stile Electrum: con cifratura attiva serve una - // password non vuota, digitata due volte e coincidente. Solo se la - // cifratura è disattivata il file resta in chiaro (avviso esplicito). - string? password; - if (EncryptWallet) - { - if (string.IsNullOrEmpty(PasswordInput)) - { - StatusMessage = Loc.Tr("msg.password.required"); - return; - } - if (PasswordInput != ConfirmPasswordInput) - { - StatusMessage = Loc.Tr("msg.password.mismatch"); - return; - } - password = PasswordInput; - } - else - { - password = null; - } - - try - { - var (doc, account) = WalletLoader.NewFromMnemonic( - MnemonicInput, - string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput, - ScriptKind.NativeSegwit, - Profile); - // Mai sovrascrivere un wallet esistente: si cerca il primo nome libero. - var path = AppPaths.DefaultWalletPath(Net); - for (var n = 2; WalletStore.Exists(path); n++) - path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json"); - WalletStore.Save(doc, path, password); - var newLock = WalletLock.TryAcquire(path); - if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } - OpenLoaded(doc, account, path, password, newLock); - } - catch (Exception ex) - { - StatusMessage = $"Errore: {ex.Message}"; - } - } - - [RelayCommand] - private void OpenExisting() - { - var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net); - var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput; - - // Il lock viene acquisito prima del caricamento: se il wallet è già aperto - // altrove, non ha senso nemmeno tentare la decifrazione. - var newLock = WalletLock.TryAcquire(path); - if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } - - try - { - var doc = WalletStore.Load(path, password); - _pendingOpenPath = null; - OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password, newLock); - } - catch (WrongPasswordException) - { - newLock.Dispose(); - StatusMessage = Loc.Tr("msg.wrongpassword"); - } - catch (UnauthorizedAccessException) - { - newLock.Dispose(); - StatusMessage = Loc.Tr("msg.wallet.noaccess"); - } - catch (Exception ex) - { - newLock.Dispose(); - StatusMessage = $"Errore: {ex.Message}"; - } - } - - /// Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8). - public void OpenFromPath(string path) - { - // Il lock viene acquisito prima di chiudere l'eventuale wallet aperto: - // se il nuovo wallet non è disponibile, la sessione corrente resta intatta. - var newLock = WalletLock.TryAcquire(path); - if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; } - - try - { - var doc = WalletStore.Load(path); - if (IsWalletOpen) - CloseWallet(); - OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock); - } - catch (WrongPasswordException) - { - // Cifrato: si chiede la password nel passo di apertura del wizard. - newLock.Dispose(); - if (IsWalletOpen) - CloseWallet(); - _pendingOpenPath = path; - WalletFileExists = true; - PasswordInput = ""; - SetupStep = StepOpen; - StatusMessage = ""; - } - catch (UnauthorizedAccessException) - { - newLock.Dispose(); - StatusMessage = Loc.Tr("msg.wallet.noaccess"); - } - catch (Exception ex) - { - newLock.Dispose(); - StatusMessage = $"Errore: {ex.Message}"; - } - } - - /// Torna al pannello di setup per creare/ripristinare un altro wallet. - [RelayCommand] - private void NewWallet() - { - if (IsWalletOpen) - CloseWallet(); - _pendingOpenPath = null; - StatusMessage = ""; - } - - private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password, WalletLock walletLock) - { - _walletLock?.Dispose(); - _walletLock = walletLock; - - // La rete del wallet comanda (registry, pin TLS, indirizzi). - SelectedNetwork = doc.Network; - _doc = doc; - _account = account; - _walletPath = path; - _password = password; - MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = ""; - SetupStep = StepStart; - - NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}" - + (doc.IsWatchOnly ? " · watch-only" : ""); - LoadContacts(); - ApplyCache(doc.Cache); - IsWalletOpen = true; - StatusMessage = Loc.Tr("msg.opened"); - // Come Electrum: ci si connette da soli al server selezionato, - // senza aspettare un click. - _ = ConnectAndSync(); - } - - private void ApplyCache(SyncCache? cache) - { - if (_account is null) - return; - if (cache is null) - { - BalanceText = $"0.00000000 {Profile.CoinUnit}"; - UnconfirmedText = ""; - ReceiveAddress = _account.GetReceiveAddress(0).ToString(); - History.Clear(); - // Prima della sincronizzazione si mostrano i primi indirizzi derivati. - Addresses.Clear(); - for (var i = 0; i < 10; i++) - Addresses.Add(new AddressRow(_loc["addr.receive"], i, - _account.GetReceiveAddress(i).ToString(), "—", "—", - false, - _account.GetPublicKey(false, i).ToHex(), - KeyWif(false, i), - $"m/{_doc!.AccountPath}/0/{i}")); - return; - } - BalanceText = Fmt(cache.ConfirmedSats); - // Saldo in attesa: somma delle tx in mempool (può essere negativo per - // gli invii in uscita non ancora confermati). Non è spendibile finché - // non conferma: la TransactionFactory usa solo UTXO confermati. - var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); - UnconfirmedText = pending != 0 - ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" - : ""; - ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); - History.Clear(); - foreach (var tx in cache.History) - History.Add(new HistoryRow( - tx.Height > 0 ? tx.Height.ToString() : "mempool", - (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false), - tx.Txid, - tx.Verified ? "✓ SPV" : "—")); - - Addresses.Clear(); - foreach (var a in cache.Addresses) - Addresses.Add(new AddressRow( - a.IsChange ? _loc["addr.change"] : _loc["addr.receive"], - a.Index, - a.Address, - a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"), - a.TxCount > 0 ? a.TxCount.ToString() : "—", - a.IsChange, - _account.GetPublicKey(a.IsChange, a.Index).ToHex(), - KeyWif(a.IsChange, a.Index), - $"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}")); - } - - // ---------- comandi wallet ---------- - - [RelayCommand] - private async Task ConnectAndSync() - { - if (IsSyncing) - { - _resyncRequested = true; - return; - } - IsSyncing = true; - StatusMessage = ""; - try - { - var (host, port) = ParseServer(); - - // Se l'endpoint richiesto (host/porta/TLS) è diverso da quello della - // connessione attiva, la chiudo: l'utente ha cambiato server o ha - // attivato TLS e si aspetta che la nuova scelta abbia effetto. - if (_client is { } current && - (current.Host != host || current.Port != port || current.UseSsl != UseSsl)) - { - await DisconnectAsync(); - } - - if (_client is null || !_client.IsConnected) - { - ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…"; - var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); - _client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins); - _client.NotificationReceived += OnServerNotification; - _client.Disconnected += _ => Dispatcher.UIThread.Post(() => - { - IsConnected = false; - ConnectionStatus = Loc.Tr("conn.disconnected"); - }); - IsConnected = true; - _autoReconnect = true; - ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}"; - } - - // Senza un wallet aperto ci si limita a stabilire/verificare la - // connessione: la sincronizzazione richiede un account. - if (_account is null || _doc is null) - return; - - // Synchronizer legato all'account corrente, creato alla prima sync - // dopo la connessione: conserva la cache di tx e prove verificate, - // così le risincronizzazioni sono incrementali. - if (_synchronizer is null) - { - _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); - _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); - } - - // Se durante la sync arrivano notifiche, si ripete subito: nessun - // aggiornamento del server va perso (modello Electrum). - do - { - _resyncRequested = false; - var result = await _synchronizer.SyncOnceAsync(); - _lastTransactions = result.Transactions; - - _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, _walletPath!, _password); - ApplyCache(_doc.Cache); - StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + - $"{result.History.Count} {Loc.Tr("msg.synced.detail")}"; - } while (_resyncRequested); - } - catch (CertificatePinMismatchException ex) - { - IsConnected = false; - ConnectionStatus = Loc.Tr("conn.certchanged"); - StatusMessage = ex.Message; - } - catch (Exception ex) - { - IsConnected = _client?.IsConnected == true; - ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error"); - StatusMessage = $"Errore: {ex.Message}"; - } - finally - { - IsSyncing = false; - } - } - - /// Scopre nuovi server dai peer annunciati dal server connesso (§9). - [RelayCommand] - private async Task DiscoverServers() - { - if (_client is null || !_client.IsConnected) - { - StatusMessage = Loc.Tr("conn.none") + "."; - return; - } - try - { - var added = await Registry.DiscoverAsync(_client); - var selected = SelectedKnownServer; - RefreshServers(); - SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host) - ?? KnownServers.FirstOrDefault(); - StatusMessage = added > 0 - ? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})." - : $"Nessun nuovo server annunciato (totale {KnownServers.Count})."; - } - catch (Exception ex) - { - StatusMessage = $"Errore nella scoperta peer: {ex.Message}"; - } - } - - private void OnServerNotification(string method, System.Text.Json.JsonElement payload) - { - // Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza. - // Se una sync è già in corso, si accoda (il loop in ConnectAndSync la - // ripete subito dopo): nessuna notifica viene persa. - if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe") - Dispatcher.UIThread.Post(() => - { - if (IsSyncing) - _resyncRequested = true; - else - _ = ConnectAndSync(); - }); - } - - [RelayCommand] - private void ResetCertificates() - { - new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll(); - StatusMessage = Loc.Tr("msg.certreset"); - } - - [RelayCommand] - private async Task PrepareSend() - { - if (_account is null || _doc?.Cache is null) - { - SendPreview = "Sincronizza prima di inviare."; - return; - } - try - { - if (_lastTransactions is null) - { - SendPreview = "Connettiti al server e sincronizza prima di inviare."; - return; - } - - var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net)); - long amount = 0; - if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount)) - { - SendPreview = "Importo non valido."; - return; - } - if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0) - { - SendPreview = "Fee rate non valido."; - return; - } - - _pendingSend = new TransactionFactory(_account).Build( - _doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate, - _doc.Cache.NextChangeIndex, SendAll); - - SendPreview = $"txid {_pendingSend.Txid[..16]}… · " + - $"fee {Fmt(_pendingSend.Fee.Satoshi)} " + - $"({_pendingSend.Transaction.GetVirtualSize()} vB)" + - (_pendingSend.Signed ? "" : " · NON firmata (watch-only)"); - HasPendingSend = _pendingSend.Signed; - } - catch (Exception ex) - { - _pendingSend = null; - HasPendingSend = false; - SendPreview = $"Errore: {ex.Message}"; - } - await Task.CompletedTask; - } - - [RelayCommand] - private async Task ConfirmSend() - { - if (_pendingSend is null || _client is null) - return; - try - { - var txid = await _client.BroadcastAsync(_pendingSend.ToHex()); - SendPreview = $"Trasmessa: {txid}"; - SendTo = SendAmount = ""; - _pendingSend = null; - HasPendingSend = false; - await ConnectAndSync(); - } - catch (Exception ex) - { - SendPreview = $"Errore broadcast: {ex.Message}"; - } - } - - /// - /// Chiude la connessione corrente lasciando il wallet aperto: usata quando - /// l'utente cambia server o attiva TLS e serve riconnettersi al nuovo - /// endpoint. Attende la chiusura del socket prima di tornare. - /// - private async Task DisconnectAsync() - { - if (_client is { } client) - { - _client = null; - _synchronizer = null; - try { await client.DisposeAsync(); } catch { /* in chiusura */ } - } - IsConnected = false; - } + // ---- ciclo di vita wallet ---- [RelayCommand] private void CloseWallet() @@ -1299,13 +197,4 @@ public partial class MainWindowViewModel : ViewModelBase ConnectionStatus = Loc.Tr("conn.none"); RefreshSetupState(); } - - private (string Host, int Port) ParseServer() - { - var host = ServerHost.Trim(); - var port = int.TryParse(ServerPort.Trim(), out var p) - ? p - : UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort; - return (host, port); - } }