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; using NBitcoin; using PalladiumWallet.App.Localization; using PalladiumWallet.Core.Chain; using PalladiumWallet.Core.Crypto; using PalladiumWallet.Core.Net; using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Wallet; using QRCoder; namespace PalladiumWallet.App.ViewModels; /// Riga dello storico transazioni per la vista. public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata); /// Riga della vista indirizzi con chiavi e derivation path pre-calcolati. public sealed record AddressRow( string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx, bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "") { public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey); } /// Dati completi di un indirizzo passati alla finestra di dettaglio. public sealed record AddressInfo( Localization.Loc Loc, string Address, string DerivPath, string PubKey, string PrivKey) { public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey); } /// Contatto in rubrica: nome + indirizzo blockchain. public sealed record ContactEntry(string Name, string Address); /// Voce della lista di scelta wallet: nome file + percorso completo. 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). /// public partial class MainWindowViewModel : ViewModelBase { private WalletDocument? _doc; private HdAccount? _account; private string? _walletPath; private string? _password; private WalletLock? _walletLock; 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(); /// Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua /// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}. private Loc _loc = Loc.Instance; public Loc Loc => _loc; /// Versione dell'app (da <Version> nel csproj), formato Major.Minor.Patch. 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. 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"; [RelayCommand] private void SetLanguage(string language) { _config.Language = language; ApplySettings(_config); } [RelayCommand] private void SetUnit(string unit) { _config.Unit = unit; ApplySettings(_config); } /// 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"); } /// 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 ""; try { return _account.GetExtPrivateKey(isChange, index) .PrivateKey.GetWif(PalladiumNetworks.For(Net)).ToString(); } 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; 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.Tick += async (_, _) => await KeepAliveTickAsync(); _keepAliveTimer.Start(); } private async 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. } } else if (_autoReconnect) { ConnectionStatus = Loc.Tr("conn.reconnecting"); await ConnectAndSync(); } } 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; } [RelayCommand] private void CloseWallet() { _walletLock?.Dispose(); _walletLock = null; _ = _client?.DisposeAsync().AsTask(); _client = null; _synchronizer = null; _autoReconnect = false; _resyncRequested = false; _doc = null; _account = null; _lastTransactions = null; _pendingSend = null; HasPendingSend = false; History.Clear(); Contacts.Clear(); SelectedContactInList = null; SendToContact = null; SelectedAddressRow = null; IsWalletOpen = false; IsConnected = false; 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); } }