4 Commits

Author SHA1 Message Date
davide e3570da3f4 Add CLAUDE.md and blueprint.md (for development) 2026-06-11 16:24:25 +02:00
davide 75583f7a69 feat(app): settings menu with language and display unit
Add global AppConfig (config.json, blueprint §8) persisting language
and amount unit. CoinAmount gains unit-aware formatting and parsing
(PLM, mPLM, µPLM, sat), applied everywhere amounts are shown or
entered, including the send form. New Loc i18n layer (it/en) with
live-updating XAML bindings covering menu, wizard, wallet panel and
status messages.

The Impostazioni menu opens a dropdown with separate Lingua and Unità
entries (radio-checked submenus, applied and saved on click), ready to
host future settings.
2026-06-11 16:23:06 +02:00
davide e8777fb458 feat(app): step-by-step setup wizard instead of single-form panel
Startup now follows the blueprint §15 flow, one field per screen with
Avanti/Indietro: initial choice (open / create / restore), seed display
with write-it-down warning, seed confirmation by re-typing, mnemonic
validation on restore, optional BIP39 passphrase, and wallet file
password as the final step. Opening an encrypted file from the menu
routes to the same password step.
2026-06-11 16:04:29 +02:00
davide 73a2d5356d feat(spv): Electrum-style continuous updates with incremental parallel sync
WalletSynchronizer now pipelines JSON-RPC requests (address subscribes,
histories, tx downloads and Merkle proofs run in parallel batches) and
keeps per-connection caches of downloaded transactions and verified
proofs, so notification-triggered resyncs only cost the delta.

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