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;
|
|
|
|
|
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 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]
|
|
|
|
|
private string connectionStatus = "non connesso";
|
|
|
|
|
|
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()
|
|
|
|
|
{
|
|
|
|
|
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)
|
|
|
|
|
{
|
|
|
|
|
ConnectionStatus = "riconnessione…";
|
|
|
|
|
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:04:25 +02:00
|
|
|
? "Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro."
|
|
|
|
|
: "Benvenuto: crea un nuovo wallet o ripristina da seed.";
|
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;
|
|
|
|
|
StatusMessage = "Inserisci la password del file (lascia vuoto se non impostata).";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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;
|
|
|
|
|
StatusMessage = "Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardStartRestore()
|
|
|
|
|
{
|
|
|
|
|
_isRestoreFlow = true;
|
|
|
|
|
MnemonicInput = "";
|
|
|
|
|
SetupStep = StepWords;
|
|
|
|
|
StatusMessage = "Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromShowSeed()
|
|
|
|
|
{
|
|
|
|
|
ConfirmMnemonicInput = "";
|
|
|
|
|
SetupStep = StepConfirmSeed;
|
|
|
|
|
StatusMessage = "Reinserisci le 12 parole per confermare di averle scritte.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromConfirmSeed()
|
|
|
|
|
{
|
|
|
|
|
var normalized = string.Join(' ',
|
|
|
|
|
ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
|
|
|
|
if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = "Le parole non corrispondono: ricontrolla quello che hai scritto su carta.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
GoToPassphraseStep();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromWords()
|
|
|
|
|
{
|
|
|
|
|
if (!Bip39.TryParse(MnemonicInput, out _))
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = "Mnemonica non valida (parole o checksum errati): ricontrolla.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
GoToPassphraseStep();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void GoToPassphraseStep()
|
|
|
|
|
{
|
|
|
|
|
PassphraseInput = "";
|
|
|
|
|
SetupStep = StepPassphrase;
|
|
|
|
|
StatusMessage = "Passphrase BIP39 opzionale: cambia completamente il wallet. " +
|
|
|
|
|
"Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. " +
|
|
|
|
|
"Lascia vuoto per non usarla.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void WizardNextFromPassphrase()
|
|
|
|
|
{
|
|
|
|
|
PasswordInput = "";
|
|
|
|
|
SetupStep = StepPassword;
|
|
|
|
|
StatusMessage = "Password di cifratura del file wallet su disco (consigliata). " +
|
|
|
|
|
"Non sostituisce il seed: serve solo a proteggere il file.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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)
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = "Password errata.";
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
StatusMessage = "Crea un nuovo wallet o ripristina da seed. ATTENZIONE: sovrascrive il wallet di default della rete selezionata.";
|
|
|
|
|
}
|
|
|
|
|
|
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 13:37:31 +02:00
|
|
|
StatusMessage = "Wallet aperto: connessione al server…";
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
|
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 11:34:13 +02:00
|
|
|
? $"in attesa di conferma: {(pending > 0 ? "+" : "")}{CoinAmount.Format(pending)} — non ancora spendibile"
|
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",
|
|
|
|
|
(tx.DeltaSats >= 0 ? "+" : "") + CoinAmount.Format(tx.DeltaSats),
|
|
|
|
|
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,
|
|
|
|
|
a.BalanceSats > 0 ? CoinAmount.Format(a.BalanceSats) : (a.TxCount > 0 ? "0" : "—"),
|
|
|
|
|
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();
|
|
|
|
|
ConnectionStatus = $"connessione a {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(() =>
|
2026-06-11 11:27:41 +02:00
|
|
|
{
|
|
|
|
|
IsConnected = false;
|
|
|
|
|
ConnectionStatus = "disconnesso";
|
|
|
|
|
});
|
|
|
|
|
IsConnected = true;
|
|
|
|
|
_autoReconnect = true;
|
2026-06-11 10:47:52 +02:00
|
|
|
ConnectionStatus = $"connesso a {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);
|
|
|
|
|
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
|
|
|
|
|
$"{result.History.Count} transazioni verificate SPV. Aggiornamento in tempo reale attivo.";
|
|
|
|
|
} 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 10:47:52 +02:00
|
|
|
ConnectionStatus = "certificato cambiato";
|
|
|
|
|
StatusMessage = ex.Message;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2026-06-11 11:27:41 +02:00
|
|
|
IsConnected = _client?.IsConnected == true;
|
|
|
|
|
ConnectionStatus = IsConnected ? ConnectionStatus : "errore di connessione";
|
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)
|
|
|
|
|
{
|
|
|
|
|
StatusMessage = "Connettiti a un server prima di cercare i peer.";
|
|
|
|
|
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();
|
|
|
|
|
StatusMessage = "Certificati SSL azzerati: riprova la connessione.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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.TryParseCoins(SendAmount, 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 {CoinAmount.Format(_pendingSend.Fee.Satoshi, Profile.CoinUnit)} " +
|
|
|
|
|
$"({_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 10:47:52 +02:00
|
|
|
ConnectionStatus = "non connesso";
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|