2026-06-11 10:47:52 +02:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Collections.ObjectModel;
|
2026-06-11 11:27:41 +02:00
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
2026-06-11 10:47:52 +02:00
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using Avalonia.Threading;
|
|
|
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
|
|
|
using CommunityToolkit.Mvvm.Input;
|
|
|
|
|
using NBitcoin;
|
2026-06-11 16:23:02 +02:00
|
|
|
using PalladiumWallet.App.Localization;
|
2026-06-11 10:47:52 +02:00
|
|
|
using PalladiumWallet.Core.Chain;
|
|
|
|
|
using PalladiumWallet.Core.Crypto;
|
|
|
|
|
using PalladiumWallet.Core.Net;
|
|
|
|
|
using PalladiumWallet.Core.Spv;
|
|
|
|
|
using PalladiumWallet.Core.Storage;
|
|
|
|
|
using PalladiumWallet.Core.Wallet;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary>
|
|
|
|
|
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx);
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
/// <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 ElectrumClient? _client;
|
2026-06-11 13:37:31 +02:00
|
|
|
private WalletSynchronizer? _synchronizer;
|
2026-06-11 10:47:52 +02:00
|
|
|
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
|
|
|
|
private BuiltTransaction? _pendingSend;
|
|
|
|
|
|
2026-06-11 13:37:31 +02:00
|
|
|
/// <summary>Notifica arrivata durante una sync: si risincronizza appena finita.</summary>
|
|
|
|
|
private bool _resyncRequested;
|
|
|
|
|
|
2026-06-11 16:23:02 +02:00
|
|
|
/// <summary>Configurazione globale (§8): lingua e unità.</summary>
|
|
|
|
|
private AppConfig _config = AppConfig.Load();
|
|
|
|
|
|
2026-06-11 18:41:31 +02:00
|
|
|
/// <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;
|
2026-06-11 16:23:02 +02:00
|
|
|
|
|
|
|
|
/// <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";
|
2026-06-11 18:41:31 +02:00
|
|
|
public bool IsLangEs => _config.Language == "es";
|
|
|
|
|
public bool IsLangFr => _config.Language == "fr";
|
|
|
|
|
public bool IsLangPt => _config.Language == "pt";
|
|
|
|
|
public bool IsLangDe => _config.Language == "de";
|
2026-06-11 16:23:02 +02:00
|
|
|
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();
|
2026-06-11 18:41:31 +02:00
|
|
|
_loc = Loc.SwitchTo(config.Language);
|
|
|
|
|
OnPropertyChanged(nameof(Loc));
|
2026-06-11 16:23:02 +02:00
|
|
|
OnPropertyChanged(nameof(UnitLabel));
|
|
|
|
|
OnPropertyChanged(nameof(IsLangIt));
|
|
|
|
|
OnPropertyChanged(nameof(IsLangEn));
|
2026-06-11 18:41:31 +02:00
|
|
|
OnPropertyChanged(nameof(IsLangEs));
|
|
|
|
|
OnPropertyChanged(nameof(IsLangFr));
|
|
|
|
|
OnPropertyChanged(nameof(IsLangPt));
|
|
|
|
|
OnPropertyChanged(nameof(IsLangDe));
|
2026-06-11 16:23:02 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
/// <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;
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
// ---- 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 = "";
|
|
|
|
|
|
2026-06-11 16:04:25 +02:00
|
|
|
// ---- wizard di setup (§15): un passo alla volta ----
|
|
|
|
|
|
|
|
|
|
public const string StepStart = "start";
|
|
|
|
|
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(IsStepStart))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsStepOpen))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
|
|
|
|
private string setupStep = StepStart;
|
|
|
|
|
|
|
|
|
|
public bool IsStepStart => SetupStep == StepStart;
|
|
|
|
|
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>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary>
|
|
|
|
|
private bool _isRestoreFlow;
|
2026-06-11 10:47:52 +02:00
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string mnemonicInput = "";
|
|
|
|
|
|
2026-06-11 16:04:25 +02:00
|
|
|
[ObservableProperty]
|
|
|
|
|
private string confirmMnemonicInput = "";
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
[ObservableProperty]
|
|
|
|
|
private string passphraseInput = "";
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string passwordInput = "";
|
|
|
|
|
|
|
|
|
|
// ---- pannello wallet ----
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string balanceText = "—";
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string unconfirmedText = "";
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string networkInfo = "";
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string receiveAddress = "";
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string serverInput = "";
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private bool useSsl;
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
2026-06-11 16:23:02 +02:00
|
|
|
private string connectionStatus = Loc.Tr("conn.none");
|
2026-06-11 10:47:52 +02:00
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
[ObservableProperty]
|
|
|
|
|
private bool isConnected;
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
[ObservableProperty]
|
|
|
|
|
private bool isSyncing;
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private KnownServer? selectedKnownServer;
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
public ObservableCollection<HistoryRow> History { get; } = [];
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
// ---- 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()
|
|
|
|
|
{
|
2026-06-11 18:41:31 +02:00
|
|
|
_loc = Loc.SwitchTo(_config.Language);
|
2026-06-11 10:47:52 +02:00
|
|
|
RefreshSetupState();
|
2026-06-11 11:27:41 +02:00
|
|
|
// 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()
|
|
|
|
|
{
|
|
|
|
|
if (!IsWalletOpen || IsSyncing)
|
|
|
|
|
return;
|
|
|
|
|
if (_client is { IsConnected: true })
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await _client.PingAsync();
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// La caduta viene gestita dall'evento Disconnected.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (_autoReconnect)
|
|
|
|
|
{
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
2026-06-11 11:27:41 +02:00
|
|
|
await ConnectAndSync();
|
|
|
|
|
}
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private NetKind Net => Enum.Parse<NetKind>(SelectedNetwork, ignoreCase: true);
|
|
|
|
|
private ChainProfile Profile => ChainProfiles.For(Net);
|
|
|
|
|
|
|
|
|
|
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
private void RefreshSetupState()
|
|
|
|
|
{
|
2026-06-11 16:04:25 +02:00
|
|
|
SetupStep = StepStart;
|
|
|
|
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
|
2026-06-11 10:47:52 +02:00
|
|
|
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
|
|
|
|
|
StatusMessage = WalletFileExists
|
2026-06-11 16:23:02 +02:00
|
|
|
? Loc.Tr("msg.welcome.existing")
|
|
|
|
|
: Loc.Tr("msg.welcome.new");
|
2026-06-11 11:27:41 +02:00
|
|
|
RefreshServers();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void RefreshServers()
|
|
|
|
|
{
|
|
|
|
|
KnownServers.Clear();
|
|
|
|
|
foreach (var server in Registry.All)
|
|
|
|
|
KnownServers.Add(server);
|
|
|
|
|
SelectedKnownServer = KnownServers.FirstOrDefault();
|
|
|
|
|
if (SelectedKnownServer is null)
|
|
|
|
|
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
partial void OnSelectedKnownServerChanged(KnownServer? value)
|
|
|
|
|
{
|
|
|
|
|
if (value is not null)
|
|
|
|
|
ServerInput = $"{value.Host}:{value.PortFor(UseSsl)}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
partial void OnUseSslChanged(bool value)
|
|
|
|
|
{
|
|
|
|
|
if (SelectedKnownServer is { } server)
|
|
|
|
|
ServerInput = $"{server.Host}:{server.PortFor(value)}";
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 16:04:25 +02:00
|
|
|
// ---------- comandi del wizard (§15): un passo alla volta ----------
|
2026-06-11 10:47:52 +02:00
|
|
|
|
|
|
|
|
[RelayCommand]
|
2026-06-11 16:04:25 +02:00
|
|
|
private void WizardStartOpen()
|
2026-06-11 10:47:52 +02:00
|
|
|
{
|
2026-06-11 16:04:25 +02:00
|
|
|
PasswordInput = "";
|
|
|
|
|
SetupStep = StepOpen;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.open.password");
|
2026-06-11 16:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardStartNew()
|
|
|
|
|
{
|
|
|
|
|
_isRestoreFlow = false;
|
2026-06-11 10:47:52 +02:00
|
|
|
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
2026-06-11 16:04:25 +02:00
|
|
|
SetupStep = StepShowSeed;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.seed.write");
|
2026-06-11 16:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardStartRestore()
|
|
|
|
|
{
|
|
|
|
|
_isRestoreFlow = true;
|
|
|
|
|
MnemonicInput = "";
|
|
|
|
|
SetupStep = StepWords;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.words.enter");
|
2026-06-11 16:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromShowSeed()
|
|
|
|
|
{
|
|
|
|
|
ConfirmMnemonicInput = "";
|
|
|
|
|
SetupStep = StepConfirmSeed;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.seed.retype");
|
2026-06-11 16:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromConfirmSeed()
|
|
|
|
|
{
|
|
|
|
|
var normalized = string.Join(' ',
|
|
|
|
|
ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
|
|
|
|
if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.seed.mismatch");
|
2026-06-11 16:04:25 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
GoToPassphraseStep();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromWords()
|
|
|
|
|
{
|
|
|
|
|
if (!Bip39.TryParse(MnemonicInput, out _))
|
|
|
|
|
{
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.words.invalid");
|
2026-06-11 16:04:25 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
GoToPassphraseStep();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void GoToPassphraseStep()
|
|
|
|
|
{
|
|
|
|
|
PassphraseInput = "";
|
|
|
|
|
SetupStep = StepPassphrase;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.passphrase.info");
|
2026-06-11 16:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromPassphrase()
|
|
|
|
|
{
|
|
|
|
|
PasswordInput = "";
|
|
|
|
|
SetupStep = StepPassword;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.password.info");
|
2026-06-11 16:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardBack()
|
|
|
|
|
{
|
|
|
|
|
SetupStep = SetupStep switch
|
|
|
|
|
{
|
|
|
|
|
StepOpen or StepShowSeed or StepWords => StepStart,
|
|
|
|
|
StepConfirmSeed => StepShowSeed,
|
|
|
|
|
StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed,
|
|
|
|
|
StepPassword => StepPassphrase,
|
|
|
|
|
_ => StepStart,
|
|
|
|
|
};
|
|
|
|
|
if (SetupStep == StepStart)
|
|
|
|
|
RefreshSetupState();
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void CreateOrRestore()
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var (doc, account) = WalletLoader.NewFromMnemonic(
|
|
|
|
|
MnemonicInput,
|
|
|
|
|
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
|
|
|
|
|
ScriptKind.NativeSegwit,
|
|
|
|
|
Profile);
|
2026-06-11 11:27:41 +02:00
|
|
|
// Mai sovrascrivere un wallet esistente: si cerca il primo nome libero.
|
2026-06-11 10:47:52 +02:00
|
|
|
var path = AppPaths.DefaultWalletPath(Net);
|
2026-06-11 11:27:41 +02:00
|
|
|
for (var n = 2; WalletStore.Exists(path); n++)
|
|
|
|
|
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
2026-06-11 10:47:52 +02:00
|
|
|
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
|
|
|
|
|
WalletStore.Save(doc, path, password);
|
|
|
|
|
OpenLoaded(doc, account, path, password);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = $"Errore: {ex.Message}";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void OpenExisting()
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-06-11 11:27:41 +02:00
|
|
|
var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net);
|
2026-06-11 10:47:52 +02:00
|
|
|
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
|
|
|
|
|
var doc = WalletStore.Load(path, password);
|
2026-06-11 11:27:41 +02:00
|
|
|
_pendingOpenPath = null;
|
2026-06-11 10:47:52 +02:00
|
|
|
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password);
|
|
|
|
|
}
|
|
|
|
|
catch (WrongPasswordException)
|
|
|
|
|
{
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.wrongpassword");
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = $"Errore: {ex.Message}";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
/// <summary>Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8).</summary>
|
|
|
|
|
public void OpenFromPath(string path)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (IsWalletOpen)
|
|
|
|
|
CloseWallet();
|
|
|
|
|
var doc = WalletStore.Load(path);
|
|
|
|
|
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null);
|
|
|
|
|
}
|
|
|
|
|
catch (WrongPasswordException)
|
|
|
|
|
{
|
2026-06-11 16:04:25 +02:00
|
|
|
// Cifrato: si chiede la password nel passo di apertura del wizard.
|
2026-06-11 11:27:41 +02:00
|
|
|
if (IsWalletOpen)
|
|
|
|
|
CloseWallet();
|
|
|
|
|
_pendingOpenPath = path;
|
|
|
|
|
WalletFileExists = true;
|
2026-06-11 16:04:25 +02:00
|
|
|
PasswordInput = "";
|
|
|
|
|
SetupStep = StepOpen;
|
2026-06-11 11:27:41 +02:00
|
|
|
StatusMessage = $"Il wallet \"{Path.GetFileName(path)}\" è cifrato: inserisci la password e premi Apri.";
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
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;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.welcome.new");
|
2026-06-11 11:27:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
|
|
|
|
|
{
|
2026-06-11 11:27:41 +02:00
|
|
|
// La rete del wallet comanda (registry, pin TLS, indirizzi).
|
|
|
|
|
SelectedNetwork = doc.Network;
|
2026-06-11 10:47:52 +02:00
|
|
|
_doc = doc;
|
|
|
|
|
_account = account;
|
|
|
|
|
_walletPath = path;
|
|
|
|
|
_password = password;
|
2026-06-11 16:04:25 +02:00
|
|
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
|
|
|
|
|
SetupStep = StepStart;
|
2026-06-11 10:47:52 +02:00
|
|
|
|
|
|
|
|
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
|
|
|
|
|
+ (doc.IsWatchOnly ? " · watch-only" : "");
|
|
|
|
|
ApplyCache(doc.Cache);
|
|
|
|
|
IsWalletOpen = true;
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.opened");
|
2026-06-11 13:37:31 +02:00
|
|
|
// Come Electrum: ci si connette da soli al server selezionato,
|
|
|
|
|
// senza aspettare un click.
|
|
|
|
|
_ = ConnectAndSync();
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-06-11 11:27:41 +02:00
|
|
|
// Prima della sincronizzazione si mostrano i primi indirizzi derivati.
|
|
|
|
|
Addresses.Clear();
|
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
|
|
|
Addresses.Add(new AddressRow("ricezione", i,
|
|
|
|
|
_account.GetReceiveAddress(i).ToString(), "—", "—"));
|
2026-06-11 10:47:52 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-11 16:23:02 +02:00
|
|
|
BalanceText = Fmt(cache.ConfirmedSats);
|
2026-06-11 11:27:41 +02:00
|
|
|
// Saldo in attesa: somma delle tx in mempool (può essere negativo per
|
2026-06-11 11:34:13 +02:00
|
|
|
// gli invii in uscita non ancora confermati). Non è spendibile finché
|
|
|
|
|
// non conferma: la TransactionFactory usa solo UTXO confermati.
|
2026-06-11 11:27:41 +02:00
|
|
|
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
|
|
|
|
UnconfirmedText = pending != 0
|
2026-06-11 16:23:02 +02:00
|
|
|
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
2026-06-11 10:47:52 +02:00
|
|
|
: "";
|
|
|
|
|
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",
|
2026-06-11 16:23:02 +02:00
|
|
|
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
2026-06-11 10:47:52 +02:00
|
|
|
tx.Txid,
|
|
|
|
|
tx.Verified ? "✓ SPV" : "—"));
|
2026-06-11 11:27:41 +02:00
|
|
|
|
|
|
|
|
Addresses.Clear();
|
|
|
|
|
foreach (var a in cache.Addresses)
|
|
|
|
|
Addresses.Add(new AddressRow(
|
|
|
|
|
a.IsChange ? "change" : "ricezione",
|
|
|
|
|
a.Index,
|
|
|
|
|
a.Address,
|
2026-06-11 16:23:02 +02:00
|
|
|
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
2026-06-11 11:27:41 +02:00
|
|
|
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- comandi wallet ----------
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private async Task ConnectAndSync()
|
|
|
|
|
{
|
|
|
|
|
if (_account is null || _doc is null)
|
|
|
|
|
return;
|
2026-06-11 13:37:31 +02:00
|
|
|
if (IsSyncing)
|
|
|
|
|
{
|
|
|
|
|
_resyncRequested = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-11 10:47:52 +02:00
|
|
|
IsSyncing = true;
|
|
|
|
|
StatusMessage = "";
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (_client is null || !_client.IsConnected)
|
|
|
|
|
{
|
|
|
|
|
var (host, port) = ParseServer();
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
|
2026-06-11 10:47:52 +02:00
|
|
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
|
|
|
|
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
|
|
|
|
|
_client.NotificationReceived += OnServerNotification;
|
|
|
|
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
2026-06-11 11:27:41 +02:00
|
|
|
{
|
|
|
|
|
IsConnected = false;
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = Loc.Tr("conn.disconnected");
|
2026-06-11 11:27:41 +02:00
|
|
|
});
|
|
|
|
|
IsConnected = true;
|
|
|
|
|
_autoReconnect = true;
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}";
|
2026-06-11 13:37:31 +02:00
|
|
|
// Synchronizer per connessione: conserva la cache di tx e
|
|
|
|
|
// prove verificate, così le risincronizzazioni sono incrementali.
|
|
|
|
|
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
|
|
|
|
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 13:37:31 +02:00
|
|
|
// Se durante la sync arrivano notifiche, si ripete subito: nessun
|
|
|
|
|
// aggiornamento del server va perso (modello Electrum).
|
|
|
|
|
do
|
2026-06-11 10:47:52 +02:00
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
_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);
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
|
|
|
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
2026-06-11 13:37:31 +02:00
|
|
|
} while (_resyncRequested);
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
catch (CertificatePinMismatchException ex)
|
|
|
|
|
{
|
2026-06-11 11:27:41 +02:00
|
|
|
IsConnected = false;
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = Loc.Tr("conn.certchanged");
|
2026-06-11 10:47:52 +02:00
|
|
|
StatusMessage = ex.Message;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2026-06-11 11:27:41 +02:00
|
|
|
IsConnected = _client?.IsConnected == true;
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error");
|
2026-06-11 10:47:52 +02:00
|
|
|
StatusMessage = $"Errore: {ex.Message}";
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
IsSyncing = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 11:27:41 +02:00
|
|
|
/// <summary>Scopre nuovi server dai peer annunciati dal server connesso (§9).</summary>
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private async Task DiscoverServers()
|
|
|
|
|
{
|
|
|
|
|
if (_client is null || !_client.IsConnected)
|
|
|
|
|
{
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("conn.none") + ".";
|
2026-06-11 11:27:41 +02:00
|
|
|
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}";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:47:52 +02:00
|
|
|
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
|
|
|
|
{
|
|
|
|
|
// Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
|
2026-06-11 13:37:31 +02:00
|
|
|
// Se una sync è già in corso, si accoda (il loop in ConnectAndSync la
|
|
|
|
|
// ripete subito dopo): nessuna notifica viene persa.
|
2026-06-11 10:47:52 +02:00
|
|
|
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
|
|
|
|
Dispatcher.UIThread.Post(() =>
|
|
|
|
|
{
|
2026-06-11 13:37:31 +02:00
|
|
|
if (IsSyncing)
|
|
|
|
|
_resyncRequested = true;
|
|
|
|
|
else
|
2026-06-11 10:47:52 +02:00
|
|
|
_ = ConnectAndSync();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void ResetCertificates()
|
|
|
|
|
{
|
|
|
|
|
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
|
2026-06-11 16:23:02 +02:00
|
|
|
StatusMessage = Loc.Tr("msg.certreset");
|
2026-06-11 10:47:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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;
|
2026-06-11 16:23:02 +02:00
|
|
|
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
2026-06-11 10:47:52 +02:00
|
|
|
{
|
|
|
|
|
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]}… · " +
|
2026-06-11 16:23:02 +02:00
|
|
|
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
2026-06-11 10:47:52 +02:00
|
|
|
$"({_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}";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void CloseWallet()
|
|
|
|
|
{
|
|
|
|
|
_ = _client?.DisposeAsync().AsTask();
|
|
|
|
|
_client = null;
|
2026-06-11 13:37:31 +02:00
|
|
|
_synchronizer = null;
|
|
|
|
|
_autoReconnect = false;
|
|
|
|
|
_resyncRequested = false;
|
2026-06-11 10:47:52 +02:00
|
|
|
_doc = null;
|
|
|
|
|
_account = null;
|
|
|
|
|
_lastTransactions = null;
|
|
|
|
|
_pendingSend = null;
|
|
|
|
|
HasPendingSend = false;
|
|
|
|
|
History.Clear();
|
|
|
|
|
IsWalletOpen = false;
|
2026-06-11 11:27:41 +02:00
|
|
|
IsConnected = false;
|
2026-06-11 16:23:02 +02:00
|
|
|
ConnectionStatus = Loc.Tr("conn.none");
|
2026-06-11 10:47:52 +02:00
|
|
|
RefreshSetupState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private (string Host, int Port) ParseServer()
|
|
|
|
|
{
|
|
|
|
|
var parts = ServerInput.Trim().Split(':');
|
|
|
|
|
var host = parts[0];
|
|
|
|
|
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
|
|
|
|
|
? p
|
|
|
|
|
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
|
|
|
|
|
return (host, port);
|
|
|
|
|
}
|
|
|
|
|
}
|