Files
PalladiumWallet/src/App/ViewModels/MainWindowViewModel.cs
T
davide 58645bd7a7 fix(storage): acquire wallet lock before load and before CloseWallet
Three improvements to WalletLock integration:

1. OpenExisting acquires the lock before decrypting the file — no point
   attempting a potentially expensive PBKDF2 derivation for a wallet
   already held by another instance.

2. OpenFromPath acquires the lock before CloseWallet — if the new wallet
   is unavailable, the current session stays intact instead of being
   silently destroyed.

3. OpenLoaded now receives an already-acquired WalletLock from the caller
   instead of acquiring it internally; TryAcquireWalletLock is removed.
   Callers that fail to open (wrong password, I/O error) dispose the lock
   in their catch blocks.

Also fix CoinAmount.Of() to throw ArgumentException for unknown units
instead of silently falling back to PLM.
2026-06-13 20:50:22 +02:00

1312 lines
45 KiB
C#

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;
/// <summary>Riga dello storico transazioni per la vista.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
/// <summary>Riga della vista indirizzi con chiavi e derivation path pre-calcolati.</summary>
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);
}
/// <summary>Dati completi di un indirizzo passati alla finestra di dettaglio.</summary>
public sealed record AddressInfo(
Localization.Loc Loc,
string Address, string DerivPath, string PubKey, string PrivKey)
{
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
}
/// <summary>Contatto in rubrica: nome + indirizzo blockchain.</summary>
public sealed record ContactEntry(string Name, string Address);
/// <summary>Voce della lista di scelta wallet: nome file + percorso completo.</summary>
public sealed record WalletFileEntry(string Name, string Path);
/// <summary>
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
/// pannello di setup (crea/ripristina/apri) e pannello wallet
/// (saldo, ricevi, storico, invia, server).
/// </summary>
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<string, Transaction>? _lastTransactions;
private BuiltTransaction? _pendingSend;
/// <summary>Notifica arrivata durante una sync: si risincronizza appena finita.</summary>
private bool _resyncRequested;
/// <summary>Configurazione globale (§8): lingua e unità.</summary>
private AppConfig _config = AppConfig.Load();
/// <summary>Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua
/// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}.</summary>
private Loc _loc = Loc.Instance;
public Loc Loc => _loc;
/// <summary>Versione dell'app (da &lt;Version&gt; nel csproj), formato Major.Minor.Patch.</summary>
public static string AppVersion =>
typeof(MainWindowViewModel).Assembly.GetName().Version is { } v
? $"{v.Major}.{v.Minor}.{v.Build}"
: "";
/// <summary>Titolo della finestra con la versione, es. "Palladium Wallet 0.9.0".</summary>
public string WindowTitle => $"Palladium Wallet {AppVersion}";
/// <summary>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.</summary>
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
/// <summary>Unità corrente per il campo importo del pannello Invia.</summary>
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);
}
/// <summary>Applica e persiste le impostazioni (§8).</summary>
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");
}
/// <summary>Formatta un importo nell'unità scelta nelle impostazioni.</summary>
private string Fmt(long sats, bool withLabel = true) =>
CoinAmount.FormatIn(sats, _config.Unit, withLabel);
/// <summary>Chiave privata WIF per un indirizzo; stringa vuota se watch-only.</summary>
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 ""; }
}
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
private string? _pendingOpenPath;
/// <summary>Dopo la prima connessione riuscita il timer riconnette da solo.</summary>
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;
/// <summary>Percorso dati predefinito, mostrato al primo avvio.</summary>
public string DefaultDataPath => AppPaths.DefaultDataRoot();
/// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary>
private bool _isRestoreFlow;
[ObservableProperty]
private string mnemonicInput = "";
[ObservableProperty]
private string confirmMnemonicInput = "";
[ObservableProperty]
private string passphraseInput = "";
[ObservableProperty]
private string passwordInput = "";
/// <summary>Conferma password alla creazione (stile Electrum: digitata due volte).</summary>
[ObservableProperty]
private string confirmPasswordInput = "";
/// <summary>Se true il file wallet viene cifrato con la password (default, stile Electrum).</summary>
[ObservableProperty]
private bool encryptWallet = true;
// ---- pannello wallet ----
[ObservableProperty]
private string balanceText = "—";
[ObservableProperty]
private string unconfirmedText = "";
[ObservableProperty]
private string networkInfo = "";
[ObservableProperty]
private string receiveAddress = "";
/// <summary>QR code dell'indirizzo di ricezione corrente (PNG in-memory).</summary>
[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();
}
/// <summary>Feedback dopo la copia dell'indirizzo negli appunti (chiamato dal code-behind).</summary>
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 = "";
/// <summary>Si predilige TLS: la connessione automatica all'apertura del
/// wallet usa la porta SSL del server. L'utente può disattivarlo.</summary>
[ObservableProperty]
private bool useSsl = true;
/// <summary>Overlay impostazioni server: aperto dall'overlay Impostazioni.</summary>
[ObservableProperty]
private bool isServerSettingsOpen;
[RelayCommand]
private void OpenServerSettings()
{
IsSettingsOpen = false;
IsServerSettingsOpen = true;
}
[RelayCommand]
private void CloseServerSettings() => IsServerSettingsOpen = false;
/// <summary>Overlay impostazioni (lingua, unità, server): in-app per evitare
/// i popup di menu annidati, lenti su WSLg.</summary>
[ObservableProperty]
private bool isSettingsOpen;
[RelayCommand]
private void OpenSettings() => IsSettingsOpen = true;
[RelayCommand]
private void CloseSettings() => IsSettingsOpen = false;
/// <summary>Overlay informazioni sul software (menu Help).</summary>
[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<KnownServer> KnownServers { get; } = [];
[ObservableProperty]
private KnownServer? selectedKnownServer;
public ObservableCollection<HistoryRow> History { get; } = [];
public ObservableCollection<AddressRow> Addresses { get; } = [];
/// <summary>Wallet disponibili nella cartella della rete, per la schermata di scelta.</summary>
public ObservableCollection<WalletFileEntry> WalletList { get; } = [];
// ---- tab indirizzi ----
[ObservableProperty]
private AddressRow? selectedAddressRow;
/// <summary>Dettaglio indirizzo mostrato nell'overlay in-app; null = nascosto.</summary>
[ObservableProperty]
private AddressInfo? addressInfo;
/// <summary>Apre l'overlay con i dati dell'indirizzo passato.</summary>
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 ----
/// <summary>Overlay dettaglio transazione aperto.</summary>
[ObservableProperty]
private bool isTxDetailsOpen;
/// <summary>Caricamento dei dati in corso (mostra lo spinner nell'overlay).</summary>
[ObservableProperty]
private bool isTxDetailsLoading;
/// <summary>Dati della transazione mostrata; null finché non sono pronti.</summary>
[ObservableProperty]
private TransactionDetailsViewModel? txDetails;
private CancellationTokenSource? _txDetailsCts;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Recupera dal server tutti i dati della transazione <paramref name="txid"/>
/// (importi, fee, indirizzi, dimensioni, data) e li impacchetta per la
/// finestra di dettaglio. Restituisce null se non c'è connessione o in errore.
/// </summary>
public async Task<TransactionDetailsViewModel?> 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<ContactEntry> Contacts { get; } = [];
[ObservableProperty]
private ContactEntry? selectedContactInList;
/// <summary>Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo.</summary>
[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<NetKind>(SelectedNetwork, ignoreCase: true);
private ChainProfile Profile => ChainProfiles.For(Net);
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
/// <summary>Primo avvio: usa il percorso dati predefinito di piattaforma.</summary>
[RelayCommand]
private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot());
/// <summary>
/// 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.
/// </summary>
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();
}
}
/// <summary>Evita la ricorsione tra OnUseSslChanged e OnServerPortChanged
/// mentre tengono coerenti porta e flag TLS.</summary>
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 = "";
}
/// <summary>Sceglie quale wallet aprire dalla lista e passa alla password.</summary>
[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}";
}
}
/// <summary>Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8).</summary>
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}";
}
}
/// <summary>Torna al pannello di setup per creare/ripristinare un altro wallet.</summary>
[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;
}
}
/// <summary>Scopre nuovi server dai peer annunciati dal server connesso (§9).</summary>
[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}";
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}