Compare commits
3 Commits
ece634f42e
...
75583f7a69
| Author | SHA1 | Date | |
|---|---|---|---|
| 75583f7a69 | |||
| e8777fb458 | |||
| 73a2d5356d |
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.Localization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con
|
||||||
|
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
|
||||||
|
/// notifica "Item[]" e tutte le binding si aggiornano.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Loc : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public static Loc Instance { get; } = new();
|
||||||
|
|
||||||
|
public static readonly string[] Languages = ["it", "en"];
|
||||||
|
public static readonly string[] LanguageNames = ["Italiano", "English"];
|
||||||
|
|
||||||
|
public string Language { get; private set; } = "it";
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public void SetLanguage(string language)
|
||||||
|
{
|
||||||
|
if (Language == language || System.Array.IndexOf(Languages, language) < 0)
|
||||||
|
return;
|
||||||
|
Language = language;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string this[string key] =>
|
||||||
|
Strings.TryGetValue(key, out var values)
|
||||||
|
? values[Language == "en" ? 1 : 0]
|
||||||
|
: key;
|
||||||
|
|
||||||
|
public static string Tr(string key) => Instance[key];
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string[]> Strings = new()
|
||||||
|
{
|
||||||
|
// Menu
|
||||||
|
["menu.file"] = ["_File", "_File"],
|
||||||
|
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"],
|
||||||
|
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"],
|
||||||
|
["menu.file.close"] = ["Chiudi wallet", "Close wallet"],
|
||||||
|
["menu.net"] = ["_Rete", "_Network"],
|
||||||
|
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"],
|
||||||
|
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"],
|
||||||
|
["menu.settings"] = ["_Impostazioni", "_Settings"],
|
||||||
|
["settings.unit.short"] = ["Unità", "Unit"],
|
||||||
|
|
||||||
|
// Wizard
|
||||||
|
["wiz.net"] = ["Rete:", "Network:"],
|
||||||
|
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"],
|
||||||
|
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"],
|
||||||
|
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"],
|
||||||
|
["wiz.open.title"] = ["Apri il wallet", "Open the wallet"],
|
||||||
|
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"],
|
||||||
|
["wiz.open.ok"] = ["Apri", "Open"],
|
||||||
|
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"],
|
||||||
|
["wiz.seed.warning"] = [
|
||||||
|
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
|
||||||
|
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable."],
|
||||||
|
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next"],
|
||||||
|
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"],
|
||||||
|
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"],
|
||||||
|
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"],
|
||||||
|
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"],
|
||||||
|
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"],
|
||||||
|
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"],
|
||||||
|
["wiz.password.title"] = ["Password del file wallet", "Wallet file password"],
|
||||||
|
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"],
|
||||||
|
["wiz.password.create"] = ["Crea il wallet", "Create wallet"],
|
||||||
|
["wiz.back"] = ["Indietro", "Back"],
|
||||||
|
["wiz.next"] = ["Avanti", "Next"],
|
||||||
|
|
||||||
|
// Pannello wallet
|
||||||
|
["wallet.close"] = ["Chiudi wallet", "Close wallet"],
|
||||||
|
["wallet.server"] = ["Server:", "Server:"],
|
||||||
|
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"],
|
||||||
|
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"],
|
||||||
|
["wallet.discover"] = ["Cerca altri server", "Discover servers"],
|
||||||
|
["wallet.resetcert"] = ["Reset cert.", "Reset certs"],
|
||||||
|
["tab.receive"] = ["Ricevi", "Receive"],
|
||||||
|
["tab.history"] = ["Storico", "History"],
|
||||||
|
["tab.addresses"] = ["Indirizzi", "Addresses"],
|
||||||
|
["tab.send"] = ["Invia", "Send"],
|
||||||
|
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"],
|
||||||
|
["receive.hint"] = [
|
||||||
|
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
||||||
|
"Payments received here will appear in the history at the next synchronization."],
|
||||||
|
["addr.type"] = ["Tipo", "Type"],
|
||||||
|
["addr.index"] = ["Indice", "Index"],
|
||||||
|
["addr.address"] = ["Indirizzo", "Address"],
|
||||||
|
["addr.balance"] = ["Saldo", "Balance"],
|
||||||
|
["addr.receive"] = ["ricezione", "receive"],
|
||||||
|
["addr.change"] = ["change", "change"],
|
||||||
|
["send.to"] = ["Indirizzo destinatario", "Recipient address"],
|
||||||
|
["send.amount"] = ["Importo", "Amount"],
|
||||||
|
["send.all"] = ["Invia tutto", "Send all"],
|
||||||
|
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"],
|
||||||
|
["send.prepare"] = ["Prepara transazione", "Prepare transaction"],
|
||||||
|
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"],
|
||||||
|
|
||||||
|
// Stato connessione
|
||||||
|
["conn.none"] = ["non connesso", "not connected"],
|
||||||
|
["conn.disconnected"] = ["disconnesso", "disconnected"],
|
||||||
|
["conn.reconnecting"] = ["riconnessione…", "reconnecting…"],
|
||||||
|
["conn.error"] = ["errore di connessione", "connection error"],
|
||||||
|
["conn.certchanged"] = ["certificato cambiato", "certificate changed"],
|
||||||
|
["conn.connectedto"] = ["connesso a", "connected to"],
|
||||||
|
["conn.connectingto"] = ["connessione a", "connecting to"],
|
||||||
|
|
||||||
|
// Messaggi di stato principali
|
||||||
|
["msg.welcome.existing"] = [
|
||||||
|
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
||||||
|
"Found an existing wallet on this network: open it, or create another one."],
|
||||||
|
["msg.welcome.new"] = [
|
||||||
|
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
||||||
|
"Welcome: create a new wallet or restore from seed."],
|
||||||
|
["msg.open.password"] = [
|
||||||
|
"Inserisci la password del file (lascia vuoto se non impostata).",
|
||||||
|
"Enter the file password (leave empty if not set)."],
|
||||||
|
["msg.seed.write"] = [
|
||||||
|
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
||||||
|
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet."],
|
||||||
|
["msg.seed.retype"] = [
|
||||||
|
"Reinserisci le 12 parole per confermare di averle scritte.",
|
||||||
|
"Re-enter the 12 words to confirm you wrote them down."],
|
||||||
|
["msg.seed.mismatch"] = [
|
||||||
|
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
||||||
|
"The words do not match: check what you wrote on paper."],
|
||||||
|
["msg.words.enter"] = [
|
||||||
|
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
||||||
|
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces)."],
|
||||||
|
["msg.words.invalid"] = [
|
||||||
|
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
||||||
|
"Invalid mnemonic (wrong words or checksum): check again."],
|
||||||
|
["msg.passphrase.info"] = [
|
||||||
|
"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.",
|
||||||
|
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip."],
|
||||||
|
["msg.password.info"] = [
|
||||||
|
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
|
||||||
|
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file."],
|
||||||
|
["msg.wrongpassword"] = ["Password errata.", "Wrong password."],
|
||||||
|
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…"],
|
||||||
|
["msg.synced"] = ["Sincronizzato", "Synchronized"],
|
||||||
|
["msg.synced.detail"] = [
|
||||||
|
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
||||||
|
"SPV-verified transactions. Real-time updates active."],
|
||||||
|
["msg.height"] = ["altezza", "height"],
|
||||||
|
["msg.pending"] = ["in attesa di conferma", "pending confirmation"],
|
||||||
|
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"],
|
||||||
|
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."],
|
||||||
|
["msg.certreset"] = [
|
||||||
|
"Certificati SSL azzerati: riprova la connessione.",
|
||||||
|
"SSL certificates cleared: retry the connection."],
|
||||||
|
["msg.error"] = ["Errore", "Error"],
|
||||||
|
|
||||||
|
// Finestra impostazioni
|
||||||
|
["settings.title"] = ["Impostazioni", "Settings"],
|
||||||
|
["settings.language"] = ["Lingua", "Language"],
|
||||||
|
["settings.unit"] = ["Unità degli importi", "Amount unit"],
|
||||||
|
["settings.ok"] = ["Salva", "Save"],
|
||||||
|
["settings.cancel"] = ["Annulla", "Cancel"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using Avalonia.Threading;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
@@ -35,9 +36,67 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
private string? _walletPath;
|
private string? _walletPath;
|
||||||
private string? _password;
|
private string? _password;
|
||||||
private ElectrumClient? _client;
|
private ElectrumClient? _client;
|
||||||
|
private WalletSynchronizer? _synchronizer;
|
||||||
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
||||||
private BuiltTransaction? _pendingSend;
|
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>Stringhe localizzate, bindabili da XAML come Loc[chiave].</summary>
|
||||||
|
public Loc Loc => Loc.Instance;
|
||||||
|
|
||||||
|
/// <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 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.SetLanguage(config.Language);
|
||||||
|
OnPropertyChanged(nameof(UnitLabel));
|
||||||
|
OnPropertyChanged(nameof(IsLangIt));
|
||||||
|
OnPropertyChanged(nameof(IsLangEn));
|
||||||
|
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>File in attesa di password (apertura da menu File → Apri).</summary>
|
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
|
||||||
private string? _pendingOpenPath;
|
private string? _pendingOpenPath;
|
||||||
|
|
||||||
@@ -65,11 +124,43 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string statusMessage = "";
|
private string statusMessage = "";
|
||||||
|
|
||||||
// ---- pannello setup ----
|
// ---- 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;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string mnemonicInput = "";
|
private string mnemonicInput = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string confirmMnemonicInput = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string passphraseInput = "";
|
private string passphraseInput = "";
|
||||||
|
|
||||||
@@ -97,7 +188,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
private bool useSsl;
|
private bool useSsl;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string connectionStatus = "non connesso";
|
private string connectionStatus = Loc.Tr("conn.none");
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isConnected;
|
private bool isConnected;
|
||||||
@@ -162,7 +253,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
else if (_autoReconnect)
|
else if (_autoReconnect)
|
||||||
{
|
{
|
||||||
ConnectionStatus = "riconnessione…";
|
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||||
await ConnectAndSync();
|
await ConnectAndSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,10 +267,12 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void RefreshSetupState()
|
private void RefreshSetupState()
|
||||||
{
|
{
|
||||||
|
SetupStep = StepStart;
|
||||||
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
|
||||||
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
|
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
|
||||||
StatusMessage = WalletFileExists
|
StatusMessage = WalletFileExists
|
||||||
? "Trovato un wallet esistente: inserisci la password (se impostata) e apri."
|
? Loc.Tr("msg.welcome.existing")
|
||||||
: "Nessun wallet su questa rete: creane uno nuovo o ripristina da seed.";
|
: Loc.Tr("msg.welcome.new");
|
||||||
RefreshServers();
|
RefreshServers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,13 +298,94 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
ServerInput = $"{server.Host}:{server.PortFor(value)}";
|
ServerInput = $"{server.Host}:{server.PortFor(value)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- comandi setup ----------
|
// ---------- comandi del wizard (§15): un passo alla volta ----------
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void GenerateMnemonic()
|
private void WizardStartOpen()
|
||||||
{
|
{
|
||||||
|
PasswordInput = "";
|
||||||
|
SetupStep = StepOpen;
|
||||||
|
StatusMessage = Loc.Tr("msg.open.password");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardStartNew()
|
||||||
|
{
|
||||||
|
_isRestoreFlow = false;
|
||||||
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
||||||
StatusMessage = "Nuova mnemonica generata: SCRIVILA SU CARTA prima di continuare.";
|
SetupStep = StepShowSeed;
|
||||||
|
StatusMessage = Loc.Tr("msg.seed.write");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardStartRestore()
|
||||||
|
{
|
||||||
|
_isRestoreFlow = true;
|
||||||
|
MnemonicInput = "";
|
||||||
|
SetupStep = StepWords;
|
||||||
|
StatusMessage = Loc.Tr("msg.words.enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardNextFromShowSeed()
|
||||||
|
{
|
||||||
|
ConfirmMnemonicInput = "";
|
||||||
|
SetupStep = StepConfirmSeed;
|
||||||
|
StatusMessage = Loc.Tr("msg.seed.retype");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 = Loc.Tr("msg.passphrase.info");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void WizardNextFromPassphrase()
|
||||||
|
{
|
||||||
|
PasswordInput = "";
|
||||||
|
SetupStep = StepPassword;
|
||||||
|
StatusMessage = Loc.Tr("msg.password.info");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -251,7 +425,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
catch (WrongPasswordException)
|
catch (WrongPasswordException)
|
||||||
{
|
{
|
||||||
StatusMessage = "Password errata.";
|
StatusMessage = Loc.Tr("msg.wrongpassword");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -271,11 +445,13 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
catch (WrongPasswordException)
|
catch (WrongPasswordException)
|
||||||
{
|
{
|
||||||
// Cifrato: si chiede la password nel pannello di apertura.
|
// Cifrato: si chiede la password nel passo di apertura del wizard.
|
||||||
if (IsWalletOpen)
|
if (IsWalletOpen)
|
||||||
CloseWallet();
|
CloseWallet();
|
||||||
_pendingOpenPath = path;
|
_pendingOpenPath = path;
|
||||||
WalletFileExists = true;
|
WalletFileExists = true;
|
||||||
|
PasswordInput = "";
|
||||||
|
SetupStep = StepOpen;
|
||||||
StatusMessage = $"Il wallet \"{Path.GetFileName(path)}\" è cifrato: inserisci la password e premi Apri.";
|
StatusMessage = $"Il wallet \"{Path.GetFileName(path)}\" è cifrato: inserisci la password e premi Apri.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -291,7 +467,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
if (IsWalletOpen)
|
if (IsWalletOpen)
|
||||||
CloseWallet();
|
CloseWallet();
|
||||||
_pendingOpenPath = null;
|
_pendingOpenPath = null;
|
||||||
StatusMessage = "Crea un nuovo wallet o ripristina da seed. ATTENZIONE: sovrascrive il wallet di default della rete selezionata.";
|
StatusMessage = Loc.Tr("msg.welcome.new");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
|
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
|
||||||
@@ -302,15 +478,17 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_account = account;
|
_account = account;
|
||||||
_walletPath = path;
|
_walletPath = path;
|
||||||
_password = password;
|
_password = password;
|
||||||
MnemonicInput = PassphraseInput = PasswordInput = "";
|
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
|
||||||
|
SetupStep = StepStart;
|
||||||
|
|
||||||
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
|
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
|
||||||
+ (doc.IsWatchOnly ? " · watch-only" : "");
|
+ (doc.IsWatchOnly ? " · watch-only" : "");
|
||||||
ApplyCache(doc.Cache);
|
ApplyCache(doc.Cache);
|
||||||
IsWalletOpen = true;
|
IsWalletOpen = true;
|
||||||
StatusMessage = doc.Cache is null
|
StatusMessage = Loc.Tr("msg.opened");
|
||||||
? "Wallet aperto. Connettiti a un server per sincronizzare."
|
// Come Electrum: ci si connette da soli al server selezionato,
|
||||||
: "Wallet aperto (dati dell'ultima sincronizzazione).";
|
// senza aspettare un click.
|
||||||
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyCache(SyncCache? cache)
|
private void ApplyCache(SyncCache? cache)
|
||||||
@@ -330,20 +508,20 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_account.GetReceiveAddress(i).ToString(), "—", "—"));
|
_account.GetReceiveAddress(i).ToString(), "—", "—"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
|
BalanceText = Fmt(cache.ConfirmedSats);
|
||||||
// Saldo in attesa: somma delle tx in mempool (può essere negativo per
|
// Saldo in attesa: somma delle tx in mempool (può essere negativo per
|
||||||
// gli invii in uscita non ancora confermati). Non è spendibile finché
|
// gli invii in uscita non ancora confermati). Non è spendibile finché
|
||||||
// non conferma: la TransactionFactory usa solo UTXO confermati.
|
// non conferma: la TransactionFactory usa solo UTXO confermati.
|
||||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||||
UnconfirmedText = pending != 0
|
UnconfirmedText = pending != 0
|
||||||
? $"in attesa di conferma: {(pending > 0 ? "+" : "")}{CoinAmount.Format(pending)} — non ancora spendibile"
|
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||||
: "";
|
: "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
foreach (var tx in cache.History)
|
foreach (var tx in cache.History)
|
||||||
History.Add(new HistoryRow(
|
History.Add(new HistoryRow(
|
||||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||||
(tx.DeltaSats >= 0 ? "+" : "") + CoinAmount.Format(tx.DeltaSats),
|
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||||
tx.Txid,
|
tx.Txid,
|
||||||
tx.Verified ? "✓ SPV" : "—"));
|
tx.Verified ? "✓ SPV" : "—"));
|
||||||
|
|
||||||
@@ -353,7 +531,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
a.IsChange ? "change" : "ricezione",
|
a.IsChange ? "change" : "ricezione",
|
||||||
a.Index,
|
a.Index,
|
||||||
a.Address,
|
a.Address,
|
||||||
a.BalanceSats > 0 ? CoinAmount.Format(a.BalanceSats) : (a.TxCount > 0 ? "0" : "—"),
|
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
||||||
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
|
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +542,11 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (_account is null || _doc is null)
|
if (_account is null || _doc is null)
|
||||||
return;
|
return;
|
||||||
|
if (IsSyncing)
|
||||||
|
{
|
||||||
|
_resyncRequested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
IsSyncing = true;
|
IsSyncing = true;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
try
|
try
|
||||||
@@ -371,23 +554,30 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
if (_client is null || !_client.IsConnected)
|
if (_client is null || !_client.IsConnected)
|
||||||
{
|
{
|
||||||
var (host, port) = ParseServer();
|
var (host, port) = ParseServer();
|
||||||
ConnectionStatus = $"connessione a {host}:{port}…";
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
|
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
|
||||||
_client.NotificationReceived += OnServerNotification;
|
_client.NotificationReceived += OnServerNotification;
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = "disconnesso";
|
ConnectionStatus = Loc.Tr("conn.disconnected");
|
||||||
});
|
});
|
||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
_autoReconnect = true;
|
_autoReconnect = true;
|
||||||
ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}";
|
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}";
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
var sync = new WalletSynchronizer(_account, _client, _doc.GapLimit);
|
// Se durante la sync arrivano notifiche, si ripete subito: nessun
|
||||||
sync.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
// aggiornamento del server va perso (modello Electrum).
|
||||||
var result = await sync.SyncOnceAsync();
|
do
|
||||||
|
{
|
||||||
|
_resyncRequested = false;
|
||||||
|
var result = await _synchronizer!.SyncOnceAsync();
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
_doc.Cache = new SyncCache
|
_doc.Cache = new SyncCache
|
||||||
@@ -403,19 +593,20 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
WalletStore.Save(_doc, _walletPath!, _password);
|
||||||
ApplyCache(_doc.Cache);
|
ApplyCache(_doc.Cache);
|
||||||
StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
$"{result.History.Count} transazioni verificate SPV.";
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||||
|
} while (_resyncRequested);
|
||||||
}
|
}
|
||||||
catch (CertificatePinMismatchException ex)
|
catch (CertificatePinMismatchException ex)
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = "certificato cambiato";
|
ConnectionStatus = Loc.Tr("conn.certchanged");
|
||||||
StatusMessage = ex.Message;
|
StatusMessage = ex.Message;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : "errore di connessione";
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error");
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
StatusMessage = $"Errore: {ex.Message}";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -430,7 +621,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (_client is null || !_client.IsConnected)
|
if (_client is null || !_client.IsConnected)
|
||||||
{
|
{
|
||||||
StatusMessage = "Connettiti a un server prima di cercare i peer.";
|
StatusMessage = Loc.Tr("conn.none") + ".";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
@@ -453,10 +644,14 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
||||||
{
|
{
|
||||||
// Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
|
// 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")
|
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (!IsSyncing)
|
if (IsSyncing)
|
||||||
|
_resyncRequested = true;
|
||||||
|
else
|
||||||
_ = ConnectAndSync();
|
_ = ConnectAndSync();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -465,7 +660,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
private void ResetCertificates()
|
private void ResetCertificates()
|
||||||
{
|
{
|
||||||
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
|
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
|
||||||
StatusMessage = "Certificati SSL azzerati: riprova la connessione.";
|
StatusMessage = Loc.Tr("msg.certreset");
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -486,7 +681,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
|
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
|
||||||
long amount = 0;
|
long amount = 0;
|
||||||
if (!SendAll && !CoinAmount.TryParseCoins(SendAmount, out amount))
|
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
||||||
{
|
{
|
||||||
SendPreview = "Importo non valido.";
|
SendPreview = "Importo non valido.";
|
||||||
return;
|
return;
|
||||||
@@ -502,7 +697,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_doc.Cache.NextChangeIndex, SendAll);
|
_doc.Cache.NextChangeIndex, SendAll);
|
||||||
|
|
||||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||||
$"fee {CoinAmount.Format(_pendingSend.Fee.Satoshi, Profile.CoinUnit)} " +
|
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||||
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
|
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
|
||||||
HasPendingSend = _pendingSend.Signed;
|
HasPendingSend = _pendingSend.Signed;
|
||||||
@@ -541,6 +736,9 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_ = _client?.DisposeAsync().AsTask();
|
_ = _client?.DisposeAsync().AsTask();
|
||||||
_client = null;
|
_client = null;
|
||||||
|
_synchronizer = null;
|
||||||
|
_autoReconnect = false;
|
||||||
|
_resyncRequested = false;
|
||||||
_doc = null;
|
_doc = null;
|
||||||
_account = null;
|
_account = null;
|
||||||
_lastTransactions = null;
|
_lastTransactions = null;
|
||||||
@@ -549,7 +747,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
History.Clear();
|
History.Clear();
|
||||||
IsWalletOpen = false;
|
IsWalletOpen = false;
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = "non connesso";
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
RefreshSetupState();
|
RefreshSetupState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+151
-61
@@ -18,57 +18,145 @@
|
|||||||
|
|
||||||
<!-- ============ MENU (stile Electrum) ============ -->
|
<!-- ============ MENU (stile Electrum) ============ -->
|
||||||
<Menu Grid.Row="0">
|
<Menu Grid.Row="0">
|
||||||
<MenuItem Header="_File">
|
<MenuItem Header="{Binding Loc[menu.file]}">
|
||||||
<MenuItem Header="Nuovo / ripristina wallet…" Command="{Binding NewWalletCommand}"/>
|
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
|
||||||
<MenuItem Header="Apri wallet da file…" Click="OnOpenWalletFileClick"/>
|
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Chiudi wallet" Command="{Binding CloseWalletCommand}"
|
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
|
||||||
IsEnabled="{Binding IsWalletOpen}"/>
|
IsEnabled="{Binding IsWalletOpen}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="_Rete">
|
<MenuItem Header="{Binding Loc[menu.net]}">
|
||||||
<MenuItem Header="Cerca altri server (peer)" Command="{Binding DiscoverServersCommand}"/>
|
<MenuItem Header="{Binding Loc[menu.net.discover]}" Command="{Binding DiscoverServersCommand}"/>
|
||||||
<MenuItem Header="Reset certificati SSL" Command="{Binding ResetCertificatesCommand}"/>
|
<MenuItem Header="{Binding Loc[menu.net.resetcerts]}" Command="{Binding ResetCertificatesCommand}"/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{Binding Loc[menu.settings]}">
|
||||||
|
<MenuItem Header="{Binding Loc[settings.language]}">
|
||||||
|
<MenuItem Header="Italiano" ToggleType="Radio"
|
||||||
|
IsChecked="{Binding IsLangIt, Mode=OneWay}"
|
||||||
|
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
|
||||||
|
<MenuItem Header="English" ToggleType="Radio"
|
||||||
|
IsChecked="{Binding IsLangEn, Mode=OneWay}"
|
||||||
|
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{Binding Loc[settings.unit.short]}">
|
||||||
|
<MenuItem Header="PLM" ToggleType="Radio"
|
||||||
|
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
|
||||||
|
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
|
||||||
|
<MenuItem Header="mPLM" ToggleType="Radio"
|
||||||
|
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
|
||||||
|
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
|
||||||
|
<MenuItem Header="µPLM" ToggleType="Radio"
|
||||||
|
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
|
||||||
|
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
|
||||||
|
<MenuItem Header="sat" ToggleType="Radio"
|
||||||
|
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
|
||||||
|
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
|
||||||
|
</MenuItem>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<!-- ============ PANNELLO SETUP: crea / ripristina / apri ============ -->
|
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
|
||||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||||
<StackPanel MaxWidth="560" Margin="24" Spacing="14">
|
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"/>
|
HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<!-- Passo 1: scelta iniziale -->
|
||||||
<TextBlock Text="Rete:" VerticalAlignment="Center"/>
|
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.net]}" VerticalAlignment="Center"/>
|
||||||
<ComboBox ItemsSource="{Binding Networks}"
|
<ComboBox ItemsSource="{Binding Networks}"
|
||||||
SelectedItem="{Binding SelectedNetwork}"
|
SelectedItem="{Binding SelectedNetwork}" MinWidth="140"/>
|
||||||
MinWidth="140"/>
|
</StackPanel>
|
||||||
|
<Button Content="{Binding Loc[wiz.open.btn]}" FontSize="16"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
|
IsVisible="{Binding WalletFileExists}"
|
||||||
|
Command="{Binding WizardStartOpenCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.new.btn]}" FontSize="16"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding WizardStartNewCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.restore.btn]}" FontSize="16"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding WizardStartRestoreCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Apri wallet esistente -->
|
<!-- Passo: password del wallet esistente -->
|
||||||
<Border IsVisible="{Binding WalletFileExists}"
|
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
|
||||||
BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
|
<TextBlock Text="{Binding Loc[wiz.open.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.open.placeholder]}"
|
||||||
|
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.open.ok]}" Classes="accent"
|
||||||
|
Command="{Binding OpenExistingCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Passo: mostra il nuovo seed -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepShowSeed}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.seed.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<Border BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
|
||||||
CornerRadius="6" Padding="14">
|
CornerRadius="6" Padding="14">
|
||||||
<StackPanel Spacing="8">
|
<SelectableTextBlock Text="{Binding MnemonicInput}"
|
||||||
<TextBlock Text="Apri il wallet esistente" FontWeight="Bold"/>
|
FontFamily="monospace" FontSize="16" TextWrapping="Wrap"/>
|
||||||
<TextBox PlaceholderText="Password del file (vuoto se non impostata)"
|
|
||||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
|
||||||
<Button Content="Apri wallet" Command="{Binding OpenExistingCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
</Border>
|
||||||
|
<TextBlock Foreground="Orange" TextWrapping="Wrap"
|
||||||
|
Text="{Binding Loc[wiz.seed.warning]}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.seed.next]}" Classes="accent"
|
||||||
|
Command="{Binding WizardNextFromShowSeedCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Crea / ripristina -->
|
<!-- Passo: conferma del seed -->
|
||||||
<Border BorderBrush="Gray" BorderThickness="1" CornerRadius="6" Padding="14">
|
<StackPanel IsVisible="{Binding IsStepConfirmSeed}" Spacing="12">
|
||||||
<StackPanel Spacing="8">
|
<TextBlock Text="{Binding Loc[wiz.confirm.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
<TextBlock Text="Crea nuovo o ripristina da seed" FontWeight="Bold"/>
|
<TextBox PlaceholderText="{Binding Loc[wiz.confirm.placeholder]}"
|
||||||
<TextBox PlaceholderText="Mnemonica BIP39 (12 o 24 parole)"
|
AcceptsReturn="False" Text="{Binding ConfirmMnemonicInput}"/>
|
||||||
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="Genera nuova mnemonica" Command="{Binding GenerateMnemonicCommand}"/>
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
<TextBox PlaceholderText="Passphrase BIP39 opzionale (cambia il wallet! annotala a parte)"
|
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||||
Text="{Binding PassphraseInput}"/>
|
Command="{Binding WizardNextFromConfirmSeedCommand}"/>
|
||||||
<TextBox PlaceholderText="Password di cifratura del file wallet (consigliata)"
|
</StackPanel>
|
||||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
</StackPanel>
|
||||||
<Button Content="Crea / ripristina wallet" Command="{Binding CreateOrRestoreCommand}"/>
|
|
||||||
|
<!-- Passo: inserimento seed (ripristino) -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepWords}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.words.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.words.placeholder]}"
|
||||||
|
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||||
|
Command="{Binding WizardNextFromWordsCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Passo: passphrase opzionale -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepPassphrase}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.passphrase.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.passphrase.placeholder]}"
|
||||||
|
Text="{Binding PassphraseInput}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||||
|
Command="{Binding WizardNextFromPassphraseCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Passo finale: password del file -->
|
||||||
|
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
|
||||||
|
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
|
||||||
|
Command="{Binding CreateOrRestoreCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
@@ -82,7 +170,7 @@
|
|||||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
|
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
|
||||||
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
|
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="Chiudi wallet" VerticalAlignment="Top"
|
<Button Grid.Column="1" Content="{Binding Loc[wallet.close]}" VerticalAlignment="Top"
|
||||||
Command="{Binding CloseWalletCommand}"/>
|
Command="{Binding CloseWalletCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -90,7 +178,7 @@
|
|||||||
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
|
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
|
||||||
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
|
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
|
||||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
|
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Server:"
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[wallet.server]}"
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
<ComboBox Grid.Row="0" Grid.Column="1"
|
<ComboBox Grid.Row="0" Grid.Column="1"
|
||||||
ItemsSource="{Binding KnownServers}"
|
ItemsSource="{Binding KnownServers}"
|
||||||
@@ -98,16 +186,16 @@
|
|||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
|
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
|
||||||
IsChecked="{Binding UseSsl}" Margin="8,0"/>
|
IsChecked="{Binding UseSsl}" Margin="8,0"/>
|
||||||
<Button Grid.Row="0" Grid.Column="3" Content="Connetti e sincronizza"
|
<Button Grid.Row="0" Grid.Column="3" Content="{Binding Loc[wallet.connect]}"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
|
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
|
||||||
Spacing="8" Margin="0,8,0,0">
|
Spacing="8" Margin="0,8,0,0">
|
||||||
<TextBox Text="{Binding ServerInput}" MinWidth="220"
|
<TextBox Text="{Binding ServerInput}" MinWidth="220"
|
||||||
PlaceholderText="oppure host:porta manuale"/>
|
PlaceholderText="{Binding Loc[wallet.manual]}"/>
|
||||||
<Button Content="Cerca altri server"
|
<Button Content="{Binding Loc[wallet.discover]}"
|
||||||
Command="{Binding DiscoverServersCommand}"/>
|
Command="{Binding DiscoverServersCommand}"/>
|
||||||
<Button Content="Reset cert."
|
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||||
Command="{Binding ResetCertificatesCommand}"/>
|
Command="{Binding ResetCertificatesCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
|
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
|
||||||
@@ -122,19 +210,19 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Tab: Ricevi / Storico / Invia -->
|
<!-- Tab: Ricevi / Storico / Indirizzi / Invia -->
|
||||||
<TabControl Grid.Row="2">
|
<TabControl Grid.Row="2">
|
||||||
<TabItem Header="Ricevi">
|
<TabItem Header="{Binding Loc[tab.receive]}">
|
||||||
<StackPanel Spacing="10" Margin="8">
|
<StackPanel Spacing="10" Margin="8">
|
||||||
<TextBlock Text="Prossimo indirizzo non usato:"/>
|
<TextBlock Text="{Binding Loc[receive.next]}"/>
|
||||||
<SelectableTextBlock Text="{Binding ReceiveAddress}"
|
<SelectableTextBlock Text="{Binding ReceiveAddress}"
|
||||||
FontFamily="monospace" FontSize="16"/>
|
FontFamily="monospace" FontSize="16"/>
|
||||||
<TextBlock Text="Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione."
|
<TextBlock Text="{Binding Loc[receive.hint]}"
|
||||||
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
|
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Storico">
|
<TabItem Header="{Binding Loc[tab.history]}">
|
||||||
<ListBox ItemsSource="{Binding History}" Margin="4">
|
<ListBox ItemsSource="{Binding History}" Margin="4">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:HistoryRow">
|
<DataTemplate x:DataType="vm:HistoryRow">
|
||||||
@@ -150,13 +238,13 @@
|
|||||||
</ListBox>
|
</ListBox>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Indirizzi">
|
<TabItem Header="{Binding Loc[tab.addresses]}">
|
||||||
<Grid RowDefinitions="Auto,*" Margin="4">
|
<Grid RowDefinitions="Auto,*" Margin="4">
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
|
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
|
||||||
<TextBlock Grid.Column="0" Text="Tipo" FontWeight="Bold"/>
|
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
|
||||||
<TextBlock Grid.Column="1" Text="Indice" FontWeight="Bold"/>
|
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
|
||||||
<TextBlock Grid.Column="2" Text="Indirizzo" FontWeight="Bold"/>
|
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
|
||||||
<TextBlock Grid.Column="3" Text="Saldo" FontWeight="Bold"/>
|
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
|
||||||
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
|
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
|
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
|
||||||
@@ -176,23 +264,25 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Invia">
|
<TabItem Header="{Binding Loc[tab.send]}">
|
||||||
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
|
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
|
||||||
<TextBox PlaceholderText="Indirizzo destinatario" Text="{Binding SendTo}"
|
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
|
||||||
FontFamily="monospace"/>
|
FontFamily="monospace"/>
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||||
<TextBox Grid.Column="0" PlaceholderText="Importo (es. 1.5)"
|
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
|
||||||
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
|
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
|
||||||
<CheckBox Grid.Column="1" Content="Invia tutto" Margin="10,0"
|
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
|
||||||
|
VerticalAlignment="Center" Margin="6,0" Foreground="Gray"/>
|
||||||
|
<CheckBox Grid.Column="2" Content="{Binding Loc[send.all]}" Margin="10,0"
|
||||||
IsChecked="{Binding SendAll}"/>
|
IsChecked="{Binding SendAll}"/>
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Text="fee sat/vB:" VerticalAlignment="Center"/>
|
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center"/>
|
||||||
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
|
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="Prepara transazione" Command="{Binding PrepareSendCommand}"/>
|
<Button Content="{Binding Loc[send.prepare]}" Command="{Binding PrepareSendCommand}"/>
|
||||||
<Button Content="CONFERMA E TRASMETTI" Classes="accent"
|
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||||
Command="{Binding ConfirmSendCommand}"
|
Command="{Binding ConfirmSendCommand}"
|
||||||
IsEnabled="{Binding HasPendingSend}"/>
|
IsEnabled="{Binding HasPendingSend}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
||||||
public event Action<string>? Progress;
|
public event Action<string>? Progress;
|
||||||
|
|
||||||
|
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
||||||
|
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
||||||
|
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
||||||
|
// costano solo ciò che è cambiato (modello Electrum).
|
||||||
|
private readonly Dictionary<string, Transaction> _txCache = [];
|
||||||
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
|
|
||||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tip = await client.SubscribeHeadersAsync(ct);
|
var tip = await client.SubscribeHeadersAsync(ct);
|
||||||
@@ -56,37 +63,45 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||||
txHeights[item.TxHash] = item.Height;
|
txHeights[item.TxHash] = item.Height;
|
||||||
|
|
||||||
// 4. Scarica le transazioni.
|
// 4. Scarica in parallelo le sole transazioni nuove.
|
||||||
Progress?.Invoke($"scarico {txHeights.Count} transazioni…");
|
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var transactions = new Dictionary<string, Transaction>();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
foreach (var txid in txHeights.Keys)
|
if (missing.Count > 0)
|
||||||
{
|
{
|
||||||
var hex = await client.GetTransactionAsync(txid, ct);
|
Progress?.Invoke($"scarico {missing.Count} transazioni…");
|
||||||
transactions[txid] = Transaction.Parse(hex, network);
|
var downloaded = await Task.WhenAll(missing.Select(async txid =>
|
||||||
|
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
|
||||||
|
foreach (var (txid, tx) in downloaded)
|
||||||
|
_txCache[txid] = tx;
|
||||||
}
|
}
|
||||||
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||||
|
|
||||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4).
|
// 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
|
||||||
var verified = new Dictionary<string, bool>();
|
// solo per le tx non ancora verificate a quell'altezza.
|
||||||
foreach (var (txid, height) in txHeights)
|
var toVerify = txHeights
|
||||||
|
.Where(kv => kv.Value > 0
|
||||||
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
|
.ToList();
|
||||||
|
if (toVerify.Count > 0)
|
||||||
{
|
{
|
||||||
if (height <= 0)
|
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
||||||
|
await Task.WhenAll(toVerify.Select(async kv =>
|
||||||
{
|
{
|
||||||
verified[txid] = false; // in mempool: nessuna prova possibile
|
var (txid, height) = kv;
|
||||||
continue;
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
}
|
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
||||||
var proof = await client.GetMerkleAsync(txid, height, ct);
|
var proof = await proofTask;
|
||||||
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
var ok = MerkleProof.Verify(
|
if (!MerkleProof.Verify(
|
||||||
uint256.Parse(txid),
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Pos,
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
proof.Merkle.Select(uint256.Parse),
|
|
||||||
header.MerkleRoot);
|
|
||||||
if (!ok)
|
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||||
verified[txid] = true;
|
}));
|
||||||
|
foreach (var (txid, height) in toVerify)
|
||||||
|
_verifiedAtHeight[txid] = height;
|
||||||
}
|
}
|
||||||
|
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||||
|
|
||||||
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
|
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
|
||||||
// script; spesi = outpoint consumati da una qualunque tx del wallet.
|
// script; spesi = outpoint consumati da una qualunque tx del wallet.
|
||||||
@@ -179,7 +194,9 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||||
/// vuoti consecutivi (§5). Ritorna il primo indice non usato.
|
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
||||||
|
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
||||||
|
/// Ritorna il primo indice non usato.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
||||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
||||||
@@ -187,23 +204,38 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
|||||||
var consecutiveEmpty = 0;
|
var consecutiveEmpty = 0;
|
||||||
var index = 0;
|
var index = 0;
|
||||||
var firstUnused = 0;
|
var firstUnused = 0;
|
||||||
for (; consecutiveEmpty < gapLimit; index++)
|
while (consecutiveEmpty < gapLimit)
|
||||||
{
|
{
|
||||||
var address = account.GetAddress(isChange, index);
|
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
||||||
var scripthash = Scripthash.FromAddress(address);
|
{
|
||||||
tracked.Add(new TrackedAddress(address, scripthash, isChange, index));
|
var address = account.GetAddress(isChange, i);
|
||||||
|
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
|
||||||
|
}).ToList();
|
||||||
|
index += batch.Count;
|
||||||
|
tracked.AddRange(batch);
|
||||||
|
|
||||||
// La subscribe registra anche la notifica push per i cambi futuri.
|
// La subscribe registra anche la notifica push per i cambi futuri.
|
||||||
var status = await client.SubscribeScripthashAsync(scripthash, ct);
|
var statuses = await Task.WhenAll(
|
||||||
if (status is null)
|
batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||||
|
|
||||||
|
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
|
||||||
|
var histories = await Task.WhenAll(
|
||||||
|
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||||
|
for (var i = 0; i < used.Count; i++)
|
||||||
|
historyByAddress[used[i].ScriptHash] = histories[i];
|
||||||
|
|
||||||
|
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||||
|
{
|
||||||
|
if (statuses[i] is null)
|
||||||
{
|
{
|
||||||
consecutiveEmpty++;
|
consecutiveEmpty++;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
historyByAddress[scripthash] = await client.GetHistoryAsync(scripthash, ct);
|
{
|
||||||
consecutiveEmpty = 0;
|
consecutiveEmpty = 0;
|
||||||
firstUnused = index + 1;
|
firstUnused = batch[i].Index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return firstUnused;
|
return firstUnused;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configurazione globale dell'applicazione (blueprint §8), separata dai file
|
||||||
|
/// wallet: lingua, unità di visualizzazione, ecc. Persistita in config.json
|
||||||
|
/// nella radice dei dati (vale per tutte le reti).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppConfig
|
||||||
|
{
|
||||||
|
/// <summary>Codice lingua UI ("it", "en").</summary>
|
||||||
|
public string Language { get; set; } = "it";
|
||||||
|
|
||||||
|
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||||
|
public string Unit { get; set; } = "PLM";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
|
|
||||||
|
public static AppConfig Load(string? path = null)
|
||||||
|
{
|
||||||
|
path ??= AppPaths.ConfigPath();
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return new AppConfig();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(path)) ?? new AppConfig();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Config corrotta: si riparte dai default senza bloccare l'avvio.
|
||||||
|
return new AppConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(string? path = null)
|
||||||
|
{
|
||||||
|
path ??= AppPaths.ConfigPath();
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, JsonSerializer.Serialize(this, JsonOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,51 @@ public static class CoinAmount
|
|||||||
{
|
{
|
||||||
public const long SatsPerCoin = 100_000_000;
|
public const long SatsPerCoin = 100_000_000;
|
||||||
|
|
||||||
|
/// <summary>Unità di visualizzazione selezionabili (config §8).</summary>
|
||||||
|
public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
|
||||||
|
|
||||||
|
/// <summary>(satoshi per unità, decimali mostrati) di ciascuna unità.</summary>
|
||||||
|
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
||||||
|
{
|
||||||
|
"mPLM" => (100_000, 5),
|
||||||
|
"µPLM" => (100, 2),
|
||||||
|
"sat" => (1, 0),
|
||||||
|
_ => (SatsPerCoin, 8), // PLM
|
||||||
|
};
|
||||||
|
|
||||||
public static string Format(long sats, string unit = "") =>
|
public static string Format(long sats, string unit = "") =>
|
||||||
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
||||||
+ (unit.Length > 0 ? " " + unit : "");
|
+ (unit.Length > 0 ? " " + unit : "");
|
||||||
|
|
||||||
|
/// <summary>Formatta nell'unità scelta (es. 150000 sat → "1.50000 mPLM").</summary>
|
||||||
|
public static string FormatIn(long sats, string unit, bool withLabel = true)
|
||||||
|
{
|
||||||
|
var (factor, decimals) = Of(unit);
|
||||||
|
var value = (sats / (decimal)factor).ToString(
|
||||||
|
decimals == 0 ? "0" : "0." + new string('0', decimals), CultureInfo.InvariantCulture);
|
||||||
|
return withLabel ? $"{value} {unit}" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parsa un importo espresso nell'unità scelta in satoshi.</summary>
|
||||||
|
public static bool TryParseIn(string text, string unit, out long sats)
|
||||||
|
{
|
||||||
|
sats = 0;
|
||||||
|
var (factor, _) = Of(unit);
|
||||||
|
text = text.Trim().Replace(',', '.');
|
||||||
|
if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var value)
|
||||||
|
|| value < 0)
|
||||||
|
return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sats = (long)(value * factor);
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Parsa un importo in coin (punto o virgola decimale) in satoshi.</summary>
|
/// <summary>Parsa un importo in coin (punto o virgola decimale) in satoshi.</summary>
|
||||||
public static bool TryParseCoins(string text, out long sats)
|
public static bool TryParseCoins(string text, out long sats)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -156,4 +156,53 @@ public class TransactionFactoryTests
|
|||||||
Assert.False(CoinAmount.TryParseCoins("abc", out _));
|
Assert.False(CoinAmount.TryParseCoins("abc", out _));
|
||||||
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.5 PLM = 150_000_000 sat, espressi in ciascuna unità (§8).
|
||||||
|
[Theory]
|
||||||
|
[InlineData("PLM", "1.50000000 PLM", "1.5")]
|
||||||
|
[InlineData("mPLM", "1500.00000 mPLM", "1500")]
|
||||||
|
[InlineData("µPLM", "1500000.00 µPLM", "1500000")]
|
||||||
|
[InlineData("sat", "150000000 sat", "150000000")]
|
||||||
|
public void Le_unita_formattano_e_parsano_in_modo_coerente(string unit, string formatted, string input)
|
||||||
|
{
|
||||||
|
const long sats = 150_000_000;
|
||||||
|
Assert.Equal(formatted, CoinAmount.FormatIn(sats, unit));
|
||||||
|
Assert.True(CoinAmount.TryParseIn(input, unit, out var parsed));
|
||||||
|
Assert.Equal(sats, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void La_config_globale_fa_roundtrip_su_file()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"plm-config-{Guid.NewGuid()}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = new PalladiumWallet.Core.Storage.AppConfig { Language = "en", Unit = "sat" };
|
||||||
|
config.Save(path);
|
||||||
|
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
|
||||||
|
Assert.Equal("en", loaded.Language);
|
||||||
|
Assert.Equal("sat", loaded.Unit);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_config_corrotta_torna_ai_default()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"plm-config-{Guid.NewGuid()}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, "{ rotto ");
|
||||||
|
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
|
||||||
|
Assert.Equal("it", loaded.Language);
|
||||||
|
Assert.Equal("PLM", loaded.Unit);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user