22 Commits

Author SHA1 Message Date
davide 658fcdbced docs: update CLAUDE.md and README for multi-head architecture and .NET 10
CLAUDE.md: reflect Desktop+Android split, net10.0, updated build/run
commands (src/App.Desktop instead of src/App), note blueprint is now
reference-only not binding.

README: update project paths, publish commands and quickstart for the
new structure.
2026-06-12 16:06:24 +02:00
davide e94eaf7700 refactor(arch): split App into shared library + Desktop + Android heads
The Avalonia UI code (App, Views, ViewModels, Localization, Assets) now
lives in src/App as a plain library (no OutputType). Two thin heads
reference it:

- src/App.Desktop/ — WinExe, Avalonia.Desktop, hosts MainView in a
  MainWindow; carries Program.cs and app.manifest (moved from src/App)
- src/App.Android/ — net10.0-android, Avalonia.Android, MainActivity/
  MainApplication; targets API 23+, EmbedAssembliesIntoApk=true so the
  apk is self-contained for sideloading

All event handlers and TopLevel-dependent calls (file picker, clipboard,
folder picker) moved from MainWindow.axaml.cs into the new shared
MainView.axaml.cs (UserControl), using TopLevel.GetTopLevel(this) so
they work on both platforms. Esc/Back key handling is also in MainView.
MainWindow becomes a thin shell that hosts MainView.

Framework bump: all projects move to net10.0; Cli and Tests follow.
2026-06-12 16:06:46 +02:00
davide a9ded6497a chore: add MIT license (copyright 2026 Davide Grilli) 2026-06-12 12:12:36 +02:00
davide 46aca513b8 docs: add README with project overview and quickstart
Covers what Palladium Wallet is, the tech stack, build/test/run
commands and current implementation status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:12:34 +02:00
davide 6c01d7e6bd docs(claude): add CLAUDE.md with codebase guidance for AI tooling
Documents stack, dependency rules, build commands, architecture
decisions and current implementation status for Claude Code.
2026-06-12 12:13:06 +02:00
davide 3cffba6e98 chore(git): track CLAUDE.md and blueprint.md
These files document the project for both contributors and AI tooling;
they belong in version control alongside the code they describe.
2026-06-12 12:12:14 +02:00
davide 865daa137d feat(app): version in window title and Help overlay
- csproj: add <Version>0.9.0</Version> as single source of truth
- AppVersion reads Major.Minor.Build from the assembly; WindowTitle
  exposes "Palladium Wallet 0.9.0" bound to Window.Title
- Menu Help → in-app overlay (same pattern as settings/server overlays)
  showing app name, version and a short description; backdrop tap and
  Esc close it
- i18n: menu.help, help.title, help.info in all 6 languages
2026-06-12 12:01:25 +02:00
davide 4735490759 feat(app): transaction detail overlay with full on-chain data
Double-clicking a history row opens an in-app overlay (same pattern as
address/server/settings overlays) that shows all available data for a
transaction. The overlay appears immediately with a spinner; data is
fetched from the server in the background and rendered once ready.
Backdrop tap and Esc close it; a pending fetch is cancelled.

New Core type — TransactionInspector (Core/Wallet/):
- FetchAsync() downloads the raw tx, all parent txs (parallel requests,
  one round-trip instead of N sequential), and the block header
- Reconstructs inputs (amounts, addresses, is-mine), outputs, fee,
  virtual size, RBF flag, block timestamp, confirmations

New App types:
- TransactionDetails record: full parsed data (fee, net, I/O lists,
  counterparty addresses, coinbase detection…)
- TransactionDetailsViewModel: pre-formats all strings in the current
  locale and unit (status, date, signed amounts, sat/vB rate…)
- TxIoRow record: one row in the input/output tables
- MineColorConverter: bool → brush (MediumSeaGreen for own addresses)

ViewModel changes:
- ShowTransactionDetailsAsync / CloseTransactionDetailsCommand
- BuildTransactionDetailsAsync (also public for future RBF/CPFP use)
- CancellationTokenSource so opening a new tx cancels the previous fetch
- All server work runs on Task.Run to keep the UI thread free

XAML: history rows get DoubleTapped handler, Hand cursor, hint label,
txid now uses TextTrimming instead of SelectableTextBlock.

i18n: 24 new tx.* / history.hint keys in all 6 languages.
2026-06-12 11:48:33 +02:00
davide 4c7e8696cb feat(app): copy-to-clipboard button for receive address
Adds a "Copy" button next to the receive address in the Receive tab.
Uses Avalonia's Clipboard API (async, code-behind) and calls
NotifyAddressCopied() to show the existing addr.copied status message.
New i18n key receive.copy in all 6 languages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:47:01 +02:00
davide 7727dbfddc feat(app): replace placeholder icon with Palladium Wallet logo
Add logo.png (source) and logo.ico (multi-size: 256/128/64/48/32/16)
generated with Pillow. Remove avalonia-logo.ico (Avalonia template
placeholder). Update MainWindow.axaml Icon and csproj ApplicationIcon
to point to the new files.
2026-06-12 10:40:29 +02:00
davide 58b86ad1af feat(app): QR code for receive address
Adds QRCoder 1.8.0 and generates a PNG QR code in-memory each time
the receive address changes (OnReceiveAddressChanged). The bitmap is
displayed in the Receive tab inside a white-background border with
pixel-perfect scaling (BitmapInterpolationMode=None). Previous bitmap
is disposed to avoid memory leaks.
2026-06-12 10:21:18 +02:00
davide 7f2759b2fc feat(app): multi-wallet chooser, confirm-password, encrypt toggle
Multi-wallet (§8):
- AppPaths.WalletFiles(net) enumerates all *.wallet.json in the wallets
  dir; WizardStartOpen shows a chooser step when more than one is found
- New StepChooseWallet + ChooseWalletCommand; Back from StepOpen returns
  to chooser (or to StepStart when only one wallet)
- WalletFileEntry record for the chooser ItemsControl

Password step (Electrum style):
- ConfirmPasswordInput: password must be typed twice before creating
- EncryptWallet checkbox (default true); when unchecked, password is
  null (plaintext file) with an explicit warning
- Validation: empty password with encryption on → msg.password.required;
  mismatch → msg.password.mismatch
- ConfirmPasswordInput cleared on wallet open and wizard back

AppPaths:
- DefaultDataRoot() is now platform-aware: %APPDATA%\PalladiumWallet on
  Windows, ~/.palladium-wallet on Linux/macOS (matches §8 convention)
- Legacy root fallback removed (no prior releases to migrate from)

i18n: wiz.choose.title, wiz.password.confirm, wiz.password.encrypt,
wiz.password.encrypt.hint, msg.choose.wallet, msg.password.required,
msg.password.mismatch — all 6 languages
2026-06-12 10:13:17 +02:00
davide a8a97f09b7 feat(app): connect to server before wallet open, status in bottom bar
- ConnectAndSync() no longer bails early when no wallet is open:
  connection is established immediately after the wizard completes
  (Electrum-style), sync starts only once an account is available
- KeepAlive tick no longer requires IsWalletOpen so the connection is
  maintained and auto-reconnected at all times
- WalletSynchronizer is lazily created on first sync (not on connect)
  to avoid a null-ref guard on _synchronizer
- Connection status indicator (dot + text) moved from wallet header to
  the status bar (always visible); tapping it still opens the server
  settings overlay
- Menu "Rete" removed: Discover and ResetCerts are inside the server
  settings overlay; redundant top-level menu entry eliminated
- "Server" button in settings overlay is no longer gated on IsWalletOpen
2026-06-12 10:13:04 +02:00
davide f3bf4cf94a feat(app): first-run data location wizard step
On first launch (no data yet, no portable dir, no pointer file),
the wizard now shows a new step-0 screen asking where to store
wallets, config and certificates.

AppPaths changes:
- DefaultDataRoot() → ~/.PalladiumWallet (home, always writable)
- IsDataLocationConfigured() → true when portable / override / pointer
  already written / legacy or default already has data
- ConfigureDataLocation(root) writes a bootstrap pointer file and
  creates the directory
- DataRoot() resolution order: override → portable → pointer → legacy
  (has data) → default

ViewModel: new StepDataLocation step, UseDefaultDataLocationCommand,
ApplyDataLocation(root) (also called from View's folder picker).

View: new wizard panel with description, default-path display, two
buttons (use default / choose folder); folder picker via
StorageProvider.OpenFolderPickerAsync.

Loc: add wiz.data.* keys (6 languages); fix fallback language "it"→"en";
update test assertion accordingly.
2026-06-12 09:25:04 +02:00
davide 87e1c82610 feat(app): connection status indicator opens server settings on tap
The green/red dot + status text in the wallet header is now tappable:
opens the server settings overlay directly. Added Hand cursor and
tooltip (server title) to signal interactivity.
Status text uses SystemAccentColor instead of Gray.
2026-06-12 09:24:57 +02:00
davide 51c87a7dc9 refactor(app): replace nested settings submenu with in-app overlay
The Impostazioni menu now opens a single overlay (same pattern as
address/server overlays) instead of nested OS popup menus, which are
slow to respond under WSLg.

The overlay groups language (RadioButton) and unit (RadioButton) in one
panel, with a button to open the server overlay. Esc and backdrop tap
close it. OpenServerSettings() closes the settings overlay first so the
two overlays never stack.
2026-06-12 09:09:31 +02:00
davide 28cb4ce6ae feat(app): server settings as in-app overlay, split host/port fields
- Moves server configuration from the wallet panel into an overlay
  (Impostazioni → Server), same pattern as the address detail overlay
- Splits the single "host:port" text box into separate Host and Port
  fields; port changes auto-toggle the TLS checkbox when the typed
  port matches a known SSL/TCP port, and vice-versa
- Adds DisconnectAsync() so changing server/TLS reconnects to the new
  endpoint instead of reusing the old socket
- Adds i18n keys (IT/EN/ES/FR/PT/DE) for the new overlay
- Removes the server row from the main wallet panel; connection status
  indicator (green/red dot) is now shown in the wallet header
2026-06-12 09:02:19 +02:00
davide cf6e2d7654 refactor(app): replace AddressInfoWindow with in-app overlay
The address detail panel is now rendered as a modal overlay inside
MainWindow instead of a separate top-level Window, avoiding the
create/destroy cost of a dialog and working better under WSLg.

Backdrop tap and Esc both close the overlay.
2026-06-12 08:22:30 +02:00
davide fe320584eb feat(app): navbar restructure, contacts tab and address info window
- Reorder tabs: Storico / Invia / Ricevi / Indirizzi / Contatti
- Invia: ComboBox to quick-fill recipient from saved contacts
- Contatti: new tab to add/remove contacts (name + address), persisted
  to the wallet file via WalletDocument.Contacts
- Indirizzi: clicking any address (left or right click) opens a modal
  window with derivation path, public key and private key as selectable
  text (no copy button needed — user selects and copies manually);
  AddressRow now carries PubKey, PrivKey, DerivPath pre-computed
- AddressInfoWindow: new Window with SelectableTextBlock per field,
  private key section hidden for watch-only wallets
- Loc: adds tab.contacts, addr.*, contacts.*, send.from.contact keys
  in all 6 languages
2026-06-11 21:39:36 +02:00
davide 6fe31964e1 feat(storage): add contacts list to WalletDocument
Adds StoredContact class and a Contacts list to WalletDocument so the
address book survives app restarts inside the encrypted wallet file.
2026-06-11 21:39:18 +02:00
davide 8ab8bbd8b3 feat(storage): XDG-compliant paths and default language English
AppPaths: use Environment.SpecialFolder.ApplicationData for all platforms
  - Windows  → %APPDATA%\PalladiumWallet  (unchanged)
  - Linux    → ~/.config/PalladiumWallet  (XDG standard, was ~/.palladium-wallet)
  - macOS    → ~/Library/Application Support/PalladiumWallet

AppConfig: change default language from "it" to "en" for new installs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:41:47 +02:00
davide 71a604c8a5 feat(i18n): add ES, FR, PT, DE languages and fix live switching
Add Spanish, French, Portuguese, German translations to all UI strings.

Fix language switching in Avalonia compiled bindings: instead of firing
PropertyChanged("Item[]") on a singleton (which Avalonia ignores when the
object reference is unchanged), Loc.SwitchTo() now creates a new Loc
instance for the selected language and replaces both Loc.Instance and the
ViewModel's _loc field. OnPropertyChanged("Loc") then forces Avalonia to
re-evaluate all {Binding Loc[key]} bindings with the new instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:41:31 +02:00
34 changed files with 2763 additions and 1239 deletions
+65 -35
View File
@@ -2,57 +2,87 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Cos'è questo progetto ## Role
Wallet desktop SPV (stile Sparrow) per la criptovaluta **Palladium (PLM)**, una catena UTXO derivata da Bitcoin. Solo Windows e Linux, niente mobile. **Lightning è esplicitamente escluso dal primo rilascio** (§11 del blueprint). Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
**La fonte di verità è [blueprint.md](blueprint.md)**: specifica completa con parametri di consenso verificati contro il nodo, algoritmi, protocollo di rete e sequenza di costruzione. Prima di implementare qualsiasi funzionalità, leggere la sezione corrispondente del blueprint. La sequenza di costruzione da seguire è quella del §16 (profilo rete → crypto/chiavi → persistenza → rete → SPV → transazioni → GUI → hardware/multisig). ## How to assist
## Stack On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
.NET 8 + Avalonia UI + NBitcoin (§19 del blueprint). Struttura della solution: ## What it is
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
[blueprint.md](blueprint.md) is a **reference for understanding** (consensus parameters verified against the node, algorithms, network protocol): consult it when it helps to understand an area, but **it is no longer binding** — it need not be followed to the letter or read before every change. The source of truth is the current code; the `§` references below point to the blueprint only as further reading.
## Stack and structure
.NET 10 + Avalonia UI 12 + NBitcoin.
``` ```
PalladiumWallet.sln src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
├─ src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (nessuna dipendenza UI) src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
├─ src/App/ Avalonia UI src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
├─ src/Cli/ CLI sullo stesso Core src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
└─ tests/ xUnit src/Cli/ CLI on the same Core tests/ xUnit
``` ```
**Regola di dipendenza non negoziabile:** `App` e `Cli` dipendono solo da `Core`; la GUI parla solo con l'Application API, mai direttamente con rete o crittografia. `Core` non conosce la UI. The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
by `MainWindow` on desktop and as the single-view root on Android.
## Comandi **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
Il .NET 8 SDK è installato in `~/.dotnet` (via dotnet-install.sh, senza root): nelle shell non interattive serve `export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"` prima dei comandi `dotnet`. ## Commands
.NET 10 SDK lives in `~/.dotnet`: in non-interactive shells, before any `dotnet` command run
`export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"`.
- Build: `dotnet build` - Build: `dotnet build`
- Test (headless, è il livello principale di verifica): `dotnet test` - Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`
- Singolo test: `dotnet test --filter "FullyQualifiedName~NomeTest"` - GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
- CLI contro testnet/regtest: `dotnet run --project src/Cli -- <comando>` - CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
- GUI con hot reload: `dotnet watch --project src/App` - Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
- Su questa macchina (WSL2 con WSLg) la finestra appare direttamente sul desktop Windows: niente X server o librerie da installare, è già tutto verificato funzionante. - Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
- Publish Windows: `dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained`
- Publish Linux: `dotnet publish -r linux-x64 --self-contained` (poi AppImage via PupNet Deploy)
La logica core e la crypto si testano senza GUI né rete reale; la GUI serve solo per rifinire l'interfaccia (§19.7). **Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
## Comandi CLI principali (src/Cli) **CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
`create`/`restore`/`restore-xpub`/`info` per i wallet; `sync`/`send`/`reset-certs` contro il server di indicizzazione (`--server host:porta [--ssl]`); `newseed`/`addresses` come strumenti. Eseguire senza argomenti per l'usage completo. Il file wallet di default è `~/.palladium-wallet/<rete>/wallets/default.wallet.json` (sovrascrivibile con `--file`). ## Architecture (points that require reading multiple files)
## Architettura — punti che richiedono più file per essere capiti - **Layers (§2):** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
- **Network profile (§3):** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
- **LWMA / skip PoW (§3, §7):** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints** (§7.3). Custom layer: NBitcoin assumes Bitcoin's retargeting.
- **NBitcoin vs custom (§19.2):** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like, §10); SPV sync with Merkle verification (§7.4); header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
- **PSBT-centric (§6.5):** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
- **Livelli (§2):** GUI → Application API → Dominio wallet → SPV/Sync → Rete → Crittografia → Persistenza. Ogni livello dipende solo verso il basso. ## GUI conventions (`src/App`)
- **Profilo di rete (§3):** tutte le costanti di catena (prefissi indirizzi, header BIP32, HRP bech32, genesi, porte, coin_type 746) vanno **centralizzate in `Core/Chain`** via `NetworkBuilder` e selezionabili per rete (mainnet/testnet/regtest). Nessun magic number sparso nel codice.
- **LWMA / skip PoW (§3, §7):** la catena usa difficoltà LWMA con blocchi da 2 minuti; un client SPV non può ricalcolarla, quindi `skip_pow_validation = true` e la fiducia è ancorata ai **checkpoint hardcoded** (§7.3). Questo strato è custom: NBitcoin assume il retargeting di Bitcoin.
- **Cosa fornisce NBitcoin vs cosa è custom (§19.2):** NBitcoin copre rete custom, BIP32/39, indirizzi, transazioni, PSBT, firma, encoding, hashing — **non reimplementarli**. Va scritto a mano: client JSON-RPC del server di indicizzazione (protocollo ElectrumX-like, §10) con pool, TLS pinning TOFU e proxy/Tor; sincronizzazione SPV con verifica Merkle (§7.4); validazione header/checkpoint; coin selection e fee policy; file wallet JSON cifrato versionato.
- **PSBT-centrico (§6.5):** ogni flusso di firma passa per PSBT (offline, air-gapped, multisig, hardware).
- **Le porte di default (50001/50002) sono del server di indicizzazione**, non la porta P2P del nodo (2333): il wallet SPV parla solo col server di indicizzazione.
- **Stato implementato (passi 17 del §16):** `Core/Chain` profili rete; `Core/Crypto` BIP39/32/SLIP-132/HdAccount; `Core/Storage` file wallet JSON v1 + AES-GCM (PBKDF2-SHA512) + percorsi dati; `Core/Net` ElectrumClient (JSON-RPC newline su TCP/TLS, TOFU in `server-certs.json`); `Core/Spv` scripthash, verifica Merkle obbligatoria su ogni tx confermata, sincronizzazione con gap limit; `Core/Wallet` TransactionFactory (RBF on, send-all, PSBT per watch-only) e WalletLoader (doc→account). GUI Avalonia a pannello unico in `MainWindowViewModel`. Mancano (TODO §16 passi 8-9): multisig, hardware wallet, coin control UI, fee ETA/mempool, RBF/CPFP UI, header chain completa su disco, pool multi-server, proxy/Tor.
## Regole di lavoro - **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
- **Test cross-implementazione obbligatori (§16):** ad ogni passo confrontare indirizzi, txid e PSBT con un wallet di riferimento (golden vectors). Un indirizzo o txid diverso è un bug bloccante. ## Implementation state (§16 steps 17 + GUI)
- **Sicurezza (§17):** seed e chiavi private mai in chiaro su disco, mai nei log, mai in rete; ogni risposta dei server va validata con prove di Merkle + checkpoint; watch-only realmente read-only.
- Le funzionalità marcate *(opzionale)* nel blueprint possono essere rimandate ma vanno comunque considerate nella progettazione. `Core/Chain` network profiles; `Core/Crypto` BIP39/32/SLIP-132/`HdAccount`; `Core/Storage` JSON wallet v1 + AES-GCM (PBKDF2-SHA512) + data paths; `Core/Net` `ElectrumClient` (newline JSON-RPC over TCP/TLS, TOFU in `server-certs.json`, concurrent requests) + `ElectrumApi`; `Core/Spv` scripthash, mandatory Merkle verification on every confirmed tx, sync with gap limit; `Core/Wallet` `TransactionFactory` (RBF on, send-all, watch-only PSBT), `TransactionInspector` (tx detail from the server), `WalletLoader`. GUI: setup wizard, dashboard (history/send/receive with QR+copy/addresses/contacts), transaction detail, settings/server/help, multi-wallet. Runs on desktop and Android from one shared UI (debug apk builds end-to-end).
**TODO (§16 steps 89):** multisig, hardware wallet, coin control UI, fee ETA/mempool, RBF/CPFP UI, on-disk header chain, multi-server pool, proxy/Tor.
## Working rules
- **Cross-implementation tests (§16):** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
- **Security (§17):** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
- *(Optional)* blueprint features may be deferred but must still be considered in the design.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Davide Grilli
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -40,11 +44,21 @@ Global
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU {C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933} {C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+312
View File
@@ -0,0 +1,312 @@
# Palladium Wallet
**An SPV wallet built specifically for the Palladium (PLM) cryptocurrency** and optimized for its chain. Runs on desktop (Windows/Linux) and Android from a single shared codebase.
Unlike generic wallets adapted to many coins, Palladium Wallet is designed around Palladium's consensus parameters — a Bitcoin-derived UTXO chain with 2-minute blocks and LWMA difficulty — and centralizes them in a single network profile. This keeps it lightweight, predictable and faithful to the chain: no client-side difficulty recalculation (trust is anchored to hardcoded checkpoints), mandatory Merkle verification on every confirmed transaction, and a network client written specifically for Palladium's indexing server.
## Features
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub.
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
- **Multi-network**: mainnet, testnet, regtest.
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German.
## Architecture
```
PalladiumWallet.sln
├─ src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
├─ src/App/ shared Avalonia UI library (Views, ViewModels, Loc, Assets)
├─ src/App.Desktop/ desktop head (Windows/Linux) → runnable
├─ src/App.Android/ Android head → apk
├─ src/Cli/ CLI on the same Core
└─ tests/ xUnit
```
The UI is written **once** in `src/App`; the desktop and Android heads only add the per-platform
entry point and packages.
Stack: **.NET 10 + Avalonia UI 12 + NBitcoin**.
---
## Development environment
For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto are fully testable without the GUI or a real network.
### Windows
1. Install the .NET 10 SDK:
```powershell
winget install Microsoft.DotNet.SDK.10
```
(alternatively, the installer from <https://dotnet.microsoft.com/download/dotnet/10.0>)
2. Clone the repository and restore dependencies:
```powershell
git clone <repo-URL>
cd PalladiumWallet
dotnet restore
```
### Linux
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
```bash
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0
export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"
```
(add the two `export` lines to your `~/.bashrc` to make them permanent)
2. Clone and restore:
```bash
git clone <repo-URL>
cd PalladiumWallet
dotnet restore
```
> The GUI uses Avalonia, which runs natively on Windows and Linux with no extra graphics dependencies.
### Android (additional setup)
Building the apk also requires the Android workload, a JDK, and the Android SDK:
```bash
dotnet workload install android # .NET Android build packs
# JDK 17+ must be available (set JAVA_HOME)
# Provision the Android SDK once into ~/android-sdk:
dotnet build src/App.Android -t:InstallAndroidDependencies \
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true
```
To run the apk on an emulator (instead of a physical device), see
[*Android emulator (developer setup)*](#android-emulator-developer-setup) below.
---
## Running it
### Desktop GUI in debug (Linux & Windows)
The desktop head runs the same way on both OSes (`Debug` is the default configuration). Run it from
the repo root:
```bash
dotnet run --project src/App.Desktop # single run (Debug)
dotnet watch --project src/App.Desktop # with hot reload (edit XAML/C# and see changes live)
dotnet run --project src/App.Desktop -c Release # to try the Release config
```
- **Linux** — runs natively on X11/Wayland, no extra graphics packages. On **WSL2** the window
appears on the Windows desktop through WSLg (already working here, nothing to install).
- **Windows** — runs natively; use the same commands from PowerShell or a terminal.
The app writes its data under the per-user data folder (see *User guide → First launch*); delete it
to start from a clean first-run wizard.
### CLI
Same core, useful for scripts and headless environments:
```bash
dotnet run --project src/Cli -- <command>
```
Run without arguments for the full list of commands.
### Android
There is no `dotnet run` for a phone: build the apk and install it (see *Building → Android apk*),
or run it on an emulator (see *Android emulator (developer setup)*).
---
## Running tests
Tests are the **primary verification layer** — the core logic and crypto run headless, without the GUI or a real network.
Run the whole suite:
```bash
dotnet test
```
Run a single test (or a group) by name:
```bash
dotnet test --filter "FullyQualifiedName~TestName"
```
Run only the tests in one project:
```bash
dotnet test tests/PalladiumWallet.Tests
```
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
---
## Building
### Development build
```bash
dotnet build # whole solution (debug)
dotnet build src/App.Desktop # desktop head only
```
> A solution-wide `dotnet build` also builds the Android head, which needs the Android SDK
> (see *Android emulator (developer setup)* below). If you don't have it, build the specific
> non-Android projects (`src/App.Desktop`, `src/Cli`, `tests/...`).
### Desktop release (self-contained)
```bash
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true --self-contained
# Linux — self-contained; AppImage then produced with PupNet Deploy
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
dotnet publish src/App.Desktop -c Release -r linux-x64 --self-contained
```
[PupNet Deploy](https://github.com/kuiperzone/PupNet-Deploy) turns the Linux publish into an AppImage.
The executable is named `PalladiumWallet` (set via `<AssemblyName>` in the desktop head).
### Android apk
Prerequisites: the Android workload + SDK (see *Development environment → Android*). The Android
head already sets `<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>`, so the apk is
**self-contained** and installs/runs standalone (a Fast-Deployment debug apk crashes at launch
with "No assemblies found" when installed without `adb`).
```bash
# Default debug apk — all ABIs (arm64-v8a + x86_64): runs on phones AND the x86_64 emulator.
# ~79 MB. Output: src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
-p:AndroidSdkDirectory=$HOME/android-sdk
# Smaller apk for a real phone — arm64 only (~41 MB):
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AbiArm64Only=true
```
The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android head's
`<RuntimeIdentifiers>` in its csproj — do **not** pass `-p:RuntimeIdentifiers=android-arm64` on the
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
`AndroidSupportedAbis` property is deprecated and ignored.)
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) Release signing with your own
keystore is not set up yet; the debug apk is fine for personal sideloading.
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
> `arm64-v8a` only) but is meant for a physical arm64 phone — on the x86_64 emulator it only runs
> through slow ARM translation and stalls on the splash, so **verify it on a real device**.
### Version
The application **version** is set in a single place: the `<Version>` tag in
[`src/App/PalladiumWallet.App.csproj`](src/App/PalladiumWallet.App.csproj). It appears in the desktop
window title, in the Help dialog, and is stamped into the published binaries (and the apk's versionName).
---
## Android emulator (developer setup)
How to run the apk without a physical device. Paths assume the Android SDK in `~/android-sdk`
and a JDK at `JAVA_HOME` (JDK 17+). Tested on Linux / WSL2.
**1. Install the emulator, a system image and the matching platform** (once):
```bash
SDK=$HOME/android-sdk
$SDK/cmdline-tools/latest/bin/sdkmanager --sdk_root=$SDK \
"emulator" "system-images;android-34;google_apis;x86_64" "platforms;android-34"
```
Use an `x86_64` image so the emulator runs with hardware acceleration (KVM); the app's min SDK is 23.
**2. Hardware acceleration (Linux/WSL2)** — the emulator needs access to `/dev/kvm`. Add yourself
to the `kvm` group once, then start a new shell (or prefix the launch with `sg kvm -c '…'`):
```bash
sudo usermod -aG kvm $USER
```
On WSL2, KVM must be enabled on the Windows host (nested virtualization); the emulator window is
shown on the Windows desktop through WSLg.
**3. Create an AVD** (virtual device):
```bash
echo no | $SDK/cmdline-tools/latest/bin/avdmanager create avd \
-n plm -k "system-images;android-34;google_apis;x86_64" -d pixel
```
**4. Launch the emulator** (software GL is the most robust under WSLg):
```bash
$SDK/emulator/emulator -avd plm -gpu swiftshader_indirect -no-snapshot -no-audio &
$SDK/platform-tools/adb wait-for-device
# wait for full boot:
until [ "$($SDK/platform-tools/adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done
```
If the window won't render, add `-no-window` and rely on `adb` + screenshots
(`adb exec-out screencap -p > shot.png`).
**5. Install and run the apk; capture logs to debug crashes:**
```bash
ADB=$SDK/platform-tools/adb
$ADB install -r src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
$ADB shell monkey -p io.github.davide3011.palladiumwallet -c android.intent.category.LAUNCHER 1
$ADB logcat -d | grep -iE "monodroid|exception|fatal|avalonia"
```
**VS Code "Android iOS Emulator" extension** (optional, click-to-launch): it only starts an
existing AVD, so create one first (step 3). Point it at the emulator binary:
```jsonc
// VS Code settings.json
"emulator.emulatorPathLinux": "/home/<user>/android-sdk/emulator"
// on WSL, use: "emulator.emulatorPathWSL": "/home/<user>/android-sdk/emulator"
```
---
## User guide (quick)
### First launch
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
3. If you create a wallet, **write the seed phrase down on paper**: it will not be shown again. You can protect the file with a password.
> **Desktop vs Android.** The UI and features are the same on both. Differences: on Android the
> data-location step is skipped (fixed app sandbox) and *File → Open wallet from file* (importing a
> wallet from an arbitrary file) is hidden — open wallets from the in-app chooser instead. The
> version is shown in the desktop window title and, on every platform, in the Help dialog. The CLI
> is desktop/headless only.
### Main tabs
- **History** — list of transactions. *Double-click* (double-tap on touch) a row to open the full detail (amount, fee, addresses, sizes, confirmations).
- **Send** — recipient + amount (or "send all"), adjustable fee; for watch-only wallets a PSBT is produced to be signed offline.
- **Receive** — next unused address, with a **QR code** and a **Copy** button.
- **Addresses** — all derived addresses with balances; click for details (keys, derivation path).
- **Contacts** — address book with labels.
### Connection
- The status indicator at the bottom shows the connection to the **indexing server**; tapping it opens the server settings.
- Sync is SPV: it downloads only what concerns your wallet and verifies every confirmed transaction with a Merkle proof.
### Settings and Help
- **Settings**: language, display unit (PLM / mPLM / µPLM / sat), server.
- **Help**: software information and version.
### CLI in brief
```bash
# Wallet
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
dotnet run --project src/Cli -- info [--net ...] [--password P]
# Network
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
```
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
---
## License
Released under the MIT License. See the [LICENSE](LICENSE) file.
-670
View File
@@ -1,670 +0,0 @@
# Blueprint — Wallet desktop per criptovaluta UTXO/SPV
> Specifica **autonoma e indipendente dal linguaggio** per costruire da zero un wallet
> **solo desktop** (Windows e Linux), orientato al power-user come Sparrow Wallet.
>
> Il documento è pensato per essere letto e implementato **passo per passo**: contiene
> tutto il necessario — algoritmi, strutture dati, parametri, protocollo di rete e
> sequenza di costruzione — senza presupporre un framework, un linguaggio o un codice
> sorgente preesistente. Ogni funzionalità elencata va considerata **parte del prodotto
> completo**; quelle marcate *(opzionale)* possono essere rimandate a release successive
> ma sono comunque documentate.
---
## 0. Glossario rapido
| Termine | Significato |
|---|---|
| **SPV** | Simplified Payment Verification: il wallet non scarica la catena, verifica le transazioni tramite prove di Merkle sugli header dei blocchi. |
| **UTXO** | Unspent Transaction Output: una "moneta" non spesa; il saldo è la somma degli UTXO controllati dal wallet. |
| **HD** | Hierarchical Deterministic: tutte le chiavi derivano da un seed unico (BIP32). |
| **PSBT** | Partially Signed Bitcoin Transaction: formato standard per transazioni firmate parzialmente (offline, multisig, hardware). |
| **Watch-only** | Wallet che conosce solo le chiavi pubbliche: vede saldo e storico ma non può firmare. |
| **Server di indicizzazione** | Server che indicizza la catena e risponde alle query del client (protocollo §10). |
| **Scripthash** | SHA-256 dello scriptPubKey con byte invertiti: chiave con cui il server indicizza gli indirizzi. |
---
## 1. Visione del prodotto
Un wallet **desktop nativo** con queste qualità target (modello Sparrow):
- **Solo desktop**: nessuna UI mobile. UX densa, orientata a trasparenza e controllo.
- **Single-sig e multisig** di prima classe, con supporto hardware wallet.
- **Controllo totale su monete e fee**: coin control (UTXO), selezione manuale, etichette,
controllo fee, RBF/CPFP.
- **PSBT-centrico**: ogni flusso di firma passa per PSBT, abilitando firma offline,
air-gapped e collaborativa.
- **Leggero (SPV)**: avvio immediato, nessun full node richiesto (ma con possibilità di
collegare un server proprio).
- **Privacy-aware**: coin selection che preserva la privacy, supporto proxy/Tor,
possibilità di server privato.
- **Eseguibile distribuibile**: binario per Windows e Linux, firma del codice, build
riproducibili.
L'applicazione è strutturata in due grandi blocchi: un **core** (logica pura, senza UI) e
una **GUI desktop** sopra di esso. Tutta la logica di seguito appartiene al core, tranne
dove indicato.
---
## 2. Architettura a livelli (target)
```
┌─────────────────────────────────────────────────────────────────────┐
│ GUI desktop — viste, wizard, coin control, dialog di firma │
├─────────────────────────────────────────────────────────────────────┤
│ Application API — casi d'uso: crea/apri wallet, invia, ricevi, │
│ firma, storico, gestione canali, ecc. │
│ Esposta anche come CLI + RPC locale (§13). │
├─────────────────────────────────────────────────────────────────────┤
│ Dominio wallet — wallet, keystore, indirizzi, UTXO, contatti, │
│ fatture, richieste, costruzione/firma tx, │
│ coin selection, fee policy │
├─────────────────────────────────────────────────────────────────────┤
│ SPV / Sincronizz. — synchronizer, verifier (Merkle), gestione │
│ header/checkpoint, saldo e storico │
├─────────────────────────────────────────────────────────────────────┤
│ Rete — pool di connessioni, selezione server, TLS │
│ con pinning, proxy, protocollo di query (§10) │
├─────────────────────────────────────────────────────────────────────┤
│ Crittografia — secp256k1, hash, BIP32/39/SLIP39, base58, │
│ bech32, cifratura file wallet, ECIES │
├─────────────────────────────────────────────────────────────────────┤
│ Persistenza — file wallet JSON cifrato, config, percorsi │
│ Lightning (⏳ poi) — sottosistema separato, fase successiva (§11) │
└─────────────────────────────────────────────────────────────────────┘
```
**Regola di dipendenza:** ogni livello dipende solo verso il basso. La GUI parla solo con
l'Application API, mai direttamente con rete o crittografia. Questo permette di avere
CLI, test automatici e GUI sullo stesso core.
### Requisiti di componenti (astratti, non legati al linguaggio)
Indipendentemente dallo stack scelto, servono librerie/moduli che forniscano:
1. **Curva ellittica secp256k1** (firma/verifica ECDSA + Schnorr, tweak chiavi). *Non
reimplementare a mano la matematica della curva: usare una libreria auditata.*
2. **Funzioni hash**: SHA-256, doppio SHA-256, RIPEMD-160, HASH160 (SHA256→RIPEMD160),
SHA-512, HMAC-SHA512, PBKDF2-HMAC-SHA512.
3. **Encoding**: Base58Check, Bech32 e Bech32m.
4. **Cifratura simmetrica** (AES-256) e **ECIES** per messaggi.
5. **TLS** con accesso al certificato per il pinning.
6. **JSON** per file wallet e protocollo.
7. **Generatore di numeri casuali crittografico** per seed e nonce.
---
## 3. Profilo di rete / catena (parametri da definire PRIMA di tutto)
Un wallet è legato a una catena specifica tramite un insieme di costanti. **Vanno fissate
all'inizio**: un valore sbagliato produce indirizzi non validi o fa rifiutare la catena.
Definire un oggetto/struct di configurazione con i seguenti campi.
> I valori del profilo di riferimento qui sotto sono stati **verificati contro il sorgente
> del nodo** (`chainparams.cpp` / `pow.cpp`): sono i parametri di consenso autoritativi.
| Campo | Descrizione | Esempio (profilo di riferimento mainnet) |
|---|---|---|
| `net_name` | nome rete / sottocartella dati | `mainnet` |
| `coin_unit` | simbolo unità | `PLM` |
| `wif_prefix` | prefisso chiavi private WIF | `0x80` |
| `addr_p2pkh` | byte versione indirizzi legacy | `55` → indirizzi che iniziano con `P` |
| `addr_p2sh` | byte versione indirizzi P2SH | `5` → iniziano con `3` |
| `segwit_hrp` | prefisso human-readable bech32 | `plm` → indirizzi `plm1...` |
| `bolt11_hrp` | prefisso fatture Lightning | `plm` |
| `genesis_hash` | hash del blocco genesi (mainnet riusa la genesi di Bitcoin) | `000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f` |
| `default_ports` | porte del **server di indicizzazione** usato dal wallet | `{tcp: 50001, ssl: 50002}` |
| `bip44_coin_type` | coin type SLIP-0044 nel derivation path (*convenzione wallet, non parametro di consenso del nodo*) | `746` |
| `uri_scheme` | schema URI pagamenti (BIP21) | `palladium:` |
| `explorer_url` | block explorer di default | `https://explorer.palladium-coin.com/` |
| `skip_pow_validation` | salta la verifica difficoltà — **obbligatorio**: la catena usa LWMA (vedi nota sotto) | `true` |
> **Nota sulle porte.** Le `default_ports` sopra sono quelle del **server di indicizzazione**
> (es. ElectrumX-like) a cui si connette il wallet, **non** la porta P2P del nodo (che nel
> sorgente è `2333` mainnet / `12333` testnet / `28444` regtest). Sono due cose distinte: il
> wallet SPV parla solo col server di indicizzazione.
>
> **Nota sulla difficoltà (LWMA).** Il nodo calcola la difficoltà con **LWMA** (Linearly
> Weighted Moving Average) e un **tempo di blocco di 2 minuti** (`nPowTargetSpacingV2 = 120s`;
> il vecchio `nPowTargetTimespan` di 14 giorni è mantenuto solo per la validazione storica).
> Un client SPV non è in grado di ricalcolare LWMA, quindi `skip_pow_validation` **deve**
> essere `true` e la fiducia sulla catena va ancorata ai **checkpoint** (§7.3).
**Header chiavi estese BIP32** (mainnet di riferimento) — servono per serializzare/parsare
xprv/xpub per ciascun tipo di indirizzo:
| Tipo indirizzo | prefisso priv | header priv | prefisso pub | header pub |
|---|---|---|---|---|
| standard (P2PKH) | xprv | `0x0488ade4` | xpub | `0x0488b21e` |
| segwit wrapped (P2WPKH-P2SH) | yprv | `0x049d7878` | ypub | `0x049d7cb2` |
| multisig wrapped (P2WSH-P2SH) | Yprv | `0x0295b005` | Ypub | `0x0295b43f` |
| native segwit (P2WPKH) | zprv | `0x04b2430c` | zpub | `0x04b24746` |
| native segwit multisig (P2WSH) | Zprv | `0x02aa7a99` | Zpub | `0x02aa7ed3` |
**Testnet** (profilo di riferimento): `wif_prefix=0xff`, `addr_p2pkh=127`, `addr_p2sh=115`,
`segwit_hrp=tplm`, `bip44_coin_type=1`, header tprv/tpub `0x04358394`/`0x043587cf`, ecc.
**Regtest**: `segwit_hrp=rplm`.
**Server iniziali** (bootstrap, profilo di riferimento): forniti come lista
`host:porta_tcp:porta_ssl`; il pool li usa per il primo contatto e poi scopre altri peer
via protocollo (`server.peers.subscribe`).
> Tutte le costanti devono essere **centralizzate** in un solo punto e selezionabili per
> rete (mainnet/testnet/regtest), così da non disperdere magic number nel codice.
---
## 4. Crittografia, seed e gestione chiavi
### 4.1 Seed e mnemoniche — tutti gli schemi supportati
Il wallet deve supportare **più schemi di seed** e saperli riconoscere automaticamente:
1. **Seed nativo versionato**: mnemonica con un prefisso di versione codificato via HMAC
(distingue i sottotipi `standard`, `segwit`, `2fa`, `2fa-segwit`). In creazione si
garantisce che la mnemonica generata **non** sia contemporaneamente un valido BIP39.
2. **BIP39**: import/restore di seed standard a **12 o 24 parole**, con verifica del
checksum.
3. **SLIP39 (Shamir Secret Sharing)** *(opzionale)*: seed suddiviso in più *share*; servono
K share su N per ricostruirlo.
4. **Formato seed legacy** *(opzionale, retrocompatibilità)*: vecchio schema pre-BIP32.
**Wordlist multilingua**: inglese, spagnolo, giapponese, portoghese, cinese. Le parole
vanno normalizzate (NFKD) prima della derivazione.
**Passphrase / extension word (opzionale ma obbligatoria da implementare):** parola/e
aggiuntive combinate col seed tramite **PBKDF2-HMAC-SHA512, 2048 round**, con salt
`"<schema>" + passphrase` (per il seed nativo il prefisso del salt è una costante dello
schema; per BIP39 è `"mnemonic" + passphrase`). Cambia completamente il wallet derivato.
Avvisi UI obbligatori: se persa, i fondi sono **irrecuperabili**; va annotata separatamente
dal seed; è case-sensitive e gli spazi contano.
### 4.2 Derivazione gerarchica (BIP32/BIP44/49/84)
- Da seed → **root key** (BIP32). Da root → chiavi estese per account.
- Path standard con il `coin_type` del profilo:
- Legacy P2PKH: `m/44'/<coin>'/account'/change/index`
- Segwit wrapped P2SH-P2WPKH: `m/49'/<coin>'/account'/change/index`
- Native segwit P2WPKH: `m/84'/<coin>'/account'/change/index`
- Multisig: `m/48'/<coin>'/account'/script_type'/...`
- Supportare **derivation path personalizzati** (Sparrow-like): l'utente può specificare il
path manualmente in import.
- Catena `change=0` (receiving) e `change=1` (change). Derivazione per indice on-demand.
### 4.3 Tipi di indirizzo (tutti)
| Tipo | script | prefisso (profilo rif.) | uso |
|---|---|---|---|
| Native SegWit | P2WPKH | `plm1...` | **default consigliato**: fee minime |
| Legacy | P2PKH | `P...` | massima compatibilità |
| SegWit wrapped | P2SH-P2WPKH | `3...` | compatibilità intermedia |
| Multisig native | P2WSH | `plm1...` | M-di-N moderno |
| Multisig wrapped | P2SH / P2SH-P2WSH | `3...` | M-di-N legacy |
### 4.4 Tipi di keystore (sorgenti di chiavi)
- **HD da seed** (caso principale): seed → root → xprv/xpub.
- **HD da master key importata**: import di xprv/xpub (o y/z varianti). Solo xpub = watch-only.
- **Chiavi private importate**: lista di chiavi WIF singole.
- **Hardware wallet**: la chiave privata non lascia mai il dispositivo (vedi §4.6).
- **Keystore "split/2FA"** *(opzionale)*: parte della firma delegata a un servizio remoto.
- **Keystore legacy** *(opzionale)*.
### 4.5 Tipi di wallet
- **Standard** (single-sig HD) — caso d'uso principale.
- **Multisig M-di-N** — combinazione di più keystore (seed/xpub/hardware misti).
- **Importato** — indirizzi o chiavi private importate; può essere watch-only o spendibile.
- **Watch-only** — solo chiavi pubbliche; costruisce ma non firma (esporta PSBT).
Una factory legge il tipo dal file wallet e istanzia la classe corretta.
### 4.6 Hardware wallet (tutti i modelli supportati)
Integrazione con dispositivi hardware via il loro protocollo USB/HID/seriale. Modelli da
supportare: **Trezor, Ledger, KeepKey, Coldcard, BitBox02, Digital BitBox, Safe-T, Jade**.
Funzioni: import dell'xpub dal dispositivo, conferma indirizzo sullo schermo del device,
firma PSBT sul device (la chiave privata non viene mai esposta), gestione PIN/passphrase.
Per i dispositivi air-gapped (es. Coldcard): scambio PSBT via file/QR/microSD.
### 4.7 Backup e sicurezza delle chiavi
- **Cifratura del file wallet** con password (vedi §8); cambio password.
- **Cifratura/decifratura messaggi** e firma/verifica messaggi con una chiave.
- **Export**: seed, master private key, master public key, chiavi private (per indirizzo o
per path), in modo protetto (richiesta password).
- **Backup su carta con visual one-time-pad** *(opzionale, "revealer")*: genera un foglio
cifrato che, sovrapposto a una griglia segreta stampata, rivela il seed.
- **Recupero a timelock** *(opzionale)*: predisposizione di transazioni di recupero che
diventano spendibili dopo un timelock, per ereditarietà/dead-man-switch.
---
## 5. Ricezione fondi
- **Generazione indirizzi**: prossimo indirizzo non usato; lista indirizzi receiving e
change; rispetto del **gap limit** (numero massimo di indirizzi vuoti consecutivi
scansionati; configurabile, con comando per aumentarlo).
- **Richieste di pagamento**: creare una richiesta con importo, scadenza, descrizione;
elencarle, eliminarle, segnarne lo stato (in attesa/pagata/scaduta).
- **URI BIP21**: generare e parsare `«scheme»:«indirizzo»?amount=...&label=...&message=...`.
- **QR code**: generazione per indirizzi/URI/richieste; scansione da webcam o immagine.
- **Risoluzione nomi**: OpenAlias/DNS, LNURL, e richieste firmate BIP70 *(opzionale)*.
- **Etichette** sugli indirizzi e sulle richieste.
---
## 6. Invio fondi e costruzione transazioni
### 6.1 Composizione
- **Pay-to** singolo e **pay-to-many** (più output in una sola transazione).
- Input destinatario: indirizzo, URI, nome OpenAlias/LNURL, o richiesta scansionata.
- Importo in unità coin o in fiat (con conversione al tasso corrente).
- Opzione **"invia tutto"** (max), con sottrazione fee dall'importo.
- **Locktime** e **sequence** impostabili (per RBF/timelock).
- Etichetta della transazione.
### 6.2 Coin control (cuore di un wallet desktop)
- Vista UTXO completa con importi, indirizzo, conferme, etichetta.
- **Selezione manuale** degli UTXO da spendere (coin control).
- **Freeze/unfreeze** di indirizzi e di singoli UTXO (esclusi dalla spesa automatica).
- Visualizzazione del *change* previsto e dell'indirizzo di change.
### 6.3 Strategie di selezione monete (coin selection)
Implementare almeno due strategie selezionabili:
- **Privacy-preserving**: raggruppa gli UTXO per indirizzo (evita di unire monete di
indirizzi diversi), riduce la perdita di privacy futura e il bloat di UTXO.
- **Random**: selezione casuale con arrotondamento del change per offuscare gli importi.
Obiettivi comuni: minimizzare fee, evitare output *dust*, gestire il change correttamente.
### 6.4 Politiche di fee (tutte)
Modalità di calcolo fee, selezionabili:
- **Fissa** (importo assoluto).
- **Fee rate fisso** (sat/vByte).
- **Dinamica ETA-based**: stima dal server per un target di conferma (es. 1, 2, 5, 10, 25,
144, 1008 blocchi).
- **Dinamica mempool-based**: in base allo stato della mempool.
Mostrare sempre fee totale, fee rate effettivo e dimensione virtuale stimata. Avviso se la
fee è anomala (troppo alta/bassa).
### 6.5 Firma e modello PSBT
Ogni transazione non banale passa per **PSBT**:
- **Crea** PSBT (non firmata) dal wallet.
- **Firma** con: keystore software, hardware wallet, o firma offline su altra macchina.
- **Combina** PSBT parzialmente firmate (multisig: ogni cosigner firma e si uniscono).
- **Finalizza** ed estrai la transazione grezza.
- **Import/export PSBT** via: file, **QR code** (anche animato per PSBT grandi),
**scambio su rete decentralizzata per cosigning** *(opzionale)*, **trasmissione audio**
*(opzionale, "audio modem")*.
- Flusso **watch-only / air-gapped**: la macchina online crea la PSBT, quella offline firma,
la online trasmette.
### 6.6 Gestione post-invio
- **Broadcast** della transazione (e broadcast di un *pacchetto* di transazioni correlate).
- **RBF (Replace-By-Fee)**: bump della fee di una tx non confermata; **cancellazione**
(invio a sé stessi con fee più alta).
- **CPFP (Child-Pays-For-Parent)**: accelerare una tx in entrata spendendola con fee alta.
- **Rimozione di tx locali** non confermate.
- **Sweep**: spazzare tutti i fondi di una chiave privata esterna verso il wallet.
- **Aggiunta manuale di una tx** (incolla raw/hex) e firma con chiave fornita.
### 6.7 Reportistica
- **Plusvalenze/minusvalenze on-chain** (capital gains) per anno fiscale.
- Esportazione storico (CSV/JSON) con etichette.
- Timestamp di inizio/fine anno per i report.
---
## 7. Blockchain, sincronizzazione e validazione (SPV)
### 7.1 Modello SPV
Il wallet **non scarica la catena completa**. Mantiene solo gli **header dei blocchi** e
verifica le transazioni che lo riguardano con **prove di Merkle** contro tali header.
### 7.2 Validazione header
- Header a lunghezza fissa (campi: versione, prev_hash, merkle_root, timestamp, bits, nonce).
- Verifica del **collegamento** (prev_hash) e dell'**hash atteso** di ogni header.
- **Proof-of-Work**: confronto `hash <= target` e coerenza dei `bits`.
- **Difficoltà**: la catena di riferimento usa **LWMA** (retargeting per-blocco, tempo di
blocco 2 minuti — confermato dal nodo). Un client SPV non ricalcola LWMA, quindi il flag
`skip_pow_validation` del profilo (§3) **disattiva** il controllo bits/target; in tal caso
la fiducia è ancorata ai **checkpoint** (sotto). Implementare entrambe le modalità (PoW
classico stile Bitcoin e modalità "skip" per catene LWMA).
- Header organizzati in **chunk** (es. 2016) salvati su file locale; gestione di **fork**
(più rami concorrenti) con scelta del ramo a maggior lavoro.
### 7.3 Checkpoint
Lista hardcoded di `[hash, target]` a intervalli regolari, usata per:
- accelerare la validazione iniziale (non riverificare dall'origine),
- ancorare la validità della catena quando la verifica PoW è disattivata.
Fornire un meccanismo per aggiornare/spedire i checkpoint con le release.
### 7.4 Sincronizzazione wallet
1. Per ogni indirizzo: calcola lo **scripthash**, sottoscrivi al server.
2. Alla notifica di cambiamento: richiedi lo **storico** (lista txid + altezza).
3. Scarica le transazioni mancanti.
4. **Verifica** ciascuna con prova di Merkle contro l'header all'altezza indicata.
5. Aggiorna saldo (confermato/non confermato), UTXO e storico.
6. Estendi la scansione finché si raggiunge il gap limit di indirizzi vuoti.
---
## 8. Persistenza e configurazione
- **File wallet**: un file (JSON) contenente keystore, indirizzi, storico, etichette,
contatti, richieste, fatture, (e stato canali Lightning se attivo). **Cifrato** con AES
quando è impostata una password (la password protegge il file su disco; **non** sblocca
il seed). Schema **versionato** con migrazioni automatiche all'apertura.
- **Configurazione globale** separata dal wallet: rete selezionata, server, proxy, unità,
lingua, politiche fee di default, block explorer.
- **Percorsi dati** per piattaforma + **modalità portable** (dati accanto all'eseguibile,
utile per chiavette USB).
- **Multi-wallet**: aprire/chiudere più wallet, elencarli, passare dall'uno all'altro.
---
## 9. Rete e connettività
- **Pool di connessioni**: più server contemporaneamente per ridondanza; un server
"primario" per gli header; fan-out delle query.
- **Selezione server**: automatica o manuale; scoperta di nuovi peer dal protocollo.
- **TLS con pinning (TOFU)**: al primo contatto il certificato del server viene salvato; ai
successivi viene confrontato. Se cambia → connessione rifiutata. Fornire un comando
**"reset certificati SSL"** per i server self-signed (caso tipico: il server rinnova il
certificato e il client va sbloccato manualmente).
- **Proxy / Tor**: supporto SOCKS5 per instradare tutto il traffico.
- **Stima fee** dal server; **relay fee** minima.
- **Stato connessione** visibile in UI; riconnessione automatica.
---
## 10. Protocollo client ↔ server (query di indicizzazione)
Il client comunica via **JSON-RPC** (su TCP, opzionalmente TLS). Implementare richieste e
parsing per i seguenti metodi (gli indirizzi sono indicizzati per **scripthash**):
```
# Negoziazione / server
server.version server.banner server.features
server.ping server.peers.subscribe server.donation_address
# Header / catena
blockchain.headers.subscribe blockchain.block.header blockchain.block.headers
blockchain.estimatefee blockchain.relayfee
# Indirizzi (per scripthash)
blockchain.scripthash.subscribe blockchain.scripthash.get_balance
blockchain.scripthash.get_history blockchain.scripthash.listunspent
# Transazioni
blockchain.transaction.get blockchain.transaction.get_merkle
blockchain.transaction.broadcast blockchain.transaction.broadcast_package
blockchain.transaction.id_from_pos
```
---
## 11. Lightning Network — ⏳ DA FARE IN SEGUITO (fase successiva, fuori dal primo rilascio)
> **Non incluso nel primo rilascio.** Coerentemente con il modello Sparrow (on-chain only),
> il wallet parte **senza Lightning**. Questo capitolo resta documentato come specifica per
> una **fase successiva**: va affrontato solo dopo che tutte le funzioni on-chain (§4–§10)
> sono complete e stabili. Va progettato come **sottosistema separato e disattivabile**, in
> modo da non bloccare né complicare il primo rilascio.
Quando verrà affrontato, è un grande sottosistema a sé. Funzionalità da implementare:
- **Canali**: apertura, chiusura cooperativa e forzata, lista canali e peer.
- **Pagamenti**: invio/ricezione su BOLT11, **MPP** (multi-part payments), **trampoline
routing**, onion routing, gossip/routing della rete.
- **Fatture**: creazione/decodifica BOLT11; **hold invoice** (trattenute fino a conferma).
- **Backup canali**: export/import dei backup di stato (critici per non perdere fondi).
- **Watchtower**: locale e remoto, per punire chiusure fraudolente quando offline.
- **Submarine swap**: scambio on-chain ↔ Lightning, con provider/server di swap;
rebalance dei canali.
- **LNURL** e indirizzi Lightning (lightning-address).
- **Nostr Wallet Connect (NWC)** *(opzionale)*: controllo remoto del wallet Lightning.
- **Payserver** *(opzionale)*: endpoint per ricevere pagamenti.
> **Promemoria:** per il primo rilascio Lightning è escluso. Le funzioni base (§4–§10) non
> dipendono in alcun modo da questo capitolo; affrontarlo solo come iterazione successiva.
---
## 12. Contatti, etichette e dati ausiliari
- **Rubrica contatti**: nome ↔ indirizzo, con ricerca.
- **Etichette** su indirizzi, transazioni, UTXO, richieste.
- **Sincronizzazione etichette** *(opzionale)*: cifrate, condivise tra istanze del wallet
via un servizio.
- **Lista fatture** (uscite) e **lista richieste** (entrate) con stato.
---
## 13. Interfacce non grafiche
- **CLI**: ogni caso d'uso del core esposto come comando da riga di comando (utile per
scripting e per i test automatici).
- **RPC locale / daemon** *(opzionale)*: un processo in background che espone l'API su
socket locale autenticato, così la GUI e strumenti esterni parlano con lo stesso core.
Famiglie di comandi da prevedere (elenco rappresentativo della superficie API completa):
creazione/restore/apertura/chiusura wallet, generazione indirizzi, saldo e storico,
pay-to / pay-to-many, firma/broadcast, gestione PSBT (deserialize/combine/finalize),
freeze/unfreeze UTXO, bumpfee/cancel, sweep, import/export chiavi e xkey, conversione xkey,
firma/verifica messaggi, gestione richieste e fatture, contatti, conversione fiat,
stato sincronizzazione, gestione server/config. *(Comandi Lightning — apertura/chiusura
canali, pagamenti, swap, backup canali — solo nella fase successiva, vedi §11.)*
---
## 14. Funzioni di supporto e infrastruttura
- **Tassi di cambio / fiat**: integrazione con più provider di prezzo; conversione importi
in valuta locale; **prezzi storici** per la reportistica; aggiornamento periodico.
- **Internazionalizzazione (i18n)**: UI multilingua, con rilevamento lingua di sistema.
- **Crash reporter**: raccolta e invio (con consenso) dei crash.
- **Sistema di plugin** *(opzionale)*: caricamento di estensioni (hardware wallet, swap
server, watchtower, label sync, ecc.) come moduli separati.
- **Hardening memoria** *(opzionale)*: blocco in RAM (no swap su disco) dei segreti dove il
SO lo consente; azzeramento dei buffer sensibili dopo l'uso.
- **Block explorer**: apertura di tx/indirizzi nell'explorer configurato.
---
## 15. Wizard di creazione/restore (flusso UI)
1. Avvio: **crea nuovo wallet** / **apri esistente** / **importa**.
2. Tipo wallet: **Standard** / **Multisig (M-di-N)** / **Importa indirizzi o chiavi** /
**Hardware**.
3. Per Standard: **nuovo seed** / **ho già un seed** / **usa master key** / **usa device hardware**.
4. Nuovo seed → mostra le parole → **conferma** reinserendole.
5. **Passphrase** opzionale (extension word) con avvisi (§4.1).
6. Scelta **tipo di indirizzo** (default: native segwit).
7. **Password** di cifratura del file wallet.
8. Sincronizzazione e comparsa di saldo/storico.
Per multisig: raccolta di N cosigner (seed/xpub/hardware), scelta soglia M, derivation path
e tipo di script; generazione del descrittore del wallet e verifica incrociata degli xpub
tra i partecipanti.
---
## 16. Sequenza di costruzione consigliata (passo per passo)
Costruire e **testare** in quest'ordine; ad ogni passo verificare con vettori noti.
1. **Profilo di rete (§3)**: centralizzare tutte le costanti; selettore mainnet/testnet/regtest.
2. **Crittografia e chiavi (§4)**: hash, secp256k1, base58/bech32, BIP32/39, generazione
indirizzi. *Test:* dato un seed → produrre gli indirizzi attesi (golden vectors).
3. **Persistenza (§8)**: definire e versionare lo schema del file wallet (JSON) + cifratura.
4. **Rete + protocollo (§910)**: connessione, TLS+pinning, query base.
5. **SPV + sincronizzazione (§7)**: header, checkpoint, Merkle, saldo/storico su un wallet
**watch-only**. *Test:* stesso xpub → stesso saldo di un wallet di riferimento.
6. **Transazioni (§6)**: costruzione, coin selection, fee, PSBT, firma, broadcast su testnet.
7. **GUI desktop (§15 + viste)**: wizard, dashboard saldo/storico, invia (con coin control),
ricevi, UTXO, contatti, impostazioni.
8. **Hardware wallet (§4.6)** e **multisig (§4.5)**: firma collaborativa via PSBT.
9. **Estensioni**: fiat/exchange rate, label sync, reportistica.
10. **⏳ Fase successiva (post-rilascio)**: **Lightning (§11)** come sottosistema separato,
da iniziare solo quando i passi 19 sono completi e stabili.
**Test cross-implementazione (obbligatorio per un wallet):** ad ogni passo confrontare gli
output (indirizzi, txid, PSBT) con un wallet di riferimento usando gli stessi input. Un
indirizzo o un txid diverso è un bug bloccante.
---
## 17. Requisiti di sicurezza (non negoziabili)
- Seed e chiavi private **mai** in chiaro su disco non cifrato, **mai** nei log, **mai**
inviati in rete.
- Cifratura del file wallet con derivazione robusta della chiave dalla password.
- Validare **ogni** dato proveniente dalla rete: le risposte dei server **non sono fidate**;
verificare sempre con prove di Merkle + checkpoint.
- Watch-only realmente read-only: nessuna chiave privata derivabile dalle sole pubbliche.
- TLS con pinning del certificato e reset esplicito controllato dall'utente.
- Azzeramento dei segreti in memoria dopo l'uso; ove possibile blocco anti-swap.
- **Firma del codice** dei binari Windows/Linux e **build riproducibili** per consentire la
verifica indipendente.
---
## 18. Packaging e distribuzione desktop
- **Windows**: eseguibile installabile e versione **portable** (dati accanto all'exe).
Firma Authenticode.
- **Linux**: formato portabile autoconsistente (es. immagine eseguibile singola) e/o
pacchetto nativo; firma GPG dei rilasci.
- **Build riproducibili** (ambiente di build isolato/containerizzato) e pubblicazione degli
hash + firme dei binari.
- File di associazione per lo schema URI dei pagamenti (`uri_scheme` del profilo) così che i
link di pagamento aprano il wallet.
---
## 19. Stack tecnologico raccomandato (.NET 8 + Avalonia + NBitcoin)
> I capitoli §1–§18 sono **indipendenti dal linguaggio** e restano il riferimento. Questa
> sezione propone una **realizzazione concreta** scelta per tre vincoli: sviluppo assistito
> da IA, **semplicità**, e **un solo sorgente** che produca sia `.exe` (Windows) sia
> AppImage (Linux). Lo stack non è obbligatorio: è la via più liscia per *questo* prodotto.
### 19.1 Perché questo stack
- **`NBitcoin` (C#)** modella reti altcoin custom via `NetworkBuilder` (prefissi
P2PKH/P2SH, WIF, header BIP32 xpub/xprv, HRP bech32, genesi): mappatura **diretta** della
§3. Fornisce già HD/BIP32/39, indirizzi (legacy/segwit/wrapped), costruzione e
serializzazione transazioni, **PSBT**, firma, base58/bech32, hashing → è la libreria che
fa scrivere **meno crittografia a mano** per un altcoin.
- **Avalonia UI** è cross-platform nativo: un solo sorgente per Windows e Linux.
- **C#/.NET** ha tooling maturo e un enorme corpus → l'IA produce codice idiomatico con
poche frizioni; alto livello e GC = sviluppo rapido.
- Posizione di **sicurezza accettabile** sulle chiavi senza la curva di apprendimento di
Rust e senza i rischi di Electron (chiavi in JS, footprint, supply-chain npm).
*Alternative scartate:* **Rust + BDK + Tauri** (binari minimi e memory-safe, ma curva
ripida e customizzazione altcoin più laboriosa → contro "semplicità"); **Electron + TS**
(packaging e UI rapidissimi, ma gestione chiavi più fragile → sconsigliato per un wallet);
**Java/JavaFX** (è lo stack di Sparrow e produce già `.exe`+AppImage, ma nessun vantaggio
netto su .NET per questo caso).
### 19.2 Cosa è coperto dalla libreria e cosa va scritto a mano
**Coperto da NBitcoin** (non reimplementare): rete custom, BIP32/39, indirizzi,
transazioni, PSBT, firma, base58/bech32, hashing.
**Da implementare a mano** (NBitcoin non lo include — è il grosso del lavoro originale):
1. **Client del protocollo del server di indicizzazione** (§10): JSON-RPC su TCP/TLS,
pool di connessioni, TLS pinning/TOFU, reset certificati, proxy/Tor.
2. **Sincronizzazione SPV** (§7.4): scripthash, storico, verifica prove di Merkle.
3. **Validazione header + checkpoint con modalità "skip PoW"** per LWMA (§3/§7):
NBitcoin assume il retargeting di Bitcoin, quindi questo strato è **custom**.
4. **Coin selection** privacy-preserving e **fee policy** (fissa/rate/ETA/mempool),
RBF/CPFP (§6): logica di dominio sopra le primitive NBitcoin.
5. **File wallet cifrato** (schema JSON versionato + AES) e **config** (§8).
### 19.3 Mappatura strato del blueprint → componente concreto
| Strato (§2) | Realizzazione |
|---|---|
| Crittografia (§4) | NBitcoin (`Network` custom, `ExtKey`, `Mnemonic`, `BitcoinAddress`, `PSBT`) |
| Profilo rete (§3) | `NetworkBuilder` con i valori §3, centralizzato in `Core/Chain` |
| SPV/Sync (§7) | codice custom in `Core/Spv` |
| Rete/protocollo (§910) | client custom in `Core/Net` |
| Dominio wallet (§4–§6) | `Core/Wallet` sopra NBitcoin |
| Persistenza (§8) | `System.Text.Json` + AES in `Core/Storage` |
| Application API (§13) | progetti `Cli` e API condivisa |
| GUI desktop (§15) | Avalonia in `App` |
### 19.4 Struttura del progetto (una sola solution .NET)
```
PalladiumWallet.sln
├─ src/Core/ (libreria, nessuna dipendenza UI)
│ ├─ Chain/ profilo rete (NetworkBuilder), costanti §3, checkpoint
│ ├─ Crypto/ wrapper NBitcoin: seed, BIP32/39, indirizzi, keystore
│ ├─ Wallet/ wallet, UTXO, coin selection, fee policy, PSBT, firma
│ ├─ Spv/ header store, verifier (Merkle), sync
│ ├─ Net/ client protocollo, pool, TLS pinning, proxy
│ └─ Storage/ file wallet JSON cifrato, config
├─ src/App/ (Avalonia UI: wizard, dashboard, invia/ricevi, coin control)
├─ src/Cli/ (riga di comando sullo stesso Core — utile ai test)
└─ tests/ (xUnit: golden vectors indirizzi/txid, test SPV)
```
Regola di dipendenza (§2): `App` e `Cli` dipendono da `Core`; `Core` non conosce la UI.
### 19.5 Ambiente di sviluppo e build — tutto su Ubuntu, senza Wine
- **Dev**: `.NET 8 SDK` (repo Microsoft / `apt`), VS Code + estensione C# o Rider.
Avalonia gira ed è eseguibile nativamente su Ubuntu.
- **Architetture host**: si sviluppa sia su **x86_64** sia su **arm64** (il .NET SDK è
nativo su `linux-x64` e `linux-arm64`; NBitcoin è C# puro gestito, nessuna dipendenza
nativa legata all'arch).
- **Build cross dei binari da Linux, senza Wine** (.NET cross-targeta nativamente):
- Windows: `dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained``.exe`.
- Linux: `dotnet publish -r linux-x64 --self-contained`.
- **Docker** consigliato: un solo `Dockerfile` su `mcr.microsoft.com/dotnet/sdk:8.0`
produce i target in modo riproducibile.
- Wine **non** serve per compilare. Servirebbe solo per costruire un *installer* Windows
con Inno Setup su Linux (evitabile distribuendo l'`.exe` single-file **portable**) o per
*testare* l'`.exe` su Linux (test, non build). Firma Authenticode fattibile da Linux con
`osslsigncode`.
### 19.6 Packaging "un sorgente → .exe + AppImage" (multi-architettura)
Da una macchina **Ubuntu x86_64** si producono tutti e quattro i target:
| Target | RID | Output | Da x86_64 |
|---|---|---|---|
| Windows x64 | `win-x64` | `.exe` | ✅ diretto (no Wine) |
| Windows arm64 | `win-arm64` | `.exe` | ✅ diretto (no Wine) |
| Linux x64 | `linux-x64` | AppImage | ✅ nativo |
| Linux arm64 | `linux-arm64` | AppImage | ✅ via `docker buildx` + QEMU |
- **Binari**: `dotnet publish -r <rid> -p:PublishSingleFile=true --self-contained` per
ciascun RID. Il `.exe` Windows (x64 e arm64) esce direttamente; niente Wine.
- **AppImage Linux x64**: `dotnet publish -r linux-x64` + **PupNet Deploy** su Ubuntu.
- **AppImage Linux arm64**: il `dotnet publish -r linux-arm64` cross-compila i binari da
x86_64, ma l'assemblaggio dell'AppImage (appimagetool/runtime) è arch-specifico → si fa
con **`docker buildx` multi-arch + emulazione QEMU** (`--platform linux/arm64`) nella
stessa pipeline, così l'AppImage arm64 viene impacchettato in ambiente arm64 emulato.
- Associazione dello schema URI `palladium:`; build riproducibili in Docker; firma codice.
### 19.7 Flusso di test (non serve compilare e lanciare l'app per testare)
Tre livelli, tutti su Ubuntu, il grosso **headless**:
1. **`dotnet test`** — logica del `Core` senza GUI né rete reale: golden vector
seed→indirizzi del profilo (confronto 1:1 col wallet di riferimento), costruzione/firma
tx, PSBT, coin selection, parsing protocollo e verifica Merkle con server mockato.
2. **CLI** (`dotnet run --project src/Cli -- ...`) contro **regtest/testnet**: sync, saldo
su xpub watch-only, costruzione tx — flussi reali senza aprire la UI.
3. **GUI** solo per rifinire l'interfaccia: `dotnet run` compila+lancia in un comando, con
**Hot Reload** Avalonia e previewer XAML (niente ciclo build-lancia manuale).
**Dev-loop (equivalente di `npm run dev`) — nativo anche su Debian arm64:**
- `dotnet watch --project src/App``npm run dev`: ricompila e applica **Hot Reload** a
ogni salvataggio, con la finestra Avalonia aggiornata dal vivo; `dotnet run` per il lancio
singolo; previewer XAML nell'IDE per le viste.
- Su **arm64 si sviluppa e si vede la grafica nativamente** (.NET SDK `linux-arm64`,
Avalonia rende con Skia, fallback software se manca l'accelerazione GPU). **QEMU non serve
per sviluppare/testare** — l'emulazione riguarda solo l'impacchettamento di AppImage di
un'altra architettura (§19.6).
- Prerequisiti runtime su Debian/Ubuntu: ambiente grafico (X11/Wayland) e librerie native di
Avalonia, es. `apt install libx11-6 libice6 libsm6 libfontconfig1 libglib2.0-0 libgl1`
(più mesa). Per logica/crypto non serve GUI: `dotnet test` e la CLI girano headless.
---
*Questo blueprint è una specifica completa e indipendente dal linguaggio. I parametri del
profilo di rete (§3) e la logica di validazione header/checkpoint (§7) sono gli elementi
critici e specifici della catena; tutto il resto è l'ingegneria standard di un wallet SPV
desktop. Implementando i capitoli nell'ordine del §16 si ottiene un wallet desktop completo
in stile Sparrow.*
+16
View File
@@ -0,0 +1,16 @@
using Android.App;
using Android.Content.PM;
using Avalonia.Android;
namespace PalladiumWallet.Mobile;
// Activity di avvio. La configurazione dell'app (tipo App, font) è nella
// MainApplication (AvaloniaAndroidApplication<App>). Qui basta il launcher.
[Activity(
Label = "Palladium Wallet",
Theme = "@style/MyTheme.NoActionBar",
MainLauncher = true,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity
{
}
+32
View File
@@ -0,0 +1,32 @@
using System;
using Android.App;
using Android.Runtime;
using Avalonia;
using Avalonia.Android;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Mobile;
// In Avalonia 12 l'AppBuilder Android si configura nella sottoclasse Application
// (AvaloniaAndroidApplication<TApp>), non più nell'Activity. allowBackup=false:
// il file wallet cifrato/seed non deve finire nei backup cloud automatici.
[Application(Label = "Palladium Wallet", AllowBackup = false)]
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
{
public MainApplication(IntPtr javaReference, JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
public override void OnCreate()
{
// Storage sandbox dell'app: wallet, configurazione e certificati vivono
// qui. Impostato prima dell'init Avalonia (che crea il ViewModel e decide
// se mostrare lo step "scegli cartella dati" del wizard).
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
base.OnCreate();
}
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
base.CustomizeAppBuilder(builder).WithInterFont();
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Head Android: entry-point e pacchetti specifici. La UI vera vive nella
libreria condivisa PalladiumWallet.App (stessa di desktop). -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-android</TargetFramework>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
a mano — sideload sul telefono o `adb install` — crasha all'avvio con
"monodroid: No assemblies found ... Exiting". Così l'apk è autosufficiente. -->
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<!-- Default: ABI multiple (l'apk gira sia su emulatore x86_64 sia su telefoni arm64).
Per un apk più piccolo solo-telefono: aggiungi -p:AbiArm64Only=true alla build. -->
<RuntimeIdentifiers Condition="'$(AbiArm64Only)' == 'true'">android-arm64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Necessario per parlare col server di indicizzazione (TCP/TLS).
L'elemento <application> (label, allowBackup) è generato dall'attributo
[Application] su MainApplication. usesCleartextTraffic resta al default
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- DEVE derivare da Theme.AppCompat: AvaloniaActivity estende AppCompatActivity,
che con un tema non-AppCompat lancia IllegalStateException all'avvio.
Avalonia disegna comunque l'intera UI (NoActionBar). -->
<style name="MyTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar" />
</resources>
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Head Desktop (Windows/Linux): entry-point e pacchetti specifici del
desktop. La UI vera vive nella libreria condivisa PalladiumWallet.App. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\App\Assets\logo.ico</ApplicationIcon>
<AssemblyName>PalladiumWallet</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
</ItemGroup>
</Project>
+11 -8
View File
@@ -1,8 +1,5 @@
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using PalladiumWallet.App.ViewModels; using PalladiumWallet.App.ViewModels;
using PalladiumWallet.App.Views; using PalladiumWallet.App.Views;
@@ -18,12 +15,18 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) var vm = new MainWindowViewModel();
// Desktop (Windows/Linux): finestra classica. Mobile (Android): vista
// singola. Stessa UI condivisa (MainView) e stesso ViewModel.
switch (ApplicationLifetime)
{ {
desktop.MainWindow = new MainWindow case IClassicDesktopStyleApplicationLifetime desktop:
{ desktop.MainWindow = new MainWindow { DataContext = vm };
DataContext = new MainWindowViewModel(), break;
}; case ISingleViewApplicationLifetime singleView:
singleView.MainView = new MainView { DataContext = vm };
break;
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+281 -117
View File
@@ -1,166 +1,330 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
namespace PalladiumWallet.App.Localization; namespace PalladiumWallet.App.Localization;
/// <summary> /// <summary>
/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con /// Localizzazione UI: dizionario chiave → traduzioni per lingua, con
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua /// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
/// notifica "Item[]" e tutte le binding si aggiornano. /// il ViewModel sostituisce l'istanza così Avalonia rivaluta tutte le binding.
/// </summary> /// </summary>
public sealed class Loc : INotifyPropertyChanged public sealed class Loc
{ {
public static Loc Instance { get; } = new(); public static Loc Instance { get; private set; } = new();
public static readonly string[] Languages = ["it", "en"]; public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
public static readonly string[] LanguageNames = ["Italiano", "English"]; public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
public string Language { get; private set; } = "it"; public string Language { get; private set; } = "en";
public event PropertyChangedEventHandler? PropertyChanged; private Loc() { }
private Loc(string language) { Language = language; }
public void SetLanguage(string language) /// <summary>
/// Crea una nuova istanza con la lingua specificata e aggiorna il singleton
/// usato da <see cref="Tr"/>. Il ViewModel assegna questa istanza alla
/// propria property Loc così Avalonia vede un riferimento diverso e
/// rivaluta tutte le binding {Binding Loc[chiave]}.
/// </summary>
internal static Loc SwitchTo(string language)
{ {
if (Language == language || System.Array.IndexOf(Languages, language) < 0) if (System.Array.IndexOf(Languages, language) < 0) language = "en";
return; var loc = new Loc(language);
Language = language; Instance = loc;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); return loc;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
} }
public string this[string key] => public string this[string key] =>
Strings.TryGetValue(key, out var values) Strings.TryGetValue(key, out var values)
? values[Language == "en" ? 1 : 0] ? values[System.Math.Max(0, System.Array.IndexOf(Languages, Language))]
: key; : key;
public static string Tr(string key) => Instance[key]; public static string Tr(string key) => Instance[key];
private static readonly Dictionary<string, string[]> Strings = new() private static readonly Dictionary<string, string[]> Strings = new()
{ {
// Menu // Menu it en es fr pt de
["menu.file"] = ["_File", "_File"], ["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"], ["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"], ["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…", "Abrir wallet desde archivo…", "Ouvrir le wallet depuis un fichier…", "Abrir carteira de arquivo…", "Wallet aus Datei öffnen…"],
["menu.file.close"] = ["Chiudi wallet", "Close wallet"], ["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
["menu.net"] = ["_Rete", "_Network"], ["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"], ["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"], ["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
["menu.settings"] = ["_Impostazioni", "_Settings"], ["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
["settings.unit.short"] = ["Unità", "Unit"], ["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
["help.title"] = ["Informazioni sul software", "About this software", "Información del software", "À propos du logiciel", "Sobre o software", "Über die Software"],
["help.info"] = [
"Wallet SPV per la criptovaluta Palladium (PLM).",
"SPV wallet for the Palladium (PLM) cryptocurrency.",
"Monedero SPV para la criptomoneda Palladium (PLM).",
"Portefeuille SPV pour la cryptomonnaie Palladium (PLM).",
"Carteira SPV para a criptomoeda Palladium (PLM).",
"SPV-Wallet für die Kryptowährung Palladium (PLM)."],
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
// Wizard // Wizard
["wiz.net"] = ["Rete:", "Network:"], ["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"], ["wiz.data.info"] = [
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"], "Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"], "Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
["wiz.open.title"] = ["Apri il wallet", "Open the wallet"], "Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"], "Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
["wiz.open.ok"] = ["Apri", "Open"], "Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"], "Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
["wiz.seed.warning"] = [ ["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
["wiz.net"] = ["Rete:", "Network:", "Red:", "Réseau :", "Rede:", "Netzwerk:"],
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"],
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)"],
["wiz.seed.warning"] = [
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.", "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."], "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"], "Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"], "Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"], "Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"], "Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren."],
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"], ["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter"],
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"], ["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"],
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"], ["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben"],
["wiz.password.title"] = ["Password del file wallet", "Wallet file password"], ["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"], ["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
["wiz.password.create"] = ["Crea il wallet", "Create wallet"], ["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
["wiz.back"] = ["Indietro", "Back"], ["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
["wiz.next"] = ["Avanti", "Next"], ["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"],
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"],
["wiz.password.encrypt.hint"] = [
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
"Warning: without encryption the seed stays in plaintext on disk.",
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
"Atenção: sem criptografia a semente fica em texto simples no disco.",
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
// Pannello wallet // Pannello wallet
["wallet.close"] = ["Chiudi wallet", "Close wallet"], ["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
["wallet.server"] = ["Server:", "Server:"], ["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"], ["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"], ["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
["wallet.discover"] = ["Cerca altri server", "Discover servers"], ["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
["wallet.resetcert"] = ["Reset cert.", "Reset certs"], ["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
["tab.receive"] = ["Ricevi", "Receive"], ["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
["tab.history"] = ["Storico", "History"], ["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
["tab.addresses"] = ["Indirizzi", "Addresses"], ["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
["tab.send"] = ["Invia", "Send"], ["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"], ["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"],
["receive.hint"] = [ ["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"],
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"],
["receive.hint"] = [
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.", "Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
"Payments received here will appear in the history at the next synchronization."], "Payments received here will appear in the history at the next synchronization.",
["addr.type"] = ["Tipo", "Type"], "Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
["addr.index"] = ["Indice", "Index"], "Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
["addr.address"] = ["Indirizzo", "Address"], "Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
["addr.balance"] = ["Saldo", "Balance"], "Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."],
["addr.receive"] = ["ricezione", "receive"], ["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"],
["addr.change"] = ["change", "change"], ["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
["send.to"] = ["Indirizzo destinatario", "Recipient address"], ["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
["send.amount"] = ["Importo", "Amount"], ["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
["send.all"] = ["Invia tutto", "Send all"], ["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"],
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"], ["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
["send.prepare"] = ["Prepara transazione", "Prepare transaction"], ["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"], ["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
// Storico → dettaglio transazione
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"],
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"],
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"],
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"],
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"],
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"],
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"],
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"],
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"],
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
["tx.verified"] = ["Verifica SPV", "SPV verification", "Verificación SPV", "Vérification SPV", "Verificação SPV", "SPV-Prüfung"],
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
// Stato connessione // Stato connessione
["conn.none"] = ["non connesso", "not connected"], ["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
["conn.disconnected"] = ["disconnesso", "disconnected"], ["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
["conn.reconnecting"] = ["riconnessione…", "reconnecting…"], ["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
["conn.error"] = ["errore di connessione", "connection error"], ["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"],
["conn.certchanged"] = ["certificato cambiato", "certificate changed"], ["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
["conn.connectedto"] = ["connesso a", "connected to"], ["conn.connectedto"] = ["connesso a", "connected to", "conectado a", "connecté à", "conectado a", "verbunden mit"],
["conn.connectingto"] = ["connessione a", "connecting to"], ["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
// Messaggi di stato principali // Messaggi di stato principali
["msg.welcome.existing"] = [ ["msg.welcome.existing"] = [
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.", "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."], "Found an existing wallet on this network: open it, or create another one.",
["msg.welcome.new"] = [ "Se encontró un wallet existente en esta red: ábrelo o crea otro.",
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues."],
["msg.welcome.new"] = [
"Benvenuto: crea un nuovo wallet o ripristina da seed.", "Benvenuto: crea un nuovo wallet o ripristina da seed.",
"Welcome: create a new wallet or restore from seed."], "Welcome: create a new wallet or restore from seed.",
["msg.open.password"] = [ "Bienvenido: crea un nuevo wallet o restaura desde semilla.",
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."],
["msg.open.password"] = [
"Inserisci la password del file (lascia vuoto se non impostata).", "Inserisci la password del file (lascia vuoto se non impostata).",
"Enter the file password (leave empty if not set)."], "Enter the file password (leave empty if not set).",
["msg.seed.write"] = [ "Ingresa la contraseña del archivo (deja vacío si no establecida).",
"Entrez le mot de passe du fichier (laisser vide si non défini).",
"Digite a senha do arquivo (deixe vazio se não definida).",
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."],
["msg.seed.write"] = [
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.", "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."], "Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
["msg.seed.retype"] = [ "Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets."],
["msg.seed.retype"] = [
"Reinserisci le 12 parole per confermare di averle scritte.", "Reinserisci le 12 parole per confermare di averle scritte.",
"Re-enter the 12 words to confirm you wrote them down."], "Re-enter the 12 words to confirm you wrote them down.",
["msg.seed.mismatch"] = [ "Reingresa las 12 palabras para confirmar que las has anotado.",
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
"Reinsira as 12 palavras para confirmar que as anotou.",
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."],
["msg.seed.mismatch"] = [
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.", "Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
"The words do not match: check what you wrote on paper."], "The words do not match: check what you wrote on paper.",
["msg.words.enter"] = [ "Las palabras no coinciden: revisa lo que escribiste en papel.",
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
"As palavras não correspondem: verifique o que escreveu no papel.",
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."],
["msg.words.enter"] = [
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).", "Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces)."], "Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
["msg.words.invalid"] = [ "Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."],
["msg.words.invalid"] = [
"Mnemonica non valida (parole o checksum errati): ricontrolla.", "Mnemonica non valida (parole o checksum errati): ricontrolla.",
"Invalid mnemonic (wrong words or checksum): check again."], "Invalid mnemonic (wrong words or checksum): check again.",
["msg.passphrase.info"] = [ "Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."],
["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.", "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."], "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"] = [ "Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen."],
["msg.password.info"] = [
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.", "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."], "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."], "Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server"], "Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
["msg.synced"] = ["Sincronizzato", "Synchronized"], "Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
["msg.synced.detail"] = [ "Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
["msg.password.required"] = [
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
["msg.password.mismatch"] = [
"Le due password non coincidono.",
"The two passwords do not match.",
"Las dos contraseñas no coinciden.",
"Les deux mots de passe ne correspondent pas.",
"As duas senhas não coincidem.",
"Die beiden Passwörter stimmen nicht überein."],
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
["msg.synced.detail"] = [
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.", "transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
"SPV-verified transactions. Real-time updates active."], "SPV-verified transactions. Real-time updates active.",
["msg.height"] = ["altezza", "height"], "transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
["msg.pending"] = ["in attesa di conferma", "pending confirmation"], "transactions vérifiées SPV. Mises à jour en temps réel actives.",
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"], "transações verificadas SPV. Atualizações em tempo real ativas.",
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."], "SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
["msg.certreset"] = [ ["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
["msg.certreset"] = [
"Certificati SSL azzerati: riprova la connessione.", "Certificati SSL azzerati: riprova la connessione.",
"SSL certificates cleared: retry the connection."], "SSL certificates cleared: retry the connection.",
["msg.error"] = ["Errore", "Error"], "Certificados SSL restablecidos: reintenta la conexión.",
"Certificats SSL réinitialisés : réessayez la connexion.",
"Certificados SSL redefinidos: tente novamente a conexão.",
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
// Contatti
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
// Finestra impostazioni // Finestra impostazioni
["settings.title"] = ["Impostazioni", "Settings"], ["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
["settings.language"] = ["Lingua", "Language"], ["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
["settings.unit"] = ["Unità degli importi", "Amount unit"], ["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
["settings.ok"] = ["Salva", "Save"], ["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
["settings.cancel"] = ["Annulla", "Cancel"], ["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
// Finestra server
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
}; };
} }
+9 -9
View File
@@ -1,9 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<!-- Libreria UI condivisa: il codice Avalonia (App, Views, ViewModels, Loc,
Assets) usato sia dall'head Desktop sia dall'head Android. Gli head
portano l'entry-point e i pacchetti specifici di piattaforma. -->
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework> <!-- Versione dell'applicazione: unico punto da modificare. Compare nel
titolo della finestra ed è incisa nei binari pubblicati. -->
<Version>0.9.0</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
@@ -14,14 +18,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" /> <PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="QRCoder" Version="1.8.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+550 -33
View File
@@ -3,7 +3,9 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -15,14 +17,34 @@ using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet; using PalladiumWallet.Core.Wallet;
using QRCoder;
namespace PalladiumWallet.App.ViewModels; namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga dello storico transazioni per la vista.</summary> /// <summary>Riga dello storico transazioni per la vista.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata); public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary> /// <summary>Riga della vista indirizzi con chiavi e derivation path pre-calcolati.</summary>
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx); public sealed record AddressRow(
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
{
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
}
/// <summary>Dati completi di un indirizzo passati alla finestra di dettaglio.</summary>
public sealed record AddressInfo(
Localization.Loc Loc,
string Address, string DerivPath, string PubKey, string PrivKey)
{
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
}
/// <summary>Contatto in rubrica: nome + indirizzo blockchain.</summary>
public sealed record ContactEntry(string Name, string Address);
/// <summary>Voce della lista di scelta wallet: nome file + percorso completo.</summary>
public sealed record WalletFileEntry(string Name, string Path);
/// <summary> /// <summary>
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard): /// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
@@ -46,8 +68,24 @@ public partial class MainWindowViewModel : ViewModelBase
/// <summary>Configurazione globale (§8): lingua e unità.</summary> /// <summary>Configurazione globale (§8): lingua e unità.</summary>
private AppConfig _config = AppConfig.Load(); private AppConfig _config = AppConfig.Load();
/// <summary>Stringhe localizzate, bindabili da XAML come Loc[chiave].</summary> /// <summary>Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua
public Loc Loc => Loc.Instance; /// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}.</summary>
private Loc _loc = Loc.Instance;
public Loc Loc => _loc;
/// <summary>Versione dell'app (da &lt;Version&gt; nel csproj), formato Major.Minor.Patch.</summary>
public static string AppVersion =>
typeof(MainWindowViewModel).Assembly.GetName().Version is { } v
? $"{v.Major}.{v.Minor}.{v.Build}"
: "";
/// <summary>Titolo della finestra con la versione, es. "Palladium Wallet 0.9.0".</summary>
public string WindowTitle => $"Palladium Wallet {AppVersion}";
/// <summary>true su desktop (Windows/Linux); false su mobile. Nasconde le funzioni
/// legate al filesystem libero (apri-da-file, scelta cartella dati) non valide nel
/// sandbox Android.</summary>
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
/// <summary>Unità corrente per il campo importo del pannello Invia.</summary> /// <summary>Unità corrente per il campo importo del pannello Invia.</summary>
public string UnitLabel => _config.Unit; public string UnitLabel => _config.Unit;
@@ -57,6 +95,10 @@ public partial class MainWindowViewModel : ViewModelBase
// Spunte del menu Impostazioni (ToggleType Radio). // Spunte del menu Impostazioni (ToggleType Radio).
public bool IsLangIt => _config.Language == "it"; public bool IsLangIt => _config.Language == "it";
public bool IsLangEn => _config.Language == "en"; public bool IsLangEn => _config.Language == "en";
public bool IsLangEs => _config.Language == "es";
public bool IsLangFr => _config.Language == "fr";
public bool IsLangPt => _config.Language == "pt";
public bool IsLangDe => _config.Language == "de";
public bool IsUnitPlm => _config.Unit == "PLM"; public bool IsUnitPlm => _config.Unit == "PLM";
public bool IsUnitMilli => _config.Unit == "mPLM"; public bool IsUnitMilli => _config.Unit == "mPLM";
public bool IsUnitMicro => _config.Unit == "µPLM"; public bool IsUnitMicro => _config.Unit == "µPLM";
@@ -81,10 +123,15 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
_config = config; _config = config;
_config.Save(); _config.Save();
Loc.SetLanguage(config.Language); _loc = Loc.SwitchTo(config.Language);
OnPropertyChanged(nameof(Loc));
OnPropertyChanged(nameof(UnitLabel)); OnPropertyChanged(nameof(UnitLabel));
OnPropertyChanged(nameof(IsLangIt)); OnPropertyChanged(nameof(IsLangIt));
OnPropertyChanged(nameof(IsLangEn)); OnPropertyChanged(nameof(IsLangEn));
OnPropertyChanged(nameof(IsLangEs));
OnPropertyChanged(nameof(IsLangFr));
OnPropertyChanged(nameof(IsLangPt));
OnPropertyChanged(nameof(IsLangDe));
OnPropertyChanged(nameof(IsUnitPlm)); OnPropertyChanged(nameof(IsUnitPlm));
OnPropertyChanged(nameof(IsUnitMilli)); OnPropertyChanged(nameof(IsUnitMilli));
OnPropertyChanged(nameof(IsUnitMicro)); OnPropertyChanged(nameof(IsUnitMicro));
@@ -97,6 +144,18 @@ public partial class MainWindowViewModel : ViewModelBase
private string Fmt(long sats, bool withLabel = true) => private string Fmt(long sats, bool withLabel = true) =>
CoinAmount.FormatIn(sats, _config.Unit, withLabel); CoinAmount.FormatIn(sats, _config.Unit, withLabel);
/// <summary>Chiave privata WIF per un indirizzo; stringa vuota se watch-only.</summary>
private string KeyWif(bool isChange, int index)
{
if (_account is null or { IsWatchOnly: true }) return "";
try
{
return _account.GetExtPrivateKey(isChange, index)
.PrivateKey.GetWif(PalladiumNetworks.For(Net)).ToString();
}
catch { return ""; }
}
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary> /// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
private string? _pendingOpenPath; private string? _pendingOpenPath;
@@ -126,7 +185,9 @@ public partial class MainWindowViewModel : ViewModelBase
// ---- wizard di setup (§15): un passo alla volta ---- // ---- wizard di setup (§15): un passo alla volta ----
public const string StepDataLocation = "data-location";
public const string StepStart = "start"; public const string StepStart = "start";
public const string StepChooseWallet = "choose-wallet";
public const string StepOpen = "open"; public const string StepOpen = "open";
public const string StepShowSeed = "show-seed"; public const string StepShowSeed = "show-seed";
public const string StepConfirmSeed = "confirm-seed"; public const string StepConfirmSeed = "confirm-seed";
@@ -135,7 +196,9 @@ public partial class MainWindowViewModel : ViewModelBase
public const string StepPassword = "password"; public const string StepPassword = "password";
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
[NotifyPropertyChangedFor(nameof(IsStepStart))] [NotifyPropertyChangedFor(nameof(IsStepStart))]
[NotifyPropertyChangedFor(nameof(IsStepChooseWallet))]
[NotifyPropertyChangedFor(nameof(IsStepOpen))] [NotifyPropertyChangedFor(nameof(IsStepOpen))]
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))] [NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))] [NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
@@ -144,7 +207,9 @@ public partial class MainWindowViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsStepPassword))] [NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart; private string setupStep = StepStart;
public bool IsStepDataLocation => SetupStep == StepDataLocation;
public bool IsStepStart => SetupStep == StepStart; public bool IsStepStart => SetupStep == StepStart;
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
public bool IsStepOpen => SetupStep == StepOpen; public bool IsStepOpen => SetupStep == StepOpen;
public bool IsStepShowSeed => SetupStep == StepShowSeed; public bool IsStepShowSeed => SetupStep == StepShowSeed;
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed; public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
@@ -152,6 +217,9 @@ public partial class MainWindowViewModel : ViewModelBase
public bool IsStepPassphrase => SetupStep == StepPassphrase; public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepPassword => SetupStep == StepPassword; public bool IsStepPassword => SetupStep == StepPassword;
/// <summary>Percorso dati predefinito, mostrato al primo avvio.</summary>
public string DefaultDataPath => AppPaths.DefaultDataRoot();
/// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary> /// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary>
private bool _isRestoreFlow; private bool _isRestoreFlow;
@@ -167,6 +235,14 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string passwordInput = ""; private string passwordInput = "";
/// <summary>Conferma password alla creazione (stile Electrum: digitata due volte).</summary>
[ObservableProperty]
private string confirmPasswordInput = "";
/// <summary>Se true il file wallet viene cifrato con la password (default, stile Electrum).</summary>
[ObservableProperty]
private bool encryptWallet = true;
// ---- pannello wallet ---- // ---- pannello wallet ----
[ObservableProperty] [ObservableProperty]
@@ -181,11 +257,81 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string receiveAddress = ""; private string receiveAddress = "";
/// <summary>QR code dell'indirizzo di ricezione corrente (PNG in-memory).</summary>
[ObservableProperty] [ObservableProperty]
private string serverInput = ""; private Bitmap? receiveQr;
// Rigenera il QR ogni volta che l'indirizzo di ricezione cambia.
partial void OnReceiveAddressChanged(string value)
{
var previous = ReceiveQr;
ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value);
previous?.Dispose();
}
/// <summary>Feedback dopo la copia dell'indirizzo negli appunti (chiamato dal code-behind).</summary>
public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied");
private static Bitmap? GenerateQr(string text)
{
try
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
var png = new PngByteQRCode(data).GetGraphic(8);
return new Bitmap(new MemoryStream(png));
}
catch
{
return null;
}
}
[ObservableProperty] [ObservableProperty]
private bool useSsl; private string serverHost = "";
[ObservableProperty]
private string serverPort = "";
/// <summary>Si predilige TLS: la connessione automatica all'apertura del
/// wallet usa la porta SSL del server. L'utente può disattivarlo.</summary>
[ObservableProperty]
private bool useSsl = true;
/// <summary>Overlay impostazioni server: aperto dall'overlay Impostazioni.</summary>
[ObservableProperty]
private bool isServerSettingsOpen;
[RelayCommand]
private void OpenServerSettings()
{
IsSettingsOpen = false;
IsServerSettingsOpen = true;
}
[RelayCommand]
private void CloseServerSettings() => IsServerSettingsOpen = false;
/// <summary>Overlay impostazioni (lingua, unità, server): in-app per evitare
/// i popup di menu annidati, lenti su WSLg.</summary>
[ObservableProperty]
private bool isSettingsOpen;
[RelayCommand]
private void OpenSettings() => IsSettingsOpen = true;
[RelayCommand]
private void CloseSettings() => IsSettingsOpen = false;
/// <summary>Overlay informazioni sul software (menu Help).</summary>
[ObservableProperty]
private bool isHelpOpen;
[RelayCommand]
private void OpenHelp() => IsHelpOpen = true;
[RelayCommand]
private void CloseHelp() => IsHelpOpen = false;
[ObservableProperty] [ObservableProperty]
private string connectionStatus = Loc.Tr("conn.none"); private string connectionStatus = Loc.Tr("conn.none");
@@ -205,6 +351,203 @@ public partial class MainWindowViewModel : ViewModelBase
public ObservableCollection<AddressRow> Addresses { get; } = []; public ObservableCollection<AddressRow> Addresses { get; } = [];
/// <summary>Wallet disponibili nella cartella della rete, per la schermata di scelta.</summary>
public ObservableCollection<WalletFileEntry> WalletList { get; } = [];
// ---- tab indirizzi ----
[ObservableProperty]
private AddressRow? selectedAddressRow;
/// <summary>Dettaglio indirizzo mostrato nell'overlay in-app; null = nascosto.</summary>
[ObservableProperty]
private AddressInfo? addressInfo;
/// <summary>Apre l'overlay con i dati dell'indirizzo passato.</summary>
public void ShowAddressInfo(AddressRow row) =>
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
[RelayCommand]
private void CloseAddressInfo() => AddressInfo = null;
// ---- overlay dettaglio transazione ----
/// <summary>Overlay dettaglio transazione aperto.</summary>
[ObservableProperty]
private bool isTxDetailsOpen;
/// <summary>Caricamento dei dati in corso (mostra lo spinner nell'overlay).</summary>
[ObservableProperty]
private bool isTxDetailsLoading;
/// <summary>Dati della transazione mostrata; null finché non sono pronti.</summary>
[ObservableProperty]
private TransactionDetailsViewModel? txDetails;
private CancellationTokenSource? _txDetailsCts;
/// <summary>
/// Apre l'overlay dettaglio transazione: appare subito con lo spinner e i
/// dati arrivano dal server in background. Overlay in-app (come indirizzo e
/// impostazioni) per apertura/chiusura istantanee, senza una top-level window.
/// </summary>
public async Task ShowTransactionDetailsAsync(string txid)
{
if (_client is null || !_client.IsConnected)
{
StatusMessage = Loc.Tr("tx.needconnection");
return;
}
_txDetailsCts?.Cancel();
_txDetailsCts = new CancellationTokenSource();
var ct = _txDetailsCts.Token;
TxDetails = null;
IsTxDetailsLoading = true;
IsTxDetailsOpen = true;
var details = await BuildTransactionDetailsAsync(txid, ct);
// L'utente potrebbe aver chiuso l'overlay (o aperto un'altra tx) mentre
// caricava: non sovrascrivere lo stato in quel caso.
if (ct.IsCancellationRequested || !IsTxDetailsOpen)
return;
if (details is null)
{
IsTxDetailsOpen = false;
IsTxDetailsLoading = false;
return;
}
TxDetails = details;
IsTxDetailsLoading = false;
}
[RelayCommand]
private void CloseTransactionDetails()
{
_txDetailsCts?.Cancel();
IsTxDetailsOpen = false;
IsTxDetailsLoading = false;
TxDetails = null;
}
/// <summary>
/// Recupera dal server tutti i dati della transazione <paramref name="txid"/>
/// (importi, fee, indirizzi, dimensioni, data) e li impacchetta per la
/// finestra di dettaglio. Restituisce null se non c'è connessione o in errore.
/// </summary>
public async Task<TransactionDetailsViewModel?> BuildTransactionDetailsAsync(
string txid, CancellationToken ct = default)
{
if (_client is null || !_client.IsConnected)
{
StatusMessage = Loc.Tr("tx.needconnection");
return null;
}
if (_doc?.Cache is not { } cache)
return null;
// Snapshot dei dati sull'UI thread, poi tutto il lavoro (round-trip al
// server + parsing delle transazioni) va su un thread pool: altrimenti i
// continuation dei fetch riprendono sull'UI thread e bloccano la finestra
// (spinner fermo, chiusura ritardata).
var client = _client;
var network = PalladiumNetworks.For(Net);
var row = cache.History.FirstOrDefault(t => t.Txid == txid);
var owned = cache.Addresses.Select(a => a.Address).ToHashSet();
var tipHeight = cache.TipHeight;
var height = row?.Height ?? 0;
var delta = row?.DeltaSats ?? 0;
var verified = row?.Verified ?? false;
var transactions = _lastTransactions;
var loc = _loc;
var unit = _config.Unit;
try
{
return await Task.Run(async () =>
{
var details = await TransactionInspector.FetchAsync(
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
return new TransactionDetailsViewModel(details, loc, unit);
}, ct);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
return null;
}
}
// ---- rubrica contatti ----
public ObservableCollection<ContactEntry> Contacts { get; } = [];
[ObservableProperty]
private ContactEntry? selectedContactInList;
/// <summary>Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo.</summary>
[ObservableProperty]
private ContactEntry? sendToContact;
partial void OnSendToContactChanged(ContactEntry? value)
{
if (value is not null)
SendTo = value.Address;
}
[ObservableProperty]
private string newContactName = "";
[ObservableProperty]
private string newContactAddress = "";
[RelayCommand]
private void AddContact()
{
var name = NewContactName.Trim();
var addr = NewContactAddress.Trim();
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return;
Contacts.Add(new ContactEntry(name, addr));
NewContactName = NewContactAddress = "";
PersistContacts();
}
[RelayCommand]
private void RemoveSelectedContact()
{
if (SelectedContactInList is { } c)
{
Contacts.Remove(c);
SelectedContactInList = null;
PersistContacts();
}
}
private void PersistContacts()
{
if (_doc is null || _walletPath is null) return;
_doc.Contacts = Contacts
.Select(c => new PalladiumWallet.Core.Storage.StoredContact { Name = c.Name, Address = c.Address })
.ToList();
WalletStore.Save(_doc, _walletPath, _password);
}
private void LoadContacts()
{
Contacts.Clear();
if (_doc is null) return;
foreach (var c in _doc.Contacts)
Contacts.Add(new ContactEntry(c.Name, c.Address));
}
// ---- pannello invia ---- // ---- pannello invia ----
[ObservableProperty] [ObservableProperty]
@@ -227,7 +570,13 @@ public partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel() public MainWindowViewModel()
{ {
RefreshSetupState(); _loc = Loc.SwitchTo(_config.Language);
// Primo avvio: se la posizione dei dati non è ancora determinata,
// chiediamola prima di tutto il resto del wizard.
if (!AppPaths.IsDataLocationConfigured())
SetupStep = StepDataLocation;
else
RefreshSetupState();
// Aggiornamenti continui (§9): ping periodico per tenere viva la // Aggiornamenti continui (§9): ping periodico per tenere viva la
// connessione e accorgersi subito della caduta; se cade, riconnette // connessione e accorgersi subito della caduta; se cade, riconnette
// e risincronizza da solo. Le notifiche push restano la via principale. // e risincronizza da solo. Le notifiche push restano la via principale.
@@ -238,7 +587,9 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task KeepAliveTickAsync() private async Task KeepAliveTickAsync()
{ {
if (!IsWalletOpen || IsSyncing) // Vale anche senza wallet aperto: se l'utente si è connesso dalle
// impostazioni server, la connessione va mantenuta e riconnessa.
if (IsSyncing)
return; return;
if (_client is { IsConnected: true }) if (_client is { IsConnected: true })
{ {
@@ -265,15 +616,46 @@ public partial class MainWindowViewModel : ViewModelBase
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
/// <summary>Primo avvio: usa il percorso dati predefinito di piattaforma.</summary>
[RelayCommand]
private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot());
/// <summary>
/// Memorizza la radice dati scelta (default o personalizzata), ricarica la
/// configurazione dalla nuova posizione e prosegue col wizard. Chiamata anche
/// dalla View dopo la scelta di una cartella.
/// </summary>
public void ApplyDataLocation(string root)
{
try
{
AppPaths.ConfigureDataLocation(root);
}
catch (Exception ex)
{
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
return;
}
// La config globale vive nella radice dati: ricaricala da lì.
_config = AppConfig.Load();
_loc = Loc.SwitchTo(_config.Language);
OnPropertyChanged(nameof(Loc));
OnPropertyChanged(nameof(UnitLabel));
RefreshSetupState();
}
private void RefreshSetupState() private void RefreshSetupState()
{ {
SetupStep = StepStart; SetupStep = StepStart;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ""; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net)); WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
StatusMessage = WalletFileExists StatusMessage = WalletFileExists
? Loc.Tr("msg.welcome.existing") ? Loc.Tr("msg.welcome.existing")
: Loc.Tr("msg.welcome.new"); : Loc.Tr("msg.welcome.new");
RefreshServers(); RefreshServers();
// Come Electrum: ci si connette al server già durante il setup, senza
// aspettare l'apertura di un wallet (la sync parte poi all'apertura).
_ = ConnectAndSync();
} }
private void RefreshServers() private void RefreshServers()
@@ -283,19 +665,59 @@ public partial class MainWindowViewModel : ViewModelBase
KnownServers.Add(server); KnownServers.Add(server);
SelectedKnownServer = KnownServers.FirstOrDefault(); SelectedKnownServer = KnownServers.FirstOrDefault();
if (SelectedKnownServer is null) if (SelectedKnownServer is null)
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}"; {
ServerHost = "127.0.0.1";
ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
}
} }
/// <summary>Evita la ricorsione tra OnUseSslChanged e OnServerPortChanged
/// mentre tengono coerenti porta e flag TLS.</summary>
private bool _syncingServerFields;
partial void OnSelectedKnownServerChanged(KnownServer? value) partial void OnSelectedKnownServerChanged(KnownServer? value)
{ {
if (value is not null) if (value is null)
ServerInput = $"{value.Host}:{value.PortFor(UseSsl)}"; return;
_syncingServerFields = true;
ServerHost = value.Host;
ServerPort = value.PortFor(UseSsl).ToString();
_syncingServerFields = false;
} }
partial void OnUseSslChanged(bool value) partial void OnUseSslChanged(bool value)
{ {
if (SelectedKnownServer is { } server) if (_syncingServerFields)
ServerInput = $"{server.Host}:{server.PortFor(value)}"; return;
// Attivare/disattivare TLS allinea la porta: porta SSL del server
// selezionato (o default di profilo) quando TLS è on, porta TCP quando off.
_syncingServerFields = true;
ServerPort = SelectedKnownServer is { } server
? server.PortFor(value).ToString()
: (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
_syncingServerFields = false;
}
partial void OnServerPortChanged(string value)
{
if (_syncingServerFields)
return;
if (!int.TryParse(value.Trim(), out var port))
return;
// Scegliere una porta nota allinea il flag TLS, così non si tenta mai
// una connessione in chiaro sulla porta SSL (causa di "connection error").
bool? wantSsl =
SelectedKnownServer is { } s && port == s.SslPort ? true :
SelectedKnownServer is { } t && port == t.TcpPort ? false :
port == Profile.DefaultSslPort ? true :
port == Profile.DefaultTcpPort ? false :
null;
if (wantSsl is bool b && b != UseSsl)
{
_syncingServerFields = true;
UseSsl = b;
_syncingServerFields = false;
}
} }
// ---------- comandi del wizard (§15): un passo alla volta ---------- // ---------- comandi del wizard (§15): un passo alla volta ----------
@@ -303,6 +725,31 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void WizardStartOpen() private void WizardStartOpen()
{ {
// Con più wallet nella cartella della rete si sceglie quale aprire;
// con uno solo si va dritti alla password (multi-wallet §8).
var files = AppPaths.WalletFiles(Net);
if (files.Count > 1)
{
WalletList.Clear();
foreach (var path in files)
WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path));
SetupStep = StepChooseWallet;
StatusMessage = Loc.Tr("msg.choose.wallet");
return;
}
_pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net);
PasswordInput = "";
SetupStep = StepOpen;
StatusMessage = Loc.Tr("msg.open.password");
}
/// <summary>Sceglie quale wallet aprire dalla lista e passa alla password.</summary>
[RelayCommand]
private void ChooseWallet(WalletFileEntry? entry)
{
if (entry is null)
return;
_pendingOpenPath = entry.Path;
PasswordInput = ""; PasswordInput = "";
SetupStep = StepOpen; SetupStep = StepOpen;
StatusMessage = Loc.Tr("msg.open.password"); StatusMessage = Loc.Tr("msg.open.password");
@@ -368,7 +815,8 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void WizardNextFromPassphrase() private void WizardNextFromPassphrase()
{ {
PasswordInput = ""; PasswordInput = ConfirmPasswordInput = "";
EncryptWallet = true;
SetupStep = StepPassword; SetupStep = StepPassword;
StatusMessage = Loc.Tr("msg.password.info"); StatusMessage = Loc.Tr("msg.password.info");
} }
@@ -378,7 +826,8 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
SetupStep = SetupStep switch SetupStep = SetupStep switch
{ {
StepOpen or StepShowSeed or StepWords => StepStart, StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
StepChooseWallet or StepShowSeed or StepWords => StepStart,
StepConfirmSeed => StepShowSeed, StepConfirmSeed => StepShowSeed,
StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed, StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed,
StepPassword => StepPassphrase, StepPassword => StepPassphrase,
@@ -391,6 +840,29 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void CreateOrRestore() private void CreateOrRestore()
{ {
// Scelta cifratura stile Electrum: con cifratura attiva serve una
// password non vuota, digitata due volte e coincidente. Solo se la
// cifratura è disattivata il file resta in chiaro (avviso esplicito).
string? password;
if (EncryptWallet)
{
if (string.IsNullOrEmpty(PasswordInput))
{
StatusMessage = Loc.Tr("msg.password.required");
return;
}
if (PasswordInput != ConfirmPasswordInput)
{
StatusMessage = Loc.Tr("msg.password.mismatch");
return;
}
password = PasswordInput;
}
else
{
password = null;
}
try try
{ {
var (doc, account) = WalletLoader.NewFromMnemonic( var (doc, account) = WalletLoader.NewFromMnemonic(
@@ -402,7 +874,6 @@ public partial class MainWindowViewModel : ViewModelBase
var path = AppPaths.DefaultWalletPath(Net); var path = AppPaths.DefaultWalletPath(Net);
for (var n = 2; WalletStore.Exists(path); n++) for (var n = 2; WalletStore.Exists(path); n++)
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json"); path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
WalletStore.Save(doc, path, password); WalletStore.Save(doc, path, password);
OpenLoaded(doc, account, path, password); OpenLoaded(doc, account, path, password);
} }
@@ -478,11 +949,12 @@ public partial class MainWindowViewModel : ViewModelBase
_account = account; _account = account;
_walletPath = path; _walletPath = path;
_password = password; _password = password;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ""; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
SetupStep = StepStart; 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" : "");
LoadContacts();
ApplyCache(doc.Cache); ApplyCache(doc.Cache);
IsWalletOpen = true; IsWalletOpen = true;
StatusMessage = Loc.Tr("msg.opened"); StatusMessage = Loc.Tr("msg.opened");
@@ -504,8 +976,12 @@ public partial class MainWindowViewModel : ViewModelBase
// Prima della sincronizzazione si mostrano i primi indirizzi derivati. // Prima della sincronizzazione si mostrano i primi indirizzi derivati.
Addresses.Clear(); Addresses.Clear();
for (var i = 0; i < 10; i++) for (var i = 0; i < 10; i++)
Addresses.Add(new AddressRow("ricezione", i, Addresses.Add(new AddressRow(_loc["addr.receive"], i,
_account.GetReceiveAddress(i).ToString(), "—", "—")); _account.GetReceiveAddress(i).ToString(), "—", "—",
false,
_account.GetPublicKey(false, i).ToHex(),
KeyWif(false, i),
$"m/{_doc!.AccountPath}/0/{i}"));
return; return;
} }
BalanceText = Fmt(cache.ConfirmedSats); BalanceText = Fmt(cache.ConfirmedSats);
@@ -528,11 +1004,15 @@ public partial class MainWindowViewModel : ViewModelBase
Addresses.Clear(); Addresses.Clear();
foreach (var a in cache.Addresses) foreach (var a in cache.Addresses)
Addresses.Add(new AddressRow( Addresses.Add(new AddressRow(
a.IsChange ? "change" : "ricezione", a.IsChange ? _loc["addr.change"] : _loc["addr.receive"],
a.Index, a.Index,
a.Address, a.Address,
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (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() : "—",
a.IsChange,
_account.GetPublicKey(a.IsChange, a.Index).ToHex(),
KeyWif(a.IsChange, a.Index),
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
} }
// ---------- comandi wallet ---------- // ---------- comandi wallet ----------
@@ -540,8 +1020,6 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private async Task ConnectAndSync() private async Task ConnectAndSync()
{ {
if (_account is null || _doc is null)
return;
if (IsSyncing) if (IsSyncing)
{ {
_resyncRequested = true; _resyncRequested = true;
@@ -551,9 +1029,19 @@ public partial class MainWindowViewModel : ViewModelBase
StatusMessage = ""; StatusMessage = "";
try try
{ {
var (host, port) = ParseServer();
// Se l'endpoint richiesto (host/porta/TLS) è diverso da quello della
// connessione attiva, la chiudo: l'utente ha cambiato server o ha
// attivato TLS e si aspetta che la nuova scelta abbia effetto.
if (_client is { } current &&
(current.Host != host || current.Port != port || current.UseSsl != UseSsl))
{
await DisconnectAsync();
}
if (_client is null || !_client.IsConnected) if (_client is null || !_client.IsConnected)
{ {
var (host, port) = ParseServer();
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {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);
@@ -566,8 +1054,18 @@ public partial class MainWindowViewModel : ViewModelBase
IsConnected = true; IsConnected = true;
_autoReconnect = true; _autoReconnect = true;
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {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.
// Senza un wallet aperto ci si limita a stabilire/verificare la
// connessione: la sincronizzazione richiede un account.
if (_account is null || _doc is null)
return;
// Synchronizer legato all'account corrente, creato alla prima sync
// dopo la connessione: conserva la cache di tx e prove verificate,
// così le risincronizzazioni sono incrementali.
if (_synchronizer is null)
{
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
} }
@@ -577,7 +1075,7 @@ public partial class MainWindowViewModel : ViewModelBase
do do
{ {
_resyncRequested = false; _resyncRequested = false;
var result = await _synchronizer!.SyncOnceAsync(); var result = await _synchronizer.SyncOnceAsync();
_lastTransactions = result.Transactions; _lastTransactions = result.Transactions;
_doc.Cache = new SyncCache _doc.Cache = new SyncCache
@@ -731,6 +1229,22 @@ public partial class MainWindowViewModel : ViewModelBase
} }
} }
/// <summary>
/// Chiude la connessione corrente lasciando il wallet aperto: usata quando
/// l'utente cambia server o attiva TLS e serve riconnettersi al nuovo
/// endpoint. Attende la chiusura del socket prima di tornare.
/// </summary>
private async Task DisconnectAsync()
{
if (_client is { } client)
{
_client = null;
_synchronizer = null;
try { await client.DisposeAsync(); } catch { /* in chiusura */ }
}
IsConnected = false;
}
[RelayCommand] [RelayCommand]
private void CloseWallet() private void CloseWallet()
{ {
@@ -745,6 +1259,10 @@ public partial class MainWindowViewModel : ViewModelBase
_pendingSend = null; _pendingSend = null;
HasPendingSend = false; HasPendingSend = false;
History.Clear(); History.Clear();
Contacts.Clear();
SelectedContactInList = null;
SendToContact = null;
SelectedAddressRow = null;
IsWalletOpen = false; IsWalletOpen = false;
IsConnected = false; IsConnected = false;
ConnectionStatus = Loc.Tr("conn.none"); ConnectionStatus = Loc.Tr("conn.none");
@@ -753,9 +1271,8 @@ public partial class MainWindowViewModel : ViewModelBase
private (string Host, int Port) ParseServer() private (string Host, int Port) ParseServer()
{ {
var parts = ServerInput.Trim().Split(':'); var host = ServerHost.Trim();
var host = parts[0]; var port = int.TryParse(ServerPort.Trim(), out var p)
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
? p ? p
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort; : UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
return (host, port); return (host, port);
+22
View File
@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace PalladiumWallet.App.ViewModels;
/// <summary>
/// bool → pennello: gli indirizzi del wallet (input/output "nostri") sono
/// evidenziati in verde, gli altri usano il colore di testo predefinito.
/// </summary>
public sealed class MineColorConverter : IValueConverter
{
public static readonly MineColorConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is true ? Brushes.MediumSeaGreen : AvaloniaProperty.UnsetValue;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using PalladiumWallet.App.Localization;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga input/output per le tabelle della finestra di dettaglio.</summary>
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
/// <summary>
/// ViewModel della finestra di dettaglio transazione: prende un
/// <see cref="TransactionDetails"/> (assemblato dal server) e ne ricava tutte
/// le stringhe già formattate e localizzate per la vista. È di sola lettura.
/// </summary>
public sealed class TransactionDetailsViewModel
{
private readonly string _unit;
public Loc Loc { get; }
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
{
Loc = loc;
_unit = unit;
Txid = d.Txid;
StatusText = BuildStatus(d, loc);
DateText = d.BlockTime is { } t
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: loc["tx.mempool"];
var counterparties = d.CounterpartyAddresses;
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
CounterpartyText = counterparties.Count > 0
? string.Join(Environment.NewLine, counterparties)
: "—";
// Debito (uscita verso terzi) o Credito (entrata netta sui nostri output).
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
AmountText = d.IsIncoming
? Signed(d.ReceivedSats)
: Signed(-d.SentToOthersSats);
FeeText = d.FeeSats is { } fee
? (d.IsIncoming ? Abs(fee) : Signed(-fee))
: "—";
NetText = Signed(d.NetSats);
TotalSizeText = $"{d.TotalSize} byte";
VirtualSizeText = $"{d.VirtualSize} byte";
FeeRateText = d.FeeRateSatPerVb is { } r
? r.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " sat/vB"
: "—";
VersionText = d.Version.ToString();
LockTimeText = d.LockTime.ToString();
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
VerifiedText = d.Verified ? "✓ SPV" : "—";
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
i.IsCoinbase ? "coinbase" : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
i.Address ?? "—",
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
i.IsMine)));
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
$"#{o.Index}",
o.Address ?? $"({o.ScriptType})",
CoinAmount.FormatIn(o.AmountSats, unit),
o.IsMine)));
}
public string Txid { get; }
public string StatusText { get; }
public string DateText { get; }
public string CounterpartyHeader { get; }
public string CounterpartyText { get; }
public string AmountHeader { get; }
public string AmountText { get; }
public string FeeText { get; }
public string NetText { get; }
public string TotalSizeText { get; }
public string VirtualSizeText { get; }
public string FeeRateText { get; }
public string VersionText { get; }
public string LockTimeText { get; }
public string RbfText { get; }
public string VerifiedText { get; }
public ObservableCollection<TxIoRow> Inputs { get; }
public ObservableCollection<TxIoRow> Outputs { get; }
private static string BuildStatus(TransactionDetails d, Loc loc)
{
if (d.Confirmations <= 0)
return loc["tx.status.mempool"];
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
}
private string Signed(long sats)
{
var sign = sats > 0 ? "+" : sats < 0 ? "-" : "";
return sign + CoinAmount.FormatIn(Math.Abs(sats), _unit);
}
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
private static string Shorten(string txid) =>
txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid;
}
+746
View File
@@ -0,0 +1,746 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PalladiumWallet.App.ViewModels"
xmlns:net="using:PalladiumWallet.Core.Net"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
x:Class="PalladiumWallet.App.Views.MainView"
x:DataType="vm:MainWindowViewModel">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto">
<!-- ============ MENU (stile Electrum) ============ -->
<Menu Grid.Row="0">
<MenuItem Header="{Binding Loc[menu.file]}">
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"
IsVisible="{Binding IsDesktop}"/>
<Separator/>
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
IsEnabled="{Binding IsWalletOpen}"/>
</MenuItem>
<MenuItem Header="{Binding Loc[menu.settings]}"
Command="{Binding OpenSettingsCommand}"/>
<MenuItem Header="{Binding Loc[menu.help]}"
Command="{Binding OpenHelpCommand}"/>
</Menu>
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
HorizontalAlignment="Center">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/>
<!-- Passo 0 (primo avvio): dove salvare i dati -->
<StackPanel IsVisible="{Binding IsStepDataLocation}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.data.title]}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding Loc[wiz.data.info]}" TextWrapping="Wrap" Foreground="Gray"/>
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[wiz.data.default]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding DefaultDataPath}"
FontFamily="monospace" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<Button Content="{Binding Loc[wiz.data.usedefault]}" FontSize="16" Classes="accent"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding UseDefaultDataLocationCommand}"/>
<Button Content="{Binding Loc[wiz.data.choose]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Click="OnChooseDataFolderClick"/>
</StackPanel>
<!-- Passo 1: scelta iniziale -->
<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}"
SelectedItem="{Binding SelectedNetwork}" 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>
<!-- Passo: scelta del wallet (più file presenti) -->
<StackPanel IsVisible="{Binding IsStepChooseWallet}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.choose.title]}" FontSize="18" FontWeight="Bold"/>
<ItemsControl ItemsSource="{Binding WalletList}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WalletFileEntry">
<Button Content="{Binding Name}" FontFamily="monospace"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="0,0,0,6"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ChooseWalletCommand}"
CommandParameter="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
</StackPanel>
<!-- Passo: password del wallet esistente -->
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
<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">
<SelectableTextBlock Text="{Binding MnemonicInput}"
FontFamily="monospace" FontSize="16" TextWrapping="Wrap"/>
</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>
<!-- Passo: conferma del seed -->
<StackPanel IsVisible="{Binding IsStepConfirmSeed}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.confirm.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.confirm.placeholder]}"
AcceptsReturn="False" Text="{Binding ConfirmMnemonicInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromConfirmSeedCommand}"/>
</StackPanel>
</StackPanel>
<!-- 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 (cifratura, stile Electrum) -->
<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}"/>
<TextBox PlaceholderText="{Binding Loc[wiz.password.confirm]}"
PasswordChar="●" Text="{Binding ConfirmPasswordInput}"/>
<CheckBox Content="{Binding Loc[wiz.password.encrypt]}"
IsChecked="{Binding EncryptWallet}"/>
<TextBlock Text="{Binding Loc[wiz.password.encrypt.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding !EncryptWallet}"/>
<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>
</ScrollViewer>
<!-- ============ PANNELLO WALLET ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
<!-- Testata: saldo + rete. Connetti/sincronizza è automatico; chiudi
wallet è in File. Lo stato connessione è nella barra in basso. -->
<StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,12">
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
</StackPanel>
<!-- Tab: Storico / Invia / Ricevi / Indirizzi / Contatti -->
<TabControl Grid.Row="1">
<!-- 1. Storico -->
<TabItem Header="{Binding Loc[tab.history]}">
<Grid RowDefinitions="Auto,*" Margin="4">
<TextBlock Grid.Row="0" Text="{Binding Loc[history.hint]}"
Foreground="Gray" FontSize="11" Margin="6,2"/>
<ListBox Grid.Row="1" ItemsSource="{Binding History}"
DoubleTapped="OnHistoryRowDoubleTapped">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70" Cursor="Hand">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<TextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- 2. Invia -->
<TabItem Header="{Binding Loc[tab.send]}">
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
<!-- Contatto rapido -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Loc[send.from.contact]}" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding Contacts}"
SelectedItem="{Binding SendToContact}"
PlaceholderText="{Binding Loc[send.contact.hint]}"
MinWidth="220">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ContactEntry">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
FontFamily="monospace"/>
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
<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}"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center"/>
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[send.prepare]}" Command="{Binding PrepareSendCommand}"/>
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
Command="{Binding ConfirmSendCommand}"
IsEnabled="{Binding HasPendingSend}"/>
</StackPanel>
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
FontSize="13"/>
</StackPanel>
</TabItem>
<!-- 3. Ricevi -->
<TabItem Header="{Binding Loc[tab.receive]}">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="{Binding Loc[receive.next]}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"
VerticalAlignment="Center"/>
<Button Content="{Binding Loc[receive.copy]}"
Click="OnCopyReceiveAddressClick"
VerticalAlignment="Center"/>
</StackPanel>
<Border Background="White" Padding="12" CornerRadius="6"
HorizontalAlignment="Left"
IsVisible="{Binding ReceiveQr, Converter={x:Static ObjectConverters.IsNotNull}}">
<Image Source="{Binding ReceiveQr}" Width="220" Height="220"
RenderOptions.BitmapInterpolationMode="None"/>
</Border>
<TextBlock Text="{Binding Loc[receive.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<!-- 4. Indirizzi -->
<TabItem Header="{Binding Loc[tab.addresses]}">
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}"
SelectedItem="{Binding SelectedAddressRow}"
x:Name="AddressesListBox"
Tapped="OnAddressListTapped"
PointerPressed="OnAddressListPointerPressed">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AddressRow">
<Grid ColumnDefinitions="90,60,*,140,60">
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
<TextBlock Grid.Column="2" Text="{Binding Indirizzo}"
FontFamily="monospace" FontSize="13"/>
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- 5. Contatti -->
<TabItem Header="{Binding Loc[tab.contacts]}">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="4">
<!-- Intestazioni colonne -->
<Grid Grid.Row="0" ColumnDefinitions="180,*" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[contacts.name]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[contacts.address]}" FontWeight="Bold"/>
</Grid>
<!-- Lista contatti -->
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}"
SelectedItem="{Binding SelectedContactInList}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ContactEntry">
<Grid ColumnDefinitions="180,*">
<TextBlock Grid.Column="0" Text="{Binding Name}"
VerticalAlignment="Center"/>
<SelectableTextBlock Grid.Column="1" Text="{Binding Address}"
FontFamily="monospace" FontSize="12"
VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Pulsante rimuovi selezionato -->
<Button Grid.Row="2" Content="{Binding Loc[contacts.remove]}"
Command="{Binding RemoveSelectedContactCommand}"
IsEnabled="{Binding SelectedContactInList, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,6,0,0"/>
<!-- Form aggiungi contatto -->
<Grid Grid.Row="3" ColumnDefinitions="180,*,Auto" Margin="0,8,0,0">
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[contacts.name.ph]}"
Text="{Binding NewContactName}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" PlaceholderText="{Binding Loc[contacts.address.ph]}"
Text="{Binding NewContactAddress}" FontFamily="monospace"
Margin="0,0,6,0"/>
<Button Grid.Column="2" Content="{Binding Loc[contacts.add]}"
Command="{Binding AddContactCommand}"/>
</Grid>
</Grid>
</TabItem>
</TabControl>
</Grid>
<!-- Barra di stato: messaggio a sinistra, stato connessione a destra.
Lo stato connessione è cliccabile e apre le impostazioni del server. -->
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Padding="10,6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" FontSize="12"
TextWrapping="Wrap" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
Margin="12,0,0,0" VerticalAlignment="Center"
Cursor="Hand" Background="Transparent"
ToolTip.Tip="{Binding Loc[server.title]}"
Tapped="OnConnectionStatusTapped">
<Ellipse Width="10" Height="10" Fill="LimeGreen" VerticalAlignment="Center"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed" VerticalAlignment="Center"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" FontSize="12"
Foreground="{DynamicResource SystemAccentColor}"
VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<!-- ============ OVERLAY DETTAGLIO INDIRIZZO ============ -->
<!-- Overlay in-app invece di una Window separata: apertura/chiusura
istantanee (niente create/destroy di una top-level window). -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnAddressOverlayBackdropTapped"
IsVisible="{Binding AddressInfo, Converter={x:Static ObjectConverters.IsNotNull}}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="560" Padding="0"
HorizontalAlignment="Center" VerticalAlignment="Center"
DataContext="{Binding AddressInfo}">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="{Binding Loc[addr.info.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- Indirizzo -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[addr.address]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding Address}"
FontFamily="monospace" FontSize="13"
TextWrapping="Wrap"/>
</StackPanel>
<!-- Derivation path -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[addr.derivpath]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding DerivPath}"
FontFamily="monospace" FontSize="13"/>
</StackPanel>
<!-- Chiave pubblica -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[addr.pubkey]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding PubKey}"
FontFamily="monospace" FontSize="12"
TextWrapping="Wrap"/>
</StackPanel>
<!-- Chiave privata (solo se disponibile) -->
<StackPanel Spacing="4" IsVisible="{Binding HasPrivKey}">
<TextBlock Text="{Binding Loc[addr.privkey]}" FontSize="11" Foreground="OrangeRed"/>
<SelectableTextBlock Text="{Binding PrivKey}"
FontFamily="monospace" FontSize="12"
Foreground="OrangeRed"
TextWrapping="Wrap"/>
</StackPanel>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).CloseAddressInfoCommand}"/>
</StackPanel>
</Border>
</Border>
<!-- ============ OVERLAY DETTAGLIO TRANSAZIONE ============ -->
<!-- Overlay in-app (come indirizzo): appare subito con lo spinner, i dati
arrivano dal server in background; chiusura istantanea. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnTxDetailsOverlayBackdropTapped"
IsVisible="{Binding IsTxDetailsOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="640" MaxHeight="600" Padding="0"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Panel Margin="24">
<!-- Caricamento -->
<StackPanel IsVisible="{Binding IsTxDetailsLoading}"
HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="14">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="{Binding Loc[tx.loading]}" Foreground="Gray"
HorizontalAlignment="Center"/>
</StackPanel>
<!-- Contenuto (IsVisible sul contenitore esterno, così resta
legato al VM principale; il DataContext interno è TxDetails) -->
<Grid IsVisible="{Binding !IsTxDetailsLoading}">
<DockPanel DataContext="{Binding TxDetails}">
<TextBlock DockPanel.Dock="Top" Text="{Binding Loc[tx.title]}"
FontSize="18" FontWeight="Bold" Margin="0,0,0,12"/>
<Button DockPanel.Dock="Bottom" Content="{Binding Loc[tx.close]}"
HorizontalAlignment="Right" Margin="0,12,0,0"
Command="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).CloseTransactionDetailsCommand}"/>
<ScrollViewer>
<StackPanel Spacing="14">
<Grid ColumnDefinitions="170,*" RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.status]}"/>
<SelectableTextBlock Grid.Row="0" Grid.Column="1" FontSize="13" TextWrapping="Wrap" Text="{Binding StatusText}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.date]}"/>
<SelectableTextBlock Grid.Row="1" Grid.Column="1" FontSize="13" Text="{Binding DateText}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding CounterpartyHeader}"/>
<SelectableTextBlock Grid.Row="2" Grid.Column="1" FontSize="13" FontFamily="monospace" TextWrapping="Wrap" Text="{Binding CounterpartyText}"/>
<TextBlock Grid.Row="3" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding AmountHeader}"/>
<SelectableTextBlock Grid.Row="3" Grid.Column="1" FontSize="13" FontFamily="monospace" Text="{Binding AmountText}"/>
<TextBlock Grid.Row="4" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.fee]}"/>
<SelectableTextBlock Grid.Row="4" Grid.Column="1" FontSize="13" FontFamily="monospace" Text="{Binding FeeText}"/>
<TextBlock Grid.Row="5" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.feerate]}"/>
<SelectableTextBlock Grid.Row="5" Grid.Column="1" FontSize="13" FontFamily="monospace" Text="{Binding FeeRateText}"/>
<TextBlock Grid.Row="6" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.net]}"/>
<SelectableTextBlock Grid.Row="6" Grid.Column="1" FontSize="13" FontFamily="monospace" FontWeight="Bold" Text="{Binding NetText}"/>
<TextBlock Grid.Row="7" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.id]}"/>
<SelectableTextBlock Grid.Row="7" Grid.Column="1" FontSize="12" FontFamily="monospace" TextWrapping="Wrap" Text="{Binding Txid}"/>
<TextBlock Grid.Row="8" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.size.total]}"/>
<SelectableTextBlock Grid.Row="8" Grid.Column="1" FontSize="13" Text="{Binding TotalSizeText}"/>
<TextBlock Grid.Row="9" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.size.virtual]}"/>
<SelectableTextBlock Grid.Row="9" Grid.Column="1" FontSize="13" Text="{Binding VirtualSizeText}"/>
<TextBlock Grid.Row="10" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.rbf]}"/>
<SelectableTextBlock Grid.Row="10" Grid.Column="1" FontSize="13" Text="{Binding RbfText}"/>
<TextBlock Grid.Row="11" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.verified]}"/>
<SelectableTextBlock Grid.Row="11" Grid.Column="1" FontSize="13" Foreground="Green" Text="{Binding VerifiedText}"/>
</Grid>
<TextBlock Text="{Binding Loc[tx.inputs]}" FontWeight="Bold" Margin="0,4,0,0"/>
<ItemsControl ItemsSource="{Binding Inputs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TxIoRow">
<Grid ColumnDefinitions="170,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Position}"
FontFamily="monospace" FontSize="11" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Address}"
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
<TextBlock Grid.Column="2" Text="{Binding Amount}"
FontFamily="monospace" FontSize="11" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Loc[tx.outputs]}" FontWeight="Bold" Margin="0,4,0,0"/>
<ItemsControl ItemsSource="{Binding Outputs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TxIoRow">
<Grid ColumnDefinitions="60,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Position}"
FontFamily="monospace" FontSize="11" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Address}"
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
<TextBlock Grid.Column="2" Text="{Binding Amount}"
FontFamily="monospace" FontSize="11" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Grid>
</Panel>
</Border>
</Border>
<!-- ============ OVERLAY IMPOSTAZIONI SERVER ============ -->
<!-- Stesso pattern dell'overlay indirizzo: apertura/chiusura istantanee.
Accessibile da Impostazioni → Server. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnServerOverlayBackdropTapped"
IsVisible="{Binding IsServerSettingsOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="560" MaxHeight="540"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[server.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- Host + porta separati -->
<Grid ColumnDefinitions="*,Auto,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[server.host]}"
FontSize="11" Foreground="Gray"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Loc[server.port]}"
FontSize="11" Foreground="Gray" Margin="10,0,0,0" Width="90"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="TLS"
FontSize="11" Foreground="Gray" Margin="10,0,0,0"/>
<TextBox Grid.Row="1" Grid.Column="0" Text="{Binding ServerHost}"
FontFamily="monospace" Margin="0,2,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ServerPort}"
FontFamily="monospace" Width="90" Margin="10,2,0,0"/>
<CheckBox Grid.Row="1" Grid.Column="2" IsChecked="{Binding UseSsl}"
Margin="10,2,0,0" VerticalAlignment="Center"/>
</Grid>
<!-- Azioni -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}"/>
</StackPanel>
<!-- Stato connessione -->
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Width="10" Height="10" Fill="LimeGreen" VerticalAlignment="Center"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed" VerticalAlignment="Center"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Server conosciuti: clicca per riempire host/porta -->
<TextBlock Text="{Binding Loc[server.known]}" FontSize="11" Foreground="Gray"/>
<ListBox ItemsSource="{Binding KnownServers}"
SelectedItem="{Binding SelectedKnownServer}"
MaxHeight="200">
<ListBox.Styles>
<Style Selector="ListBox:empty">
<Setter Property="Template">
<ControlTemplate>
<TextBlock Text="{Binding Loc[server.empty]}"
Foreground="Gray" FontSize="12"
TextWrapping="Wrap" Margin="8"/>
</ControlTemplate>
</Setter>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="net:KnownServer">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Host}"
FontFamily="monospace" FontSize="13"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" FontSize="11" Foreground="Gray"
VerticalAlignment="Center">
<Run Text="tcp"/><Run Text="{Binding TcpPort}"/>
<Run Text=" / ssl"/><Run Text="{Binding SslPort}"/>
</TextBlock>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding CloseServerSettingsCommand}"/>
</StackPanel>
</Border>
</Border>
<!-- ============ OVERLAY IMPOSTAZIONI ============ -->
<!-- In-app invece dei sottomenu annidati: niente popup OS lenti su WSLg. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnSettingsOverlayBackdropTapped"
IsVisible="{Binding IsSettingsOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="460"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="{Binding Loc[settings.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- Lingua -->
<StackPanel Spacing="6">
<TextBlock Text="{Binding Loc[settings.language]}" FontSize="11" Foreground="Gray"/>
<WrapPanel>
<RadioButton GroupName="lang" Content="Italiano" Margin="0,0,14,4"
IsChecked="{Binding IsLangIt, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
<RadioButton GroupName="lang" Content="English" Margin="0,0,14,4"
IsChecked="{Binding IsLangEn, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
<RadioButton GroupName="lang" Content="Español" Margin="0,0,14,4"
IsChecked="{Binding IsLangEs, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="es"/>
<RadioButton GroupName="lang" Content="Français" Margin="0,0,14,4"
IsChecked="{Binding IsLangFr, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="fr"/>
<RadioButton GroupName="lang" Content="Português" Margin="0,0,14,4"
IsChecked="{Binding IsLangPt, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="pt"/>
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
IsChecked="{Binding IsLangDe, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
</WrapPanel>
</StackPanel>
<!-- Unità -->
<StackPanel Spacing="6">
<TextBlock Text="{Binding Loc[settings.unit]}" FontSize="11" Foreground="Gray"/>
<WrapPanel>
<RadioButton GroupName="unit" Content="PLM" Margin="0,0,14,4"
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
<RadioButton GroupName="unit" Content="mPLM" Margin="0,0,14,4"
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
<RadioButton GroupName="unit" Content="µPLM" Margin="0,0,14,4"
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
<RadioButton GroupName="unit" Content="sat" Margin="0,0,14,4"
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
</WrapPanel>
</StackPanel>
<!-- Server di indicizzazione (configurabile anche prima di aprire un wallet) -->
<Button Content="{Binding Loc[settings.server]}"
HorizontalAlignment="Left"
Command="{Binding OpenServerSettingsCommand}"/>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding CloseSettingsCommand}"/>
</StackPanel>
</Border>
</Border>
<!-- ============ OVERLAY HELP / INFORMAZIONI ============ -->
<!-- Stesso pattern dell'overlay impostazioni: apertura/chiusura istantanee. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnHelpOverlayBackdropTapped"
IsVisible="{Binding IsHelpOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="460"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="{Binding Loc[help.title]}"
FontSize="18" FontWeight="Bold"/>
<StackPanel Spacing="4">
<TextBlock Text="Palladium Wallet" FontSize="16" FontWeight="Bold"/>
<TextBlock Text="{Binding WindowTitle}" FontSize="12" Foreground="Gray"/>
</StackPanel>
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding CloseHelpCommand}"/>
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>
+156
View File
@@ -0,0 +1,156 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App.Views;
/// <summary>
/// Vista radice dell'app, condivisa tra desktop (ospitata in <see cref="MainWindow"/>)
/// e mobile (root single-view). Tutti gli overlay sono in-app, quindi non servono
/// finestre separate. Le API legate al top-level (file picker, clipboard) si
/// raggiungono via <see cref="TopLevel.GetTopLevel"/> perché un UserControl non le espone.
/// </summary>
public partial class MainView : UserControl
{
public MainView()
{
InitializeComponent();
}
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Apri file wallet",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
],
});
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.OpenFromPath(path);
}
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
{
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
if (lb.SelectedItem is not HistoryRow row) return;
// Overlay in-app: appare subito con lo spinner, i dati arrivano dal
// server in background. Niente top-level window (lenta da aprire/chiudere).
_ = vm.ShowTransactionDetailsAsync(row.Txid);
}
private void OnTxDetailsOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.CloseTransactionDetailsCommand.Execute(null);
}
private void OnAddressListTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
return;
vm.ShowAddressInfo(row);
}
private void OnAddressListPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(null).Properties.IsRightButtonPressed) return;
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>();
if (item is not { DataContext: AddressRow row }) return;
lb.SelectedItem = row;
vm.ShowAddressInfo(row);
}
// Chiusura dell'overlay dettaglio indirizzo: click sullo sfondo scuro
// (solo sullo sfondo, non sulla scheda) o tasto Esc.
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.AddressInfo = null;
}
private void OnServerOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.IsServerSettingsOpen = false;
}
private async void OnChooseDataFolderClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
var folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Cartella dati Palladium Wallet",
AllowMultiple = false,
});
if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.ApplyDataLocation(path);
}
private async void OnCopyReceiveAddressClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.ReceiveAddress))
return;
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
{
await clipboard.SetTextAsync(vm.ReceiveAddress);
vm.NotifyAddressCopied();
}
}
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
vm.IsServerSettingsOpen = true;
}
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.IsSettingsOpen = false;
}
private void OnHelpOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.IsHelpOpen = false;
}
// Esc (desktop) o tasto Back (Android) chiudono l'overlay in primo piano.
protected override void OnKeyDown(KeyEventArgs e)
{
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
{
if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
if (vm.AddressInfo is not null) { vm.AddressInfo = null; e.Handled = true; return; }
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
}
base.OnKeyDown(e);
}
}
+4 -287
View File
@@ -1,302 +1,19 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PalladiumWallet.App.ViewModels" xmlns:vm="using:PalladiumWallet.App.ViewModels"
xmlns:views="using:PalladiumWallet.App.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
x:Class="PalladiumWallet.App.Views.MainWindow" x:Class="PalladiumWallet.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico" Icon="/Assets/logo.ico"
Width="900" Height="620" Width="900" Height="620"
Title="Palladium Wallet"> Title="{Binding WindowTitle}">
<Design.DataContext> <Design.DataContext>
<vm:MainWindowViewModel/> <vm:MainWindowViewModel/>
</Design.DataContext> </Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto"> <views:MainView/>
<!-- ============ MENU (stile Electrum) ============ -->
<Menu Grid.Row="0">
<MenuItem Header="{Binding Loc[menu.file]}">
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"/>
<Separator/>
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
IsEnabled="{Binding IsWalletOpen}"/>
</MenuItem>
<MenuItem Header="{Binding Loc[menu.net]}">
<MenuItem Header="{Binding Loc[menu.net.discover]}" Command="{Binding DiscoverServersCommand}"/>
<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>
</Menu>
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
HorizontalAlignment="Center">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/>
<!-- Passo 1: scelta iniziale -->
<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}"
SelectedItem="{Binding SelectedNetwork}" 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>
<!-- Passo: password del wallet esistente -->
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
<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">
<SelectableTextBlock Text="{Binding MnemonicInput}"
FontFamily="monospace" FontSize="16" TextWrapping="Wrap"/>
</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>
<!-- Passo: conferma del seed -->
<StackPanel IsVisible="{Binding IsStepConfirmSeed}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.confirm.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.confirm.placeholder]}"
AcceptsReturn="False" Text="{Binding ConfirmMnemonicInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromConfirmSeedCommand}"/>
</StackPanel>
</StackPanel>
<!-- 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>
</ScrollViewer>
<!-- ============ PANNELLO WALLET ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
<!-- Testata: saldo + rete + chiudi -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
</StackPanel>
<Button Grid.Column="1" Content="{Binding Loc[wallet.close]}" VerticalAlignment="Top"
Command="{Binding CloseWalletCommand}"/>
</Grid>
<!-- Server -->
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[wallet.server]}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox Grid.Row="0" Grid.Column="1"
ItemsSource="{Binding KnownServers}"
SelectedItem="{Binding SelectedKnownServer}"
HorizontalAlignment="Stretch"/>
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
IsChecked="{Binding UseSsl}" Margin="8,0"/>
<Button Grid.Row="0" Grid.Column="3" Content="{Binding Loc[wallet.connect]}"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
Spacing="8" Margin="0,8,0,0">
<TextBox Text="{Binding ServerInput}" MinWidth="220"
PlaceholderText="{Binding Loc[wallet.manual]}"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
Orientation="Horizontal" Spacing="6" Margin="8,8,0,0"
VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="LimeGreen"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"/>
</StackPanel>
</Grid>
</Border>
<!-- Tab: Ricevi / Storico / Indirizzi / Invia -->
<TabControl Grid.Row="2">
<TabItem Header="{Binding Loc[tab.receive]}">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="{Binding Loc[receive.next]}"/>
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"/>
<TextBlock Text="{Binding Loc[receive.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<TabItem Header="{Binding Loc[tab.history]}">
<ListBox ItemsSource="{Binding History}" Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="{Binding Loc[tab.addresses]}">
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AddressRow">
<Grid ColumnDefinitions="90,60,*,140,60">
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Indirizzo}"
FontFamily="monospace" FontSize="13"/>
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<TabItem Header="{Binding Loc[tab.send]}">
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
FontFamily="monospace"/>
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
<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}"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center"/>
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[send.prepare]}" Command="{Binding PrepareSendCommand}"/>
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
Command="{Binding ConfirmSendCommand}"
IsEnabled="{Binding HasPendingSend}"/>
</StackPanel>
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
FontSize="13"/>
</StackPanel>
</TabItem>
</TabControl>
</Grid>
<!-- Barra di stato -->
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Padding="10,6">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/>
</Border>
</Grid>
</Window> </Window>
+1 -24
View File
@@ -1,35 +1,12 @@
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App.Views; namespace PalladiumWallet.App.Views;
/// <summary>Finestra desktop: ospita <see cref="MainView"/> (la UI condivisa con mobile).</summary>
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
} }
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
return;
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Apri file wallet",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
],
});
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.OpenFromPath(path);
}
} }
+1 -1
View File
@@ -6,7 +6,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
+2 -2
View File
@@ -9,8 +9,8 @@ namespace PalladiumWallet.Core.Storage;
/// </summary> /// </summary>
public sealed class AppConfig public sealed class AppConfig
{ {
/// <summary>Codice lingua UI ("it", "en").</summary> /// <summary>Codice lingua UI.</summary>
public string Language { get; set; } = "it"; public string Language { get; set; } = "en";
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary> /// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
public string Unit { get; set; } = "PLM"; public string Unit { get; set; } = "PLM";
+90 -8
View File
@@ -3,24 +3,97 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Storage; namespace PalladiumWallet.Core.Storage;
/// <summary> /// <summary>
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o /// Percorsi dati per piattaforma (blueprint §8). La radice dati può essere:
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità /// 1. <b>portable</b>: cartella "palladium-data" accanto all'eseguibile;
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile /// 2. <b>personalizzata</b>: scelta dall'utente al primo avvio e memorizzata in
/// esiste una cartella "palladium-data". /// un piccolo file "puntatore" in una posizione di bootstrap fissa;
/// 3. <b>legacy</b>: vecchia posizione (%APPDATA%/PalladiumWallet) se contiene già dati;
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) o %ProgramFiles%\PalladiumWallet (Windows).
/// Sotto la radice c'è una sottocartella per rete (config, wallet, header, certificati).
/// </summary> /// </summary>
public static class AppPaths public static class AppPaths
{ {
public const string PortableDirName = "palladium-data"; public const string PortableDirName = "palladium-data";
/// <summary>Nome cartella applicazione, usato nei vari percorsi.</summary>
public const string AppDirName = "PalladiumWallet";
/// <summary>Override esplicito della radice dati (es. CLI --data-dir). Ha priorità su tutto.</summary>
public static string? OverrideDataRoot { get; set; }
/// <summary>
/// Radice dati predefinita, secondo la convenzione di ogni piattaforma:
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, come Electrum/Bitcoin);
/// Linux/macOS → ~/.palladium-wallet (dotfolder minuscolo, come ~/.bitcoin).
/// Per-utente e sempre scrivibile, senza privilegi di amministratore.
/// </summary>
public static string DefaultDataRoot()
{
if (OperatingSystem.IsWindows())
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppDirName);
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(home, ".palladium-wallet");
}
/// <summary>File puntatore alla radice dati scelta dall'utente. Vive in una
/// posizione di bootstrap sempre scrivibile e indipendente dalla radice dati.</summary>
private static string LocationPointerPath() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppDirName, "data-location");
private static string PortableRoot() =>
Path.Combine(AppContext.BaseDirectory, PortableDirName);
private static bool HasData(string root) =>
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
/// <summary>Radice dati effettiva, secondo l'ordine di precedenza documentato in classe.</summary>
public static string DataRoot() public static string DataRoot()
{ {
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName); if (!string.IsNullOrEmpty(OverrideDataRoot))
return OverrideDataRoot;
var portable = PortableRoot();
if (Directory.Exists(portable)) if (Directory.Exists(portable))
return portable; return portable;
return OperatingSystem.IsWindows() if (ReadPointer() is { } custom)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet") return custom;
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
return DefaultDataRoot();
}
/// <summary>
/// true se la posizione dei dati è già determinata e non serve chiederla
/// all'utente: modalità portable, override, puntatore già scritto, oppure
/// dati già presenti nella posizione predefinita.
/// </summary>
public static bool IsDataLocationConfigured() =>
!string.IsNullOrEmpty(OverrideDataRoot)
|| Directory.Exists(PortableRoot())
|| ReadPointer() is not null
|| HasData(DefaultDataRoot());
/// <summary>Memorizza la radice dati scelta dall'utente e la crea su disco.</summary>
public static void ConfigureDataLocation(string root)
{
root = Path.GetFullPath(root.Trim());
Directory.CreateDirectory(root);
var pointer = LocationPointerPath();
Directory.CreateDirectory(Path.GetDirectoryName(pointer)!);
File.WriteAllText(pointer, root);
}
private static string? ReadPointer()
{
var pointer = LocationPointerPath();
if (!File.Exists(pointer))
return null;
var path = File.ReadAllText(pointer).Trim();
return string.IsNullOrEmpty(path) ? null : path;
} }
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary> /// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
@@ -41,6 +114,15 @@ public static class AppPaths
public static string DefaultWalletPath(NetKind net) => public static string DefaultWalletPath(NetKind net) =>
Path.Combine(WalletsDir(net), "default.wallet.json"); Path.Combine(WalletsDir(net), "default.wallet.json");
/// <summary>Tutti i file wallet della rete, ordinati per nome (multi-wallet §8).</summary>
public static IReadOnlyList<string> WalletFiles(NetKind net)
{
var dir = WalletsDir(net);
return Directory.EnumerateFiles(dir, "*.wallet.json")
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static string CertificatePinsPath(NetKind net) => public static string CertificatePinsPath(NetKind net) =>
Path.Combine(ForNetwork(net), "server-certs.json"); Path.Combine(ForNetwork(net), "server-certs.json");
+10
View File
@@ -40,6 +40,9 @@ public sealed class WalletDocument
/// <summary>Etichette per indirizzo/txid (§12).</summary> /// <summary>Etichette per indirizzo/txid (§12).</summary>
public Dictionary<string, string> Labels { get; set; } = []; public Dictionary<string, string> Labels { get; set; } = [];
/// <summary>Rubrica contatti (nome + indirizzo blockchain).</summary>
public List<StoredContact> Contacts { get; set; } = [];
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary> /// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
public SyncCache? Cache { get; set; } public SyncCache? Cache { get; set; }
@@ -69,6 +72,13 @@ public sealed class WalletDocument
}; };
} }
/// <summary>Contatto in rubrica: nome leggibile + indirizzo blockchain.</summary>
public sealed class StoredContact
{
public required string Name { get; set; }
public required string Address { get; set; }
}
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary> /// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
public sealed class SyncCache public sealed class SyncCache
{ {
+192
View File
@@ -0,0 +1,192 @@
using NBitcoin;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
namespace PalladiumWallet.Core.Wallet;
/// <summary>Un input di una transazione, con l'output speso risolto dal server.</summary>
public sealed record TxInputInfo(
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
/// <summary>Un output di una transazione.</summary>
public sealed record TxOutputInfo(
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
/// <summary>
/// Dati completi di una transazione, assemblati interrogando il server: la tx
/// grezza più gli output spesi dagli input (per ricavare importi, indirizzi e
/// fee) e l'header del blocco (per la data). Tutto ciò che il protocollo
/// ElectrumX-like (§10) permette di sapere su una transazione.
/// </summary>
public sealed class TransactionDetails
{
public required string Txid { get; init; }
/// <summary>Altezza del blocco; ≤0 = ancora in mempool.</summary>
public required int Height { get; init; }
public required int Confirmations { get; init; }
/// <summary>Effetto netto sul saldo del wallet (delta calcolato in sincronizzazione).</summary>
public required long NetSats { get; init; }
/// <summary>Fee della transazione; null se un input ha importo non risolvibile (es. coinbase).</summary>
public required long? FeeSats { get; init; }
public required int TotalSize { get; init; }
public required int VirtualSize { get; init; }
public required uint Version { get; init; }
public required uint LockTime { get; init; }
public required bool RbfSignaled { get; init; }
/// <summary>Merkle proof verificata in sincronizzazione (§7.4).</summary>
public required bool Verified { get; init; }
public required DateTimeOffset? BlockTime { get; init; }
public required long TotalOutSats { get; init; }
public required long? TotalInSats { get; init; }
public required IReadOnlyList<TxInputInfo> Inputs { get; init; }
public required IReadOnlyList<TxOutputInfo> Outputs { get; init; }
public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
public bool IsIncoming => NetSats >= 0;
/// <summary>Importo verso destinatari esterni (output non nostri): l'importo "inviato".</summary>
public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
/// <summary>
/// Indirizzi della controparte: i destinatari esterni per un invio (output non
/// nostri), i mittenti esterni per una ricezione (input non nostri).
/// </summary>
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
: [.. Outputs.Where(o => !o.IsMine && o.Address is not null).Select(o => o.Address!).Distinct()];
}
/// <summary>
/// Recupera dal server tutti i dati di una singola transazione (blueprint §10):
/// la transazione grezza e gli output spesi dai suoi input, per ricostruire
/// importi, fee e indirizzi che il server non riassume.
/// </summary>
public static class TransactionInspector
{
public static async Task<TransactionDetails> FetchAsync(
ElectrumClient client, Network network, string txid, int tipHeight, int height,
IReadOnlySet<string> ownedAddresses, long netSats, bool verified,
IReadOnlyDictionary<string, Transaction>? cache = null,
CancellationToken ct = default)
{
async Task<Transaction> GetTx(string id)
{
if (cache is not null && cache.TryGetValue(id, out var hit))
return hit;
return Transaction.Parse(await client.GetTransactionAsync(id, ct), network);
}
async Task<Transaction?> GetTxOrNull(string id)
{
try { return await GetTx(id); }
catch { return null; }
}
async Task<DateTimeOffset?> GetBlockTimeOrNull()
{
try
{
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
return DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
}
catch { return null; }
}
string? AddrOf(Script s)
{
try { return s.GetDestinationAddress(network)?.ToString(); }
catch { return null; }
}
var tx = await GetTx(txid);
var outputs = new List<TxOutputInfo>(tx.Outputs.Count);
for (var i = 0; i < tx.Outputs.Count; i++)
{
var o = tx.Outputs[i];
var addr = AddrOf(o.ScriptPubKey);
outputs.Add(new TxOutputInfo(
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
addr is not null && ownedAddresses.Contains(addr)));
}
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
// Le transazioni degli input servono per importi/indirizzi/fee. Si
// scaricano in parallelo (id univoci, richieste concorrenti supportate
// da ElectrumClient): in sequenza la finestra impiegava un round-trip
// per input. Anche l'header del blocco è recuperato in parallelo.
var prevTxids = tx.IsCoinBase
? []
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
var prevFetch = prevTxids.ToDictionary(id => id, id => GetTxOrNull(id));
var headerTask = height > 0 ? GetBlockTimeOrNull() : Task.FromResult<DateTimeOffset?>(null);
await Task.WhenAll(prevFetch.Values.Cast<Task>().Append(headerTask));
var prevTxs = prevFetch.ToDictionary(kv => kv.Key, kv => kv.Value.Result);
var blockTime = await headerTask;
var inputs = new List<TxInputInfo>(tx.Inputs.Count);
var feeKnown = !tx.IsCoinBase;
long inSum = 0;
foreach (var inp in tx.Inputs)
{
if (tx.IsCoinBase)
{
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
continue;
}
long? amt = null;
string? addr = null;
var prev = prevTxs.GetValueOrDefault(inp.PrevOut.Hash.ToString());
if (prev is not null && inp.PrevOut.N < prev.Outputs.Count)
{
var po = prev.Outputs[(int)inp.PrevOut.N];
amt = po.Value.Satoshi;
addr = AddrOf(po.ScriptPubKey);
inSum += po.Value.Satoshi;
}
else feeKnown = false;
inputs.Add(new TxInputInfo(
inp.PrevOut.Hash.ToString(), inp.PrevOut.N, amt, addr,
addr is not null && ownedAddresses.Contains(addr), false));
}
var outSum = tx.Outputs.Sum(o => o.Value.Satoshi);
return new TransactionDetails
{
Txid = txid,
Height = height,
Confirmations = height > 0 && tipHeight >= height ? tipHeight - height + 1 : 0,
NetSats = netSats,
FeeSats = feeKnown ? inSum - outSum : null,
TotalSize = tx.ToBytes().Length,
VirtualSize = tx.GetVirtualSize(),
Version = tx.Version,
LockTime = tx.LockTime.Value,
RbfSignaled = rbf,
Verified = verified,
BlockTime = blockTime,
TotalOutSats = outSum,
TotalInSats = feeKnown ? inSum : null,
Inputs = inputs,
Outputs = outputs,
};
}
private static string ScriptType(Script script)
{
try
{
var t = StandardScripts.GetTemplateFromScriptPubKey(script);
return t is null
? "nonstandard"
: t.GetType().Name.Replace("PayTo", "").Replace("Template", "");
}
catch { return "—"; }
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -197,7 +197,7 @@ public class TransactionFactoryTests
{ {
File.WriteAllText(path, "{ rotto "); File.WriteAllText(path, "{ rotto ");
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path); var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
Assert.Equal("it", loaded.Language); Assert.Equal("en", loaded.Language);
Assert.Equal("PLM", loaded.Unit); Assert.Equal("PLM", loaded.Unit);
} }
finally finally