diff --git a/src/App/App.axaml b/src/App/App.axaml
new file mode 100644
index 0000000..a86cd94
--- /dev/null
+++ b/src/App/App.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/App/App.axaml.cs b/src/App/App.axaml.cs
new file mode 100644
index 0000000..a5609f4
--- /dev/null
+++ b/src/App/App.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core;
+using Avalonia.Data.Core.Plugins;
+using System.Linq;
+using Avalonia.Markup.Xaml;
+using PalladiumWallet.App.ViewModels;
+using PalladiumWallet.App.Views;
+
+namespace PalladiumWallet.App;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new MainWindowViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
\ No newline at end of file
diff --git a/src/App/Assets/avalonia-logo.ico b/src/App/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/src/App/Assets/avalonia-logo.ico differ
diff --git a/src/App/Program.cs b/src/App/Program.cs
new file mode 100644
index 0000000..1104663
--- /dev/null
+++ b/src/App/Program.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using System;
+
+namespace PalladiumWallet.App;
+
+sealed class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/App/ViewLocator.cs b/src/App/ViewLocator.cs
new file mode 100644
index 0000000..03af1dd
--- /dev/null
+++ b/src/App/ViewLocator.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using PalladiumWallet.App.ViewModels;
+
+namespace PalladiumWallet.App;
+
+///
+/// Given a view model, returns the corresponding view if possible.
+///
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..6ec15a7
--- /dev/null
+++ b/src/App/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,396 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+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;
+
+/// Riga dello storico transazioni per la vista.
+public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
+
+///
+/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
+/// pannello di setup (crea/ripristina/apri) e pannello wallet
+/// (saldo, ricevi, storico, invia, server).
+///
+public partial class MainWindowViewModel : ViewModelBase
+{
+ private WalletDocument? _doc;
+ private HdAccount? _account;
+ private string? _walletPath;
+ private string? _password;
+ private ElectrumClient? _client;
+ private IReadOnlyDictionary? _lastTransactions;
+ private BuiltTransaction? _pendingSend;
+
+ // ---- 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 = "";
+
+ // ---- pannello setup ----
+
+ [ObservableProperty]
+ private string mnemonicInput = "";
+
+ [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";
+
+ [ObservableProperty]
+ private bool isSyncing;
+
+ public ObservableCollection History { get; } = [];
+
+ // ---- 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();
+ }
+
+ private NetKind Net => Enum.Parse(SelectedNetwork, ignoreCase: true);
+ private ChainProfile Profile => ChainProfiles.For(Net);
+
+ partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
+
+ private void RefreshSetupState()
+ {
+ WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
+ StatusMessage = WalletFileExists
+ ? "Trovato un wallet esistente: inserisci la password (se impostata) e apri."
+ : "Nessun wallet su questa rete: creane uno nuovo o ripristina da seed.";
+ ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}";
+ }
+
+ // ---------- comandi setup ----------
+
+ [RelayCommand]
+ private void GenerateMnemonic()
+ {
+ MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
+ StatusMessage = "Nuova mnemonica generata: SCRIVILA SU CARTA prima di continuare.";
+ }
+
+ [RelayCommand]
+ private void CreateOrRestore()
+ {
+ try
+ {
+ var (doc, account) = WalletLoader.NewFromMnemonic(
+ MnemonicInput,
+ string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
+ ScriptKind.NativeSegwit,
+ Profile);
+ var path = AppPaths.DefaultWalletPath(Net);
+ 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
+ {
+ var path = AppPaths.DefaultWalletPath(Net);
+ var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
+ var doc = WalletStore.Load(path, password);
+ OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password);
+ }
+ catch (WrongPasswordException)
+ {
+ StatusMessage = "Password errata.";
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Errore: {ex.Message}";
+ }
+ }
+
+ private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
+ {
+ _doc = doc;
+ _account = account;
+ _walletPath = path;
+ _password = password;
+ MnemonicInput = PassphraseInput = PasswordInput = "";
+
+ NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
+ + (doc.IsWatchOnly ? " · watch-only" : "");
+ ApplyCache(doc.Cache);
+ IsWalletOpen = true;
+ StatusMessage = doc.Cache is null
+ ? "Wallet aperto. Connettiti a un server per sincronizzare."
+ : "Wallet aperto (dati dell'ultima sincronizzazione).";
+ }
+
+ 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();
+ return;
+ }
+ BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
+ UnconfirmedText = cache.UnconfirmedSats != 0
+ ? $"+ {CoinAmount.Format(cache.UnconfirmedSats)} non confermato"
+ : "";
+ 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" : "—"));
+ }
+
+ // ---------- comandi wallet ----------
+
+ [RelayCommand]
+ private async Task ConnectAndSync()
+ {
+ if (_account is null || _doc is null)
+ return;
+ 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(() =>
+ ConnectionStatus = "disconnesso");
+ ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}";
+ }
+
+ var sync = new WalletSynchronizer(_account, _client, _doc.GapLimit);
+ sync.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
+ var result = await sync.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],
+ };
+ WalletStore.Save(_doc, _walletPath!, _password);
+ ApplyCache(_doc.Cache);
+ StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
+ $"{result.History.Count} transazioni verificate SPV.";
+ }
+ catch (CertificatePinMismatchException ex)
+ {
+ ConnectionStatus = "certificato cambiato";
+ StatusMessage = ex.Message;
+ }
+ catch (Exception ex)
+ {
+ ConnectionStatus = "errore";
+ StatusMessage = $"Errore: {ex.Message}";
+ }
+ finally
+ {
+ IsSyncing = false;
+ }
+ }
+
+ private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
+ {
+ // Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
+ if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (!IsSyncing)
+ _ = 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;
+ _doc = null;
+ _account = null;
+ _lastTransactions = null;
+ _pendingSend = null;
+ HasPendingSend = false;
+ History.Clear();
+ IsWalletOpen = false;
+ 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);
+ }
+}
diff --git a/src/App/ViewModels/ViewModelBase.cs b/src/App/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..85d0b15
--- /dev/null
+++ b/src/App/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace PalladiumWallet.App.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/src/App/Views/MainWindow.axaml b/src/App/Views/MainWindow.axaml
new file mode 100644
index 0000000..6e16318
--- /dev/null
+++ b/src/App/Views/MainWindow.axaml
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App/Views/MainWindow.axaml.cs b/src/App/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..0d0f40d
--- /dev/null
+++ b/src/App/Views/MainWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace PalladiumWallet.App.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/App/app.manifest b/src/App/app.manifest
new file mode 100644
index 0000000..767f665
--- /dev/null
+++ b/src/App/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+