14 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
davide ece634f42e feat(wallet): show pending mempool balance and exclude unconfirmed UTXOs from spending
TransactionFactory now selects only confirmed UTXOs (height > 0), so
mempool funds are visible as pending balance but never spendable, with
a clear error when only unconfirmed funds are available. GUI and CLI
labels updated to "in attesa di conferma (non spendibile)".
2026-06-11 11:34:21 +02:00
davide af10482e54 feat(cli): servers command, info --addresses, registry fallback for --server 2026-06-11 11:28:00 +02:00
davide 46d8c19784 feat(app): server selector, peer discovery, address view, menu bar, keep-alive 2026-06-11 11:27:43 +02:00
davide 08217e5306 feat(spv): per-address balance and tx count in sync result 2026-06-11 11:27:31 +02:00
davide 1c2dacc27c feat(net): server registry, bootstrap servers, peer discovery 2026-06-11 11:27:16 +02:00
davide 34c588c6f4 test: unit tests for all core modules 2026-06-11 10:48:27 +02:00
davide 0e32b9bc9b feat(cli): command-line interface 2026-06-11 10:48:12 +02:00
davide bb8563580f feat(app): Avalonia UI shell 2026-06-11 10:47:56 +02:00
davide 3d6597a3b2 feat(wallet): coin selection, tx factory, wallet loader 2026-06-11 10:47:43 +02:00
davide 4d2ae1fd13 feat(storage): encrypted wallet file and persistence 2026-06-11 10:47:31 +02:00
38 changed files with 4462 additions and 43 deletions
-3
View File
@@ -91,6 +91,3 @@ settings.local.json
# Local agent/tooling metadata # Local agent/tooling metadata
.agents/ .agents/
.codex/ .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.*
+15
View File
@@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PalladiumWallet.App.App"
xmlns:local="using:PalladiumWallet.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+31
View File
@@ -0,0 +1,31 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using PalladiumWallet.App.ViewModels;
using PalladiumWallet.App.Views;
namespace PalladiumWallet.App;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+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"],
};
}
+24
View File
@@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace PalladiumWallet.App;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
+763
View File
@@ -0,0 +1,763 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
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;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga dello storico transazioni per la vista.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary>
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx);
/// <summary>
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
/// pannello di setup (crea/ripristina/apri) e pannello wallet
/// (saldo, ricevi, storico, invia, server).
/// </summary>
public partial class MainWindowViewModel : ViewModelBase
{
private WalletDocument? _doc;
private HdAccount? _account;
private string? _walletPath;
private string? _password;
private ElectrumClient? _client;
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;
/// <summary>Dopo la prima connessione riuscita il timer riconnette da solo.</summary>
private bool _autoReconnect;
private readonly DispatcherTimer _keepAliveTimer;
// ---- selezione rete e stato pannelli ----
public string[] Networks { get; } = ["mainnet", "testnet", "regtest"];
[ObservableProperty]
private string selectedNetwork = "mainnet";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
private bool isWalletOpen;
public bool IsSetupVisible => !IsWalletOpen;
[ObservableProperty]
private bool walletFileExists;
[ObservableProperty]
private string statusMessage = "";
// ---- 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 = "";
[ObservableProperty]
private string passwordInput = "";
// ---- pannello wallet ----
[ObservableProperty]
private string balanceText = "—";
[ObservableProperty]
private string unconfirmedText = "";
[ObservableProperty]
private string networkInfo = "";
[ObservableProperty]
private string receiveAddress = "";
[ObservableProperty]
private string serverInput = "";
[ObservableProperty]
private bool useSsl;
[ObservableProperty]
private string connectionStatus = Loc.Tr("conn.none");
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
public ObservableCollection<KnownServer> KnownServers { get; } = [];
[ObservableProperty]
private KnownServer? selectedKnownServer;
public ObservableCollection<HistoryRow> History { get; } = [];
public ObservableCollection<AddressRow> Addresses { get; } = [];
// ---- pannello invia ----
[ObservableProperty]
private string sendTo = "";
[ObservableProperty]
private string sendAmount = "";
[ObservableProperty]
private string sendFeeRate = "1";
[ObservableProperty]
private bool sendAll;
[ObservableProperty]
private string sendPreview = "";
[ObservableProperty]
private bool hasPendingSend;
public MainWindowViewModel()
{
RefreshSetupState();
// Aggiornamenti continui (§9): ping periodico per tenere viva la
// connessione e accorgersi subito della caduta; se cade, riconnette
// e risincronizza da solo. Le notifiche push restano la via principale.
_keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) };
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
_keepAliveTimer.Start();
}
private async Task KeepAliveTickAsync()
{
if (!IsWalletOpen || IsSyncing)
return;
if (_client is { IsConnected: true })
{
try
{
await _client.PingAsync();
}
catch
{
// La caduta viene gestita dall'evento Disconnected.
}
}
else if (_autoReconnect)
{
ConnectionStatus = Loc.Tr("conn.reconnecting");
await ConnectAndSync();
}
}
private NetKind Net => Enum.Parse<NetKind>(SelectedNetwork, ignoreCase: true);
private ChainProfile Profile => ChainProfiles.For(Net);
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
private void RefreshSetupState()
{
SetupStep = StepStart;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
StatusMessage = WalletFileExists
? Loc.Tr("msg.welcome.existing")
: Loc.Tr("msg.welcome.new");
RefreshServers();
}
private void RefreshServers()
{
KnownServers.Clear();
foreach (var server in Registry.All)
KnownServers.Add(server);
SelectedKnownServer = KnownServers.FirstOrDefault();
if (SelectedKnownServer is null)
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}";
}
partial void OnSelectedKnownServerChanged(KnownServer? value)
{
if (value is not null)
ServerInput = $"{value.Host}:{value.PortFor(UseSsl)}";
}
partial void OnUseSslChanged(bool value)
{
if (SelectedKnownServer is { } server)
ServerInput = $"{server.Host}:{server.PortFor(value)}";
}
// ---------- comandi del wizard (§15): un passo alla volta ----------
[RelayCommand]
private void WizardStartOpen()
{
PasswordInput = "";
SetupStep = StepOpen;
StatusMessage = Loc.Tr("msg.open.password");
}
[RelayCommand]
private void WizardStartNew()
{
_isRestoreFlow = false;
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
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]
private void CreateOrRestore()
{
try
{
var (doc, account) = WalletLoader.NewFromMnemonic(
MnemonicInput,
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
ScriptKind.NativeSegwit,
Profile);
// Mai sovrascrivere un wallet esistente: si cerca il primo nome libero.
var path = AppPaths.DefaultWalletPath(Net);
for (var n = 2; WalletStore.Exists(path); n++)
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
WalletStore.Save(doc, path, password);
OpenLoaded(doc, account, path, password);
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
[RelayCommand]
private void OpenExisting()
{
try
{
var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net);
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
var doc = WalletStore.Load(path, password);
_pendingOpenPath = null;
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password);
}
catch (WrongPasswordException)
{
StatusMessage = Loc.Tr("msg.wrongpassword");
}
catch (Exception ex)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
/// <summary>Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8).</summary>
public void OpenFromPath(string path)
{
try
{
if (IsWalletOpen)
CloseWallet();
var doc = WalletStore.Load(path);
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null);
}
catch (WrongPasswordException)
{
// 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)
{
StatusMessage = $"Errore: {ex.Message}";
}
}
/// <summary>Torna al pannello di setup per creare/ripristinare un altro wallet.</summary>
[RelayCommand]
private void NewWallet()
{
if (IsWalletOpen)
CloseWallet();
_pendingOpenPath = null;
StatusMessage = Loc.Tr("msg.welcome.new");
}
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
{
// La rete del wallet comanda (registry, pin TLS, indirizzi).
SelectedNetwork = doc.Network;
_doc = doc;
_account = account;
_walletPath = path;
_password = password;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
SetupStep = StepStart;
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
+ (doc.IsWatchOnly ? " · watch-only" : "");
ApplyCache(doc.Cache);
IsWalletOpen = true;
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)
{
if (_account is null)
return;
if (cache is null)
{
BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear();
// Prima della sincronizzazione si mostrano i primi indirizzi derivati.
Addresses.Clear();
for (var i = 0; i < 10; i++)
Addresses.Add(new AddressRow("ricezione", i,
_account.GetReceiveAddress(i).ToString(), "—", "—"));
return;
}
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
? $"{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 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid,
tx.Verified ? "✓ SPV" : "—"));
Addresses.Clear();
foreach (var a in cache.Addresses)
Addresses.Add(new AddressRow(
a.IsChange ? "change" : "ricezione",
a.Index,
a.Address,
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
}
// ---------- comandi wallet ----------
[RelayCommand]
private async Task ConnectAndSync()
{
if (_account is null || _doc is null)
return;
if (IsSyncing)
{
_resyncRequested = true;
return;
}
IsSyncing = true;
StatusMessage = "";
try
{
if (_client is null || !_client.IsConnected)
{
var (host, port) = ParseServer();
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 = Loc.Tr("conn.disconnected");
});
IsConnected = true;
_autoReconnect = true;
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);
}
// 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
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
};
WalletStore.Save(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache);
StatusMessage = $"{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 = Loc.Tr("conn.certchanged");
StatusMessage = ex.Message;
}
catch (Exception ex)
{
IsConnected = _client?.IsConnected == true;
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error");
StatusMessage = $"Errore: {ex.Message}";
}
finally
{
IsSyncing = false;
}
}
/// <summary>Scopre nuovi server dai peer annunciati dal server connesso (§9).</summary>
[RelayCommand]
private async Task DiscoverServers()
{
if (_client is null || !_client.IsConnected)
{
StatusMessage = Loc.Tr("conn.none") + ".";
return;
}
try
{
var added = await Registry.DiscoverAsync(_client);
var selected = SelectedKnownServer;
RefreshServers();
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
?? KnownServers.FirstOrDefault();
StatusMessage = added > 0
? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})."
: $"Nessun nuovo server annunciato (totale {KnownServers.Count}).";
}
catch (Exception ex)
{
StatusMessage = $"Errore nella scoperta peer: {ex.Message}";
}
}
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)
_resyncRequested = true;
else
_ = ConnectAndSync();
});
}
[RelayCommand]
private void ResetCertificates()
{
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
StatusMessage = Loc.Tr("msg.certreset");
}
[RelayCommand]
private async Task PrepareSend()
{
if (_account is null || _doc?.Cache is null)
{
SendPreview = "Sincronizza prima di inviare.";
return;
}
try
{
if (_lastTransactions is null)
{
SendPreview = "Connettiti al server e sincronizza prima di inviare.";
return;
}
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
long amount = 0;
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
{
SendPreview = "Importo non valido.";
return;
}
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
{
SendPreview = "Fee rate non valido.";
return;
}
_pendingSend = new TransactionFactory(_account).Build(
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
_doc.Cache.NextChangeIndex, SendAll);
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
HasPendingSend = _pendingSend.Signed;
}
catch (Exception ex)
{
_pendingSend = null;
HasPendingSend = false;
SendPreview = $"Errore: {ex.Message}";
}
await Task.CompletedTask;
}
[RelayCommand]
private async Task ConfirmSend()
{
if (_pendingSend is null || _client is null)
return;
try
{
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
SendPreview = $"Trasmessa: {txid}";
SendTo = SendAmount = "";
_pendingSend = null;
HasPendingSend = false;
await ConnectAndSync();
}
catch (Exception ex)
{
SendPreview = $"Errore broadcast: {ex.Message}";
}
}
[RelayCommand]
private void CloseWallet()
{
_ = _client?.DisposeAsync().AsTask();
_client = null;
_synchronizer = null;
_autoReconnect = false;
_resyncRequested = false;
_doc = null;
_account = null;
_lastTransactions = null;
_pendingSend = null;
HasPendingSend = false;
History.Clear();
IsWalletOpen = false;
IsConnected = false;
ConnectionStatus = Loc.Tr("conn.none");
RefreshSetupState();
}
private (string Host, int Port) ParseServer()
{
var parts = ServerInput.Trim().Split(':');
var host = parts[0];
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
? p
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
return (host, port);
}
}
+7
View File
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PalladiumWallet.App.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
+302
View File
@@ -0,0 +1,302 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PalladiumWallet.App.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
x:Class="PalladiumWallet.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Width="900" Height="620"
Title="Palladium Wallet">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto">
<!-- ============ MENU (stile Electrum) ============ -->
<Menu Grid.Row="0">
<MenuItem Header="{Binding Loc[menu.file]}">
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"/>
<Separator/>
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
IsEnabled="{Binding IsWalletOpen}"/>
</MenuItem>
<MenuItem Header="{Binding Loc[menu.net]}">
<MenuItem Header="{Binding Loc[menu.net.discover]}" Command="{Binding DiscoverServersCommand}"/>
<MenuItem Header="{Binding Loc[menu.net.resetcerts]}" Command="{Binding ResetCertificatesCommand}"/>
</MenuItem>
<MenuItem Header="{Binding Loc[menu.settings]}">
<MenuItem Header="{Binding Loc[settings.language]}">
<MenuItem Header="Italiano" ToggleType="Radio"
IsChecked="{Binding IsLangIt, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
<MenuItem Header="English" ToggleType="Radio"
IsChecked="{Binding IsLangEn, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
</MenuItem>
<MenuItem Header="{Binding Loc[settings.unit.short]}">
<MenuItem Header="PLM" ToggleType="Radio"
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
<MenuItem Header="mPLM" ToggleType="Radio"
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
<MenuItem Header="µPLM" ToggleType="Radio"
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
<MenuItem Header="sat" ToggleType="Radio"
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
</MenuItem>
</MenuItem>
</Menu>
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
HorizontalAlignment="Center">
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/>
<!-- Passo 1: scelta iniziale -->
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<TextBlock Text="{Binding Loc[wiz.net]}" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding Networks}"
SelectedItem="{Binding SelectedNetwork}" MinWidth="140"/>
</StackPanel>
<Button Content="{Binding Loc[wiz.open.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
IsVisible="{Binding WalletFileExists}"
Command="{Binding WizardStartOpenCommand}"/>
<Button Content="{Binding Loc[wiz.new.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartNewCommand}"/>
<Button Content="{Binding Loc[wiz.restore.btn]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding WizardStartRestoreCommand}"/>
</StackPanel>
<!-- Passo: password del wallet esistente -->
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.open.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.open.placeholder]}"
PasswordChar="●" Text="{Binding PasswordInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.open.ok]}" Classes="accent"
Command="{Binding OpenExistingCommand}"/>
</StackPanel>
</StackPanel>
<!-- Passo: mostra il nuovo seed -->
<StackPanel IsVisible="{Binding IsStepShowSeed}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.seed.title]}" FontSize="18" FontWeight="Bold"/>
<Border BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
CornerRadius="6" Padding="14">
<SelectableTextBlock Text="{Binding MnemonicInput}"
FontFamily="monospace" FontSize="16" TextWrapping="Wrap"/>
</Border>
<TextBlock Foreground="Orange" TextWrapping="Wrap"
Text="{Binding Loc[wiz.seed.warning]}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.seed.next]}" Classes="accent"
Command="{Binding WizardNextFromShowSeedCommand}"/>
</StackPanel>
</StackPanel>
<!-- Passo: conferma del seed -->
<StackPanel IsVisible="{Binding IsStepConfirmSeed}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.confirm.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.confirm.placeholder]}"
AcceptsReturn="False" Text="{Binding ConfirmMnemonicInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromConfirmSeedCommand}"/>
</StackPanel>
</StackPanel>
<!-- Passo: inserimento seed (ripristino) -->
<StackPanel IsVisible="{Binding IsStepWords}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.words.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.words.placeholder]}"
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromWordsCommand}"/>
</StackPanel>
</StackPanel>
<!-- Passo: passphrase opzionale -->
<StackPanel IsVisible="{Binding IsStepPassphrase}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.passphrase.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.passphrase.placeholder]}"
Text="{Binding PassphraseInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
Command="{Binding WizardNextFromPassphraseCommand}"/>
</StackPanel>
</StackPanel>
<!-- Passo finale: password del file -->
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
PasswordChar="●" Text="{Binding PasswordInput}"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
Command="{Binding CreateOrRestoreCommand}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ PANNELLO WALLET ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
<!-- Testata: saldo + rete + chiudi -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
</StackPanel>
<Button Grid.Column="1" Content="{Binding Loc[wallet.close]}" VerticalAlignment="Top"
Command="{Binding CloseWalletCommand}"/>
</Grid>
<!-- Server -->
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[wallet.server]}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox Grid.Row="0" Grid.Column="1"
ItemsSource="{Binding KnownServers}"
SelectedItem="{Binding SelectedKnownServer}"
HorizontalAlignment="Stretch"/>
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
IsChecked="{Binding UseSsl}" Margin="8,0"/>
<Button Grid.Row="0" Grid.Column="3" Content="{Binding Loc[wallet.connect]}"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
Spacing="8" Margin="0,8,0,0">
<TextBox Text="{Binding ServerInput}" MinWidth="220"
PlaceholderText="{Binding Loc[wallet.manual]}"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
Orientation="Horizontal" Spacing="6" Margin="8,8,0,0"
VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="LimeGreen"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"/>
</StackPanel>
</Grid>
</Border>
<!-- Tab: Ricevi / Storico / Indirizzi / Invia -->
<TabControl Grid.Row="2">
<TabItem Header="{Binding Loc[tab.receive]}">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="{Binding Loc[receive.next]}"/>
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"/>
<TextBlock Text="{Binding Loc[receive.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<TabItem Header="{Binding Loc[tab.history]}">
<ListBox ItemsSource="{Binding History}" Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="{Binding Loc[tab.addresses]}">
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AddressRow">
<Grid ColumnDefinitions="90,60,*,140,60">
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Indirizzo}"
FontFamily="monospace" FontSize="13"/>
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<TabItem Header="{Binding Loc[tab.send]}">
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
FontFamily="monospace"/>
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
VerticalAlignment="Center" Margin="6,0" Foreground="Gray"/>
<CheckBox Grid.Column="2" Content="{Binding Loc[send.all]}" Margin="10,0"
IsChecked="{Binding SendAll}"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center"/>
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[send.prepare]}" Command="{Binding PrepareSendCommand}"/>
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
Command="{Binding ConfirmSendCommand}"
IsEnabled="{Binding HasPendingSend}"/>
</StackPanel>
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
FontSize="13"/>
</StackPanel>
</TabItem>
</TabControl>
</Grid>
<!-- Barra di stato -->
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Padding="10,6">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/>
</Border>
</Grid>
</Window>
+35
View File
@@ -0,0 +1,35 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
return;
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Apri file wallet",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
],
});
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.OpenFromPath(path);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="PalladiumWallet.App.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+350
View File
@@ -0,0 +1,350 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
// CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di
// comando, per scripting, test e confronto col wallet di riferimento.
try
{
return args switch
{
["newseed", .. var rest] => NewSeed(rest),
["addresses", var words, .. var rest] => Addresses(words, rest),
["create", .. var rest] => Create(rest),
["restore", var words, .. var rest] => Restore(words, rest),
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
["info", .. var rest] => Info(rest),
["sync", .. var rest] => await Sync(rest),
["send", .. var rest] => await Send(rest),
["reset-certs", .. var rest] => ResetCerts(rest),
["servers", .. var rest] => await Servers(rest),
_ => Usage(),
};
}
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
{
Console.Error.WriteLine($"Errore: {ex.Message}");
return 1;
}
static int NewSeed(string[] o)
{
var length = Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve;
Console.WriteLine(Bip39.Generate(length));
return 0;
}
static int Addresses(string words, string[] o)
{
var account = AccountFromWords(words, o);
if (account is null)
return 1;
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}");
Console.WriteLine($"account: {account.ToSlip132()}");
for (var i = 0; i < count; i++)
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
for (var i = 0; i < Math.Min(count, 2); i++)
Console.WriteLine($" change/{i}: {account.GetChangeAddress(i)}");
return 0;
}
static int Create(string[] o)
{
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):");
Console.WriteLine($" {mnemonic}");
return SaveWallet(mnemonic.ToString(), o);
}
static int Restore(string words, string[] o)
{
if (!Bip39.TryParse(words, out _))
{
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
return 1;
}
return SaveWallet(words.Trim(), o);
}
static int RestoreXpub(string xpubText, string[] o)
{
var profile = Profile(o);
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
{
Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete.");
return 1;
}
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
var doc = new WalletDocument
{
Network = profile.NetName,
ScriptKind = kind.ToString(),
AccountPath = account.AccountPath.ToString(),
AccountXpub = account.ToSlip132(),
};
var path = WalletPath(o, profile);
WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Wallet watch-only salvato in {path}");
return 0;
}
static int Info(string[] o)
{
var (doc, account, path) = OpenWallet(o);
Console.WriteLine($"file: {path}");
Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
Console.WriteLine($"path: m/{doc.AccountPath}");
Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache)
{
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
}
else
{
Console.WriteLine($"ricezione: {account.GetReceiveAddress(0)} (mai sincronizzato)");
}
if (o.Contains("--addresses") && doc.Cache is { } c)
{
Console.WriteLine("indirizzi:");
foreach (var a in c.Addresses)
Console.WriteLine($" {(a.IsChange ? "change " : "ricev. ")}{a.Index,3} {a.Address} " +
$"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)");
}
return 0;
}
static async Task<int> Sync(string[] o)
{
var (doc, account, path) = OpenWallet(o);
await using var client = await Connect(o, account.Profile);
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
sync.Progress += msg => Console.WriteLine($" {msg}");
var result = await sync.SyncOnceAsync();
doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
};
WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
Console.WriteLine($"Storico ({result.History.Count}):");
foreach (var tx in result.History)
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
(tx.Verified ? "" : " (non verificata)"));
return 0;
}
static async Task<int> Send(string[] o)
{
var (doc, account, path) = OpenWallet(o);
if (doc.Cache is null)
{
Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'.");
return 1;
}
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to mancante.");
var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind));
var sendAll = o.Contains("--all");
long amount = 0;
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
throw new WalletSpendException("--amount mancante o non valido (oppure usa --all).");
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
// Tx di provenienza degli UTXO: servono per importi e firma.
await using var client = await Connect(o, account.Profile);
var network = PalladiumNetworks.For(account.Profile.Kind);
var transactions = new Dictionary<string, Transaction>();
foreach (var txid in doc.Cache.Utxos.Select(u => u.Txid).Distinct())
transactions[txid] = Transaction.Parse(await client.GetTransactionAsync(txid), network);
var built = new TransactionFactory(account).Build(
doc.Cache.Utxos, transactions, destination, amount, feeRate,
doc.Cache.NextChangeIndex, sendAll);
Console.WriteLine($"txid: {built.Txid}");
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
$"({feeRate} sat/vB, {built.Transaction.GetVirtualSize()} vB)");
if (!built.Signed)
{
Console.WriteLine("Wallet watch-only: PSBT da firmare offline (§6.5):");
Console.WriteLine(built.Psbt.ToBase64());
return 0;
}
if (!o.Contains("--broadcast"))
{
Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):");
Console.WriteLine(built.ToHex());
return 0;
}
var txid2 = await client.BroadcastAsync(built.ToHex());
Console.WriteLine($"Trasmessa: {txid2}");
return 0;
}
static int ResetCerts(string[] o)
{
var profile = Profile(o);
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati.");
return 0;
}
static async Task<int> Servers(string[] o)
{
var profile = Profile(o);
var registry = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind));
if (o.Contains("--discover"))
{
await using var client = await Connect(o, profile);
var added = await registry.DiscoverAsync(client);
Console.WriteLine($"Scoperti {added} nuovi server dai peer.");
}
Console.WriteLine($"Server noti ({profile.NetName}):");
foreach (var server in registry.All)
Console.WriteLine($" {server}");
return 0;
}
// ----- helper comuni -----
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
{
"testnet" => ChainProfiles.Testnet,
"regtest" => ChainProfiles.Regtest,
_ => ChainProfiles.Mainnet,
};
static ScriptKind Kind(string[] o) => Opt(o, "--kind") switch
{
"legacy" => ScriptKind.Legacy,
"wrapped" => ScriptKind.WrappedSegwit,
_ => ScriptKind.NativeSegwit,
};
static string WalletPath(string[] o, ChainProfile profile) =>
Opt(o, "--file") ?? AppPaths.DefaultWalletPath(profile.Kind);
static HdAccount? AccountFromWords(string words, string[] o)
{
if (!Bip39.TryParse(words, out var mnemonic))
{
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
return null;
}
var profile = Profile(o);
var kind = Kind(o);
var passphrase = Opt(o, "--passphrase");
return Opt(o, "--path") is { } path
? HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, passphrase), kind, profile, KeyPath.Parse(path))
: HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile);
}
static int SaveWallet(string words, string[] o)
{
var profile = Profile(o);
var (doc, account) = WalletLoader.NewFromMnemonic(
words, Opt(o, "--passphrase"), Kind(o), profile,
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
var password = Opt(o, "--password");
if (string.IsNullOrEmpty(password))
Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17).");
var path = WalletPath(o, profile);
WalletStore.Save(doc, path, password);
Console.WriteLine($"Wallet salvato in {path}");
Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}");
return 0;
}
static (WalletDocument, HdAccount, string) OpenWallet(string[] o)
{
var path = WalletPath(o, Profile(o));
if (!WalletStore.Exists(path))
throw new WalletSpendException($"Nessun wallet in {path}: usa 'create' o 'restore'.");
var doc = WalletStore.Load(path, Opt(o, "--password"));
return (doc, WalletLoader.ToAccount(doc), path);
}
static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
{
var useSsl = o.Contains("--ssl");
string host;
int port;
if (Opt(o, "--server") is { } server)
{
var parts = server.Split(':');
host = parts[0];
port = parts.Length > 1 ? int.Parse(parts[1])
: useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort;
}
else
{
// Senza --server si usa il primo server noto (bootstrap §3 o scoperto §9).
var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default
?? throw new WalletSpendException("Nessun server noto per questa rete: usa --server host:porta.");
host = known.Host;
port = known.PortFor(useSsl);
}
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
Console.WriteLine($"Connessione a {host}:{port}{(useSsl ? " (TLS)" : "")}…");
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
}
static string? Opt(string[] options, string name)
{
var i = Array.IndexOf(options, name);
return i >= 0 && i + 1 < options.Length ? options[i + 1] : null;
}
static int Usage()
{
Console.WriteLine("""
PalladiumWallet CLI
Wallet:
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
[--passphrase W] [--password P] [--file PATH]
restore "<mnemonica>" [stesse opzioni di create] [--path m/...]
restore-xpub <xpub slip132> [--net ...] [--password P] [--file PATH] (watch-only)
info [--net ...] [--password P] [--file PATH]
Rete (server di indicizzazione; senza --server usa il primo server noto):
sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH]
send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB]
[--server host[:porta]] [--ssl] [--broadcast] [...]
servers [--discover] [--server host[:porta]] [--ssl] [--net ...]
reset-certs [--net ...]
Strumenti:
newseed [--words 12|24]
addresses "<mnemonica>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
""");
return 1;
}
+9 -2
View File
@@ -34,8 +34,15 @@ public static class ChainProfiles
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub [ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub [ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
}, },
// TODO: popolare con i server reali prima della release (host:tcp:ssl). // Server di indicizzazione noti per il primo contatto (§3/§9); altri
BootstrapServers = [], // peer vengono scoperti via server.peers.subscribe.
BootstrapServers =
[
new ServerEndpoint("173.212.224.67", 50001, 50002),
new ServerEndpoint("144.91.120.225", 50001, 50002),
new ServerEndpoint("66.94.115.80", 50001, 50002),
new ServerEndpoint("89.117.149.130", 50001, 50002),
],
// TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release. // TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release.
Checkpoints = [], Checkpoints = [],
}; };
+49
View File
@@ -14,6 +14,9 @@ public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary> /// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
public readonly record struct ChainTip(int Height, string HeaderHex); public readonly record struct ChainTip(int Height, string HeaderHex);
/// <summary>Peer annunciato da server.peers.subscribe (porte null = non offerte).</summary>
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
/// <summary> /// <summary>
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il /// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>. /// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
@@ -108,4 +111,50 @@ public static class ElectrumApi
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) => public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
c.RequestAsync("server.ping", ct); c.RequestAsync("server.ping", ct);
/// <summary>Peer annunciati dal server (scoperta di altri server, §9).</summary>
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
CancellationToken ct = default)
{
var r = await c.RequestAsync("server.peers.subscribe", ct);
return ParsePeers(r);
}
/// <summary>
/// Parsa la risposta di server.peers.subscribe: lista di
/// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]];
/// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante).
/// </summary>
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
{
var peers = new List<PeerInfo>();
foreach (var entry in response.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3)
continue;
var host = entry[1].GetString();
if (string.IsNullOrWhiteSpace(host))
host = entry[0].GetString();
if (string.IsNullOrWhiteSpace(host))
continue;
int? tcp = null, ssl = null;
string? version = null;
foreach (var feature in entry[2].EnumerateArray())
{
var f = feature.GetString();
if (string.IsNullOrEmpty(f))
continue;
switch (f[0])
{
case 'v': version = f[1..]; break;
case 't': tcp = int.TryParse(f[1..], out var t) ? t : 0; break;
case 's': ssl = int.TryParse(f[1..], out var s) ? s : 0; break;
}
}
if (tcp is not null || ssl is not null)
peers.Add(new PeerInfo(host!, tcp, ssl, version));
}
return peers;
}
} }
+106
View File
@@ -0,0 +1,106 @@
using System.Text.Json;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Net;
/// <summary>Server di indicizzazione noto (bootstrap o scoperto dai peer).</summary>
public sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null)
{
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
public override string ToString() =>
$"{Host} · tcp {TcpPort} / ssl {SslPort}" + (Version is null ? "" : $" · v{Version}");
}
/// <summary>
/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3),
/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste
/// le scoperte su file per le sessioni successive.
/// </summary>
public sealed class ServerRegistry
{
private readonly ChainProfile _profile;
private readonly string _filePath;
private readonly List<KnownServer> _servers = [];
private readonly object _lock = new();
public ServerRegistry(ChainProfile profile, string filePath)
{
_profile = profile;
_filePath = filePath;
foreach (var s in profile.BootstrapServers)
_servers.Add(new KnownServer(s.Host, s.TcpPort, s.SslPort));
if (File.Exists(filePath))
{
try
{
var saved = JsonSerializer.Deserialize<List<KnownServer>>(File.ReadAllText(filePath)) ?? [];
foreach (var s in saved)
AddIfNew(s);
}
catch (JsonException)
{
// File corrotto: si riparte dai soli bootstrap.
}
}
}
public IReadOnlyList<KnownServer> All
{
get { lock (_lock) return [.. _servers]; }
}
/// <summary>Primo server noto: default quando l'utente non ne indica uno.</summary>
public KnownServer? Default
{
get { lock (_lock) return _servers.FirstOrDefault(); }
}
/// <summary>
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
/// </summary>
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
{
var peers = await client.GetPeersAsync(ct);
var added = 0;
lock (_lock)
{
foreach (var peer in peers)
{
var server = new KnownServer(
peer.Host,
peer.TcpPort is > 0 ? peer.TcpPort.Value : _profile.DefaultTcpPort,
peer.SslPort is > 0 ? peer.SslPort.Value : _profile.DefaultSslPort,
peer.Version);
if (AddIfNew(server))
added++;
}
if (added > 0)
Save();
}
return added;
}
private bool AddIfNew(KnownServer server)
{
if (_servers.Any(s => string.Equals(s.Host, server.Host, StringComparison.OrdinalIgnoreCase)))
return false;
_servers.Add(server);
return true;
}
private void Save()
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
// Si salvano solo gli scoperti: i bootstrap vengono dal profilo.
var discovered = _servers
.Where(s => !_profile.BootstrapServers.Any(b =>
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
.ToList();
File.WriteAllText(_filePath, JsonSerializer.Serialize(discovered,
new JsonSerializerOptions { WriteIndented = true }));
}
}
+88 -38
View File
@@ -24,6 +24,7 @@ public sealed class SyncResult
public required IReadOnlyList<CachedTx> History { get; init; } public required IReadOnlyList<CachedTx> History { get; init; }
public required IReadOnlyList<CachedUtxo> Utxos { get; init; } public required IReadOnlyList<CachedUtxo> Utxos { get; init; }
public required IReadOnlyList<TrackedAddress> Addresses { get; init; } public required IReadOnlyList<TrackedAddress> Addresses { get; init; }
public required IReadOnlyList<CachedAddress> AddressRows { get; init; }
public required IReadOnlyDictionary<string, Transaction> Transactions { get; init; } public required IReadOnlyDictionary<string, Transaction> Transactions { get; init; }
} }
@@ -39,6 +40,13 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary> /// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
public event Action<string>? Progress; public event Action<string>? Progress;
// Cache tra le passate (stesso synchronizer per tutta la vita della
// connessione): le tx già scaricate e le prove di Merkle già verificate a
// una data altezza non si rifanno — le risincronizzazioni da notifica
// costano solo ciò che è cambiato (modello Electrum).
private readonly Dictionary<string, Transaction> _txCache = [];
private readonly Dictionary<string, int> _verifiedAtHeight = [];
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default) public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
{ {
var tip = await client.SubscribeHeadersAsync(ct); var tip = await client.SubscribeHeadersAsync(ct);
@@ -55,37 +63,45 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
foreach (var item in historyByAddress.Values.SelectMany(h => h)) foreach (var item in historyByAddress.Values.SelectMany(h => h))
txHeights[item.TxHash] = item.Height; txHeights[item.TxHash] = item.Height;
// 4. Scarica le transazioni. // 4. Scarica in parallelo le sole transazioni nuove.
Progress?.Invoke($"scarico {txHeights.Count} transazioni…");
var network = PalladiumNetworks.For(account.Profile.Kind); var network = PalladiumNetworks.For(account.Profile.Kind);
var transactions = new Dictionary<string, Transaction>(); var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
foreach (var txid in txHeights.Keys) if (missing.Count > 0)
{ {
var hex = await client.GetTransactionAsync(txid, ct); Progress?.Invoke($"scarico {missing.Count} transazioni…");
transactions[txid] = Transaction.Parse(hex, network); var downloaded = await Task.WhenAll(missing.Select(async txid =>
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
foreach (var (txid, tx) in downloaded)
_txCache[txid] = tx;
} }
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
// 5. Verifica Merkle delle confermate (§7.4 punto 4). // 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
var verified = new Dictionary<string, bool>(); // solo per le tx non ancora verificate a quell'altezza.
foreach (var (txid, height) in txHeights) var toVerify = txHeights
.Where(kv => kv.Value > 0
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
.ToList();
if (toVerify.Count > 0)
{ {
if (height <= 0) Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
await Task.WhenAll(toVerify.Select(async kv =>
{ {
verified[txid] = false; // in mempool: nessuna prova possibile var (txid, height) = kv;
continue; var proofTask = client.GetMerkleAsync(txid, height, ct);
} var headerTask = client.GetBlockHeaderAsync(height, ct);
var proof = await client.GetMerkleAsync(txid, height, ct); var proof = await proofTask;
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct)); var header = BlockHeaderInfo.Parse(await headerTask);
var ok = MerkleProof.Verify( if (!MerkleProof.Verify(
uint256.Parse(txid), uint256.Parse(txid), proof.Pos,
proof.Pos, proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
proof.Merkle.Select(uint256.Parse), throw new SpvVerificationException(
header.MerkleRoot); $"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
if (!ok) }));
throw new SpvVerificationException( foreach (var (txid, height) in toVerify)
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile."); _verifiedAtHeight[txid] = height;
verified[txid] = true;
} }
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri // 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
// script; spesi = outpoint consumati da una qualunque tx del wallet. // script; spesi = outpoint consumati da una qualunque tx del wallet.
@@ -145,6 +161,22 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
return hb.CompareTo(ha); return hb.CompareTo(ha);
}); });
// Saldo e numero di transazioni per singolo indirizzo (vista indirizzi).
var balanceByAddress = utxos
.GroupBy(u => u.Address)
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
var addressRows = tracked
.OrderBy(t => t.IsChange).ThenBy(t => t.Index)
.Select(t => new CachedAddress
{
Address = t.Address.ToString(),
IsChange = t.IsChange,
Index = t.Index,
BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()),
TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0,
})
.ToList();
return new SyncResult return new SyncResult
{ {
TipHeight = tip.Height, TipHeight = tip.Height,
@@ -155,13 +187,16 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
History = history, History = history,
Utxos = utxos, Utxos = utxos,
Addresses = tracked, Addresses = tracked,
AddressRows = addressRows,
Transactions = transactions, Transactions = transactions,
}; };
} }
/// <summary> /// <summary>
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi /// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
/// vuoti consecutivi (§5). Ritorna il primo indice non usato. /// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
/// Ritorna il primo indice non usato.
/// </summary> /// </summary>
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked, private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct) Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
@@ -169,23 +204,38 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
var consecutiveEmpty = 0; var consecutiveEmpty = 0;
var index = 0; var index = 0;
var firstUnused = 0; var firstUnused = 0;
for (; consecutiveEmpty < gapLimit; index++) while (consecutiveEmpty < gapLimit)
{ {
var address = account.GetAddress(isChange, index); var batch = Enumerable.Range(index, gapLimit).Select(i =>
var scripthash = Scripthash.FromAddress(address); {
tracked.Add(new TrackedAddress(address, scripthash, isChange, index)); var address = account.GetAddress(isChange, i);
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
}).ToList();
index += batch.Count;
tracked.AddRange(batch);
// La subscribe registra anche la notifica push per i cambi futuri. // La subscribe registra anche la notifica push per i cambi futuri.
var status = await client.SubscribeScripthashAsync(scripthash, ct); var statuses = await Task.WhenAll(
if (status is null) batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
{
consecutiveEmpty++;
continue;
}
historyByAddress[scripthash] = await client.GetHistoryAsync(scripthash, ct); var used = batch.Where((t, i) => statuses[i] is not null).ToList();
consecutiveEmpty = 0; var histories = await Task.WhenAll(
firstUnused = index + 1; 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++;
}
else
{
consecutiveEmpty = 0;
firstUnused = batch[i].Index + 1;
}
}
} }
return firstUnused; 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));
}
}
+52
View File
@@ -0,0 +1,52 @@
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
/// esiste una cartella "palladium-data".
/// </summary>
public static class AppPaths
{
public const string PortableDirName = "palladium-data";
public static string DataRoot()
{
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
if (Directory.Exists(portable))
return portable;
return OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet")
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
}
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
public static string ForNetwork(NetKind net)
{
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
Directory.CreateDirectory(dir);
return dir;
}
public static string WalletsDir(NetKind net)
{
var dir = Path.Combine(ForNetwork(net), "wallets");
Directory.CreateDirectory(dir);
return dir;
}
public static string DefaultWalletPath(NetKind net) =>
Path.Combine(WalletsDir(net), "default.wallet.json");
public static string CertificatePinsPath(NetKind net) =>
Path.Combine(ForNetwork(net), "server-certs.json");
public static string ServersPath(NetKind net) =>
Path.Combine(ForNetwork(net), "servers.json");
public static string ConfigPath() =>
Path.Combine(DataRoot(), "config.json");
}
+95
View File
@@ -0,0 +1,95 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
/// </summary>
public static class EncryptedFile
{
private const string Format = "plm-wallet-aesgcm-v1";
private const int DefaultIterations = 600_000;
private const int SaltSize = 16;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32;
private sealed record Container(
string Format, int Iterations, string Salt, string Nonce, string Tag, string Data);
public static bool IsEncrypted(string fileContent)
{
try
{
using var doc = JsonDocument.Parse(fileContent);
return doc.RootElement.TryGetProperty("Format", out var f)
&& f.GetString() == Format;
}
catch (JsonException)
{
return false;
}
}
public static string Encrypt(string plaintext, string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var key = DeriveKey(password, salt, DefaultIterations);
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipher = new byte[plainBytes.Length];
var tag = new byte[TagSize];
using (var aes = new AesGcm(key, TagSize))
aes.Encrypt(nonce, plainBytes, cipher, tag);
CryptographicOperations.ZeroMemory(key);
return JsonSerializer.Serialize(new Container(
Format, DefaultIterations,
Convert.ToBase64String(salt), Convert.ToBase64String(nonce),
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
}
/// <summary>Lancia <see cref="WrongPasswordException"/> se la password è errata o il file è manomesso.</summary>
public static string Decrypt(string fileContent, string password)
{
var container = JsonSerializer.Deserialize<Container>(fileContent)
?? throw new InvalidDataException("Contenitore cifrato non valido.");
if (container.Format != Format)
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
var cipher = Convert.FromBase64String(container.Data);
var plain = new byte[cipher.Length];
try
{
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(
Convert.FromBase64String(container.Nonce), cipher,
Convert.FromBase64String(container.Tag), plain);
}
catch (AuthenticationTagMismatchException)
{
// Il tag GCM autentica: password errata e manomissione sono indistinguibili.
throw new WrongPasswordException();
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
return Encoding.UTF8.GetString(plain);
}
private static byte[] DeriveKey(string password, byte[] salt, int iterations) =>
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
}
/// <summary>Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).</summary>
public sealed class WrongPasswordException : Exception
{
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
}
+113
View File
@@ -0,0 +1,113 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
/// l'intero documento via <see cref="EncryptedFile"/>.
/// </summary>
public sealed class WalletDocument
{
public const int CurrentVersion = 1;
public int Version { get; set; } = CurrentVersion;
/// <summary>Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.</summary>
public required string Network { get; set; }
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
public required string ScriptKind { get; set; }
/// <summary>Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.</summary>
public string? Mnemonic { get; set; }
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
public string? Passphrase { get; set; }
/// <summary>Path di account (es. "84'/746'/0'").</summary>
public required string AccountPath { get; set; }
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
public required string AccountXpub { get; set; }
public string? MasterFingerprint { get; set; }
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
public int GapLimit { get; set; } = 20;
/// <summary>Etichette per indirizzo/txid (§12).</summary>
public Dictionary<string, string> Labels { get; set; } = [];
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
public SyncCache? Cache { get; set; }
public bool IsWatchOnly => Mnemonic is null;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
public static WalletDocument FromJson(string json)
{
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
?? throw new InvalidDataException("File wallet non valido.");
return Migrate(doc);
}
/// <summary>Migrazioni di schema all'apertura (§8). Per ora esiste solo la v1.</summary>
private static WalletDocument Migrate(WalletDocument doc) => doc.Version switch
{
CurrentVersion => doc,
_ => throw new InvalidDataException(
$"Versione file wallet {doc.Version} non supportata (max {CurrentVersion})."),
};
}
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
public sealed class SyncCache
{
public int TipHeight { get; set; }
public long ConfirmedSats { get; set; }
public long UnconfirmedSats { get; set; }
public int NextReceiveIndex { get; set; }
public int NextChangeIndex { get; set; }
public List<CachedTx> History { get; set; } = [];
public List<CachedUtxo> Utxos { get; set; } = [];
public List<CachedAddress> Addresses { get; set; } = [];
}
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
public sealed class CachedAddress
{
public required string Address { get; set; }
public bool IsChange { get; set; }
public int Index { get; set; }
public long BalanceSats { get; set; }
public int TxCount { get; set; }
}
public sealed class CachedTx
{
public required string Txid { get; set; }
public int Height { get; set; }
public long DeltaSats { get; set; }
public bool Verified { get; set; }
}
public sealed class CachedUtxo
{
public required string Txid { get; set; }
public int Vout { get; set; }
public long ValueSats { get; set; }
public required string Address { get; set; }
public bool IsChange { get; set; }
public int AddressIndex { get; set; }
public int Height { get; set; }
public bool Frozen { get; set; }
}
+38
View File
@@ -0,0 +1,38 @@
namespace PalladiumWallet.Core.Storage;
/// <summary>
/// Lettura/scrittura del file wallet su disco (blueprint §8): JSON in chiaro
/// senza password, contenitore AES-GCM con password. Scrittura atomica
/// (file temporaneo + rename) per non corrompere il wallet su crash.
/// </summary>
public static class WalletStore
{
public static bool Exists(string path) => File.Exists(path);
/// <summary>True se il file richiede una password per l'apertura.</summary>
public static bool RequiresPassword(string path) =>
EncryptedFile.IsEncrypted(File.ReadAllText(path));
public static WalletDocument Load(string path, string? password = null)
{
var content = File.ReadAllText(path);
if (EncryptedFile.IsEncrypted(content))
{
if (string.IsNullOrEmpty(password))
throw new WrongPasswordException();
content = EncryptedFile.Decrypt(content, password);
}
return WalletDocument.FromJson(content);
}
public static void Save(WalletDocument doc, string path, string? password = null)
{
var content = doc.ToJson();
if (!string.IsNullOrEmpty(password))
content = EncryptedFile.Encrypt(content, password);
var tmp = path + ".tmp";
File.WriteAllText(tmp, content);
File.Move(tmp, path, overwrite: true);
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.Globalization;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input.
/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione.
/// </summary>
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)
{
sats = 0;
text = text.Trim().Replace(',', '.');
if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var coins)
|| coins < 0)
return false;
try
{
sats = (long)(coins * SatsPerCoin);
}
catch (OverflowException)
{
return false;
}
return true;
}
}
+126
View File
@@ -0,0 +1,126 @@
using NBitcoin;
using NBitcoin.Policy;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>Esito della costruzione di una transazione.</summary>
public sealed class BuiltTransaction
{
public required Transaction Transaction { get; init; }
public required Money Fee { get; init; }
public required FeeRate FeeRate { get; init; }
public required bool Signed { get; init; }
/// <summary>PSBT per i flussi watch-only/air-gapped/multisig (§6.5).</summary>
public required PSBT Psbt { get; init; }
public string ToHex() => Transaction.ToHex();
public string Txid => Transaction.GetHash().ToString();
}
/// <summary>
/// Costruzione e firma delle transazioni (blueprint §6) sopra le primitive
/// NBitcoin: selezione monete (manuale o automatica), fee a rate fisso,
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
/// Con un account watch-only produce la PSBT non firmata (§6.5).
/// </summary>
public sealed class TransactionFactory(HdAccount account)
{
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary>
/// Costruisce (e se possibile firma) una transazione.
/// </summary>
/// <param name="utxos">UTXO selezionati (coin control §6.2) o tutti quelli spendibili.</param>
/// <param name="transactions">Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione.</param>
/// <param name="destination">Indirizzo destinatario.</param>
/// <param name="amountSats">Importo; ignorato se <paramref name="sendAll"/>.</param>
/// <param name="feeRateSatPerVByte">Fee rate fisso in sat/vByte (§6.4).</param>
/// <param name="changeIndex">Indice del prossimo indirizzo di change (catena interna).</param>
/// <param name="sendAll">Invia tutto: fee sottratta dall'importo (§6.1).</param>
public BuiltTransaction Build(
IReadOnlyList<CachedUtxo> utxos,
IReadOnlyDictionary<string, Transaction> transactions,
BitcoinAddress destination,
long amountSats,
decimal feeRateSatPerVByte,
int changeIndex,
bool sendAll = false)
{
// Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
// saldo "in attesa" ma non sono spendibili finché non confermano.
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
if (spendable.Count == 0)
{
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
throw new WalletSpendException(pending > 0
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
: "Nessun UTXO spendibile selezionato.");
}
var coins = spendable.Select(u => new Coin(
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
transactions[u.Txid].Outputs[u.Vout])).ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
var builder = Network.CreateTransactionBuilder();
builder.SetVersion(2);
// Sequence RBF per consentire il bump della fee (§6.6).
builder.OptInRBF = true;
builder.AddCoins(coins);
builder.SetChange(account.GetChangeAddress(changeIndex));
builder.SendEstimatedFees(feeRate);
if (sendAll)
builder.Send(destination, coins.Sum(c => (Money)c.Amount)).SubtractFees();
else
builder.Send(destination, Money.Satoshis(amountSats));
if (!account.IsWatchOnly)
{
builder.AddKeys(spendable
.Select(u => account.GetExtPrivateKey(u.IsChange, u.AddressIndex))
.ToArray());
}
Transaction tx;
try
{
tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
}
catch (NotEnoughFundsException ex)
{
throw new WalletSpendException($"Fondi insufficienti: {ex.Message}");
}
if (!account.IsWatchOnly)
{
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
throw new WalletSpendException(
"Transazione non valida: " + string.Join("; ", errors.Select(e => e.ToString())));
}
return new BuiltTransaction
{
Transaction = tx,
Fee = GetFee(tx, coins),
FeeRate = feeRate,
Signed = !account.IsWatchOnly,
Psbt = builder.BuildPSBT(sign: !account.IsWatchOnly),
};
}
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
{
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
var inputSum = coins.Where(c => spentOutpoints.Contains(c.Outpoint))
.Sum(c => (Money)c.Amount);
return inputSum - tx.Outputs.Sum(o => o.Value);
}
}
/// <summary>Errore di costruzione/firma della spesa (fondi, policy, parametri).</summary>
public sealed class WalletSpendException(string message) : Exception(message);
+59
View File
@@ -0,0 +1,59 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Core.Wallet;
/// <summary>
/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce
/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della
/// factory dei tipi di wallet; crescerà con multisig e importati.
/// </summary>
public static class WalletLoader
{
public static ChainProfile ProfileOf(WalletDocument doc) =>
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
public static HdAccount ToAccount(WalletDocument doc)
{
var profile = ProfileOf(doc);
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
var path = KeyPath.Parse(doc.AccountPath);
if (doc.Mnemonic is { } words)
{
if (!Bip39.TryParse(words, out var mnemonic))
throw new InvalidDataException("Mnemonica del file wallet non valida.");
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
}
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
return HdAccount.FromAccountXpub(xpub!, kind, profile, path);
}
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic(
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
{
if (!Bip39.TryParse(words, out var mnemonic))
throw new InvalidDataException("Mnemonica non valida (parole o checksum errati).");
var account = customPath is null
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
: HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, passphrase), kind, profile, customPath);
var doc = new WalletDocument
{
Network = profile.NetName,
ScriptKind = kind.ToString(),
Mnemonic = words.Trim(),
Passphrase = passphrase,
AccountPath = account.AccountPath.ToString(),
AccountXpub = account.ToSlip132(),
MasterFingerprint = Convert.ToHexString(account.MasterFingerprint.ToBytes()).ToLowerInvariant(),
};
return (doc, account);
}
}
@@ -0,0 +1,83 @@
using NBitcoin;
using NBitcoin.DataEncoders;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Tests.Chain;
public class ChainProfileTests
{
[Fact]
public void Mainnet_ha_le_costanti_del_blueprint()
{
var p = ChainProfiles.Mainnet;
Assert.Equal("PLM", p.CoinUnit);
Assert.Equal(0x80, p.WifPrefix);
Assert.Equal(55, p.AddrP2pkh);
Assert.Equal(5, p.AddrP2sh);
Assert.Equal("plm", p.SegwitHrp);
Assert.Equal("000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", p.GenesisHash);
Assert.Equal(50001, p.DefaultTcpPort);
Assert.Equal(50002, p.DefaultSslPort);
Assert.Equal(746, p.Bip44CoinType);
Assert.Equal("palladium", p.UriScheme);
Assert.True(p.SkipPowValidation); // obbligatorio: la catena usa LWMA (§3)
Assert.Equal(120, p.BlockTimeSeconds);
}
[Fact]
public void Testnet_e_regtest_hanno_i_valori_del_blueprint()
{
var t = ChainProfiles.Testnet;
Assert.Equal(0xff, t.WifPrefix);
Assert.Equal(127, t.AddrP2pkh);
Assert.Equal(115, t.AddrP2sh);
Assert.Equal("tplm", t.SegwitHrp);
Assert.Equal(1, t.Bip44CoinType);
var r = ChainProfiles.Regtest;
Assert.Equal("rplm", r.SegwitHrp);
}
[Fact]
public void Selettore_di_rete_restituisce_il_profilo_giusto()
{
Assert.Same(ChainProfiles.Mainnet, ChainProfiles.For(NetKind.Mainnet));
Assert.Same(ChainProfiles.Testnet, ChainProfiles.For(NetKind.Testnet));
Assert.Same(ChainProfiles.Regtest, ChainProfiles.For(NetKind.Regtest));
}
[Theory]
[InlineData(ScriptKind.Legacy, "xprv", "xpub")]
[InlineData(ScriptKind.WrappedSegwit, "yprv", "ypub")]
[InlineData(ScriptKind.WrappedSegwitMultisig, "Yprv", "Ypub")]
[InlineData(ScriptKind.NativeSegwit, "zprv", "zpub")]
[InlineData(ScriptKind.NativeSegwitMultisig, "Zprv", "Zpub")]
public void Header_estesi_mainnet_producono_i_prefissi_slip132_attesi(
ScriptKind kind, string privPrefix, string pubPrefix)
{
var headers = ChainProfiles.Mainnet.ExtKeyHeaders[kind];
Assert.StartsWith(privPrefix, EncodeWithHeader(headers.Private));
Assert.StartsWith(pubPrefix, EncodeWithHeader(headers.Public));
}
[Fact]
public void Header_estesi_testnet_producono_tprv_tpub()
{
var headers = ChainProfiles.Testnet.ExtKeyHeaders[ScriptKind.Legacy];
Assert.StartsWith("tprv", EncodeWithHeader(headers.Private));
Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
}
// Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check:
// il prefisso testuale risultante dipende solo dall'header.
private static string EncodeWithHeader(uint header)
{
var data = new byte[78];
data[0] = (byte)(header >> 24);
data[1] = (byte)(header >> 16);
data[2] = (byte)(header >> 8);
data[3] = (byte)header;
return Encoders.Base58Check.EncodeData(data);
}
}
@@ -0,0 +1,78 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Tests.Chain;
public class PalladiumNetworksTests
{
// Chiave privata fissa per test deterministici (solo test, mai usarla davvero).
private static Key TestKey => new(Convert.FromHexString(
"0000000000000000000000000000000000000000000000000000000000000001"));
[Theory]
[InlineData(NetKind.Mainnet)]
[InlineData(NetKind.Testnet)]
[InlineData(NetKind.Regtest)]
public void La_genesi_della_rete_corrisponde_al_profilo(NetKind kind)
{
var network = PalladiumNetworks.For(kind);
var profile = ChainProfiles.For(kind);
Assert.Equal(profile.GenesisHash, network.GetGenesis().GetHash().ToString());
}
[Fact]
public void Indirizzo_p2pkh_mainnet_inizia_con_P()
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Legacy, PalladiumNetworks.Mainnet);
Assert.StartsWith("P", addr.ToString());
}
[Fact]
public void Indirizzo_native_segwit_mainnet_inizia_con_plm1()
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, PalladiumNetworks.Mainnet);
Assert.StartsWith("plm1", addr.ToString());
}
[Fact]
public void Indirizzo_segwit_wrapped_mainnet_inizia_con_3()
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, PalladiumNetworks.Mainnet);
Assert.StartsWith("3", addr.ToString());
}
[Theory]
[InlineData(NetKind.Testnet, "tplm1")]
[InlineData(NetKind.Regtest, "rplm1")]
public void Indirizzi_segwit_test_e_regtest_usano_l_hrp_giusto(NetKind kind, string prefix)
{
var addr = TestKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, PalladiumNetworks.For(kind));
Assert.StartsWith(prefix, addr.ToString());
}
[Fact]
public void Wif_mainnet_fa_roundtrip_con_il_prefisso_del_profilo()
{
var wif = TestKey.GetWif(PalladiumNetworks.Mainnet);
var decoded = Key.Parse(wif.ToString(), PalladiumNetworks.Mainnet);
Assert.Equal(TestKey.ToHex(), decoded.ToHex());
}
[Fact]
public void Chiavi_estese_mainnet_serializzano_come_xprv_xpub()
{
var ext = new ExtKey();
Assert.StartsWith("xprv", ext.ToString(PalladiumNetworks.Mainnet));
Assert.StartsWith("xpub", ext.Neuter().ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void Le_tre_reti_sono_distinte_e_riutilizzate()
{
Assert.NotSame(PalladiumNetworks.Mainnet, PalladiumNetworks.Testnet);
Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
}
}
@@ -0,0 +1,96 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
public class AddressDerivationTests
{
private static byte[] AbandonAboutSeed()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return Bip39.ToSeed(mnemonic!);
}
[Fact]
public void Il_vettore_bip44_produce_lo_stesso_hash_su_bitcoin_e_plm()
{
// Indirizzo noto di abandon-about su m/44'/0'/0'/0/0 (riferimento pubblico).
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0);
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
var plmAddr = account.GetReceiveAddress(0);
Assert.StartsWith("P", plmAddr.ToString());
// Stesso hash160 sotto i due prefissi: la derivazione è identica,
// cambia solo la veste di rete.
Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
}
[Fact]
public void Il_vettore_bip49_produce_lo_stesso_script_hash_su_bitcoin_e_plm()
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
var pubKey = account.GetPublicKey(isChange: false, 0);
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
var plmAddr = account.GetReceiveAddress(0);
Assert.StartsWith("3", plmAddr.ToString());
Assert.Equal(((BitcoinScriptAddress)bitcoinAddr).Hash, ((BitcoinScriptAddress)plmAddr).Hash);
}
[Theory]
[InlineData(ScriptKind.Legacy, NetKind.Mainnet)]
[InlineData(ScriptKind.WrappedSegwit, NetKind.Mainnet)]
[InlineData(ScriptKind.NativeSegwit, NetKind.Mainnet)]
[InlineData(ScriptKind.Legacy, NetKind.Testnet)]
[InlineData(ScriptKind.WrappedSegwit, NetKind.Testnet)]
[InlineData(ScriptKind.NativeSegwit, NetKind.Testnet)]
[InlineData(ScriptKind.NativeSegwit, NetKind.Regtest)]
public void Ogni_indirizzo_derivato_fa_roundtrip_sulla_propria_rete(ScriptKind kind, NetKind net)
{
var profile = ChainProfiles.For(net);
var account = HdAccount.FromSeed(AbandonAboutSeed(), kind, profile);
var addr = account.GetReceiveAddress(0);
var parsed = BitcoinAddress.Create(addr.ToString(), PalladiumNetworks.For(net));
Assert.Equal(addr.ScriptPubKey, parsed.ScriptPubKey);
}
[Theory]
[InlineData(NetKind.Mainnet, "tplm1")]
[InlineData(NetKind.Mainnet, "rplm1")]
public void Gli_indirizzi_segwit_mainnet_non_hanno_prefissi_di_altre_reti(NetKind net, string wrongPrefix)
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.NativeSegwit, ChainProfiles.For(net));
Assert.DoesNotContain(wrongPrefix, account.GetReceiveAddress(0).ToString());
}
[Fact]
public void Il_path_di_account_usa_il_coin_type_del_profilo()
{
Assert.Equal("84'/746'/0'",
DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToString());
Assert.Equal("84'/1'/0'",
DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Testnet).ToString());
Assert.Equal("44'/746'/2'",
DerivationPaths.AccountPath(ScriptKind.Legacy, ChainProfiles.Mainnet, account: 2).ToString());
}
[Fact]
public void Receiving_e_change_derivano_indirizzi_diversi()
{
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.NotEqual(account.GetReceiveAddress(0).ToString(), account.GetChangeAddress(0).ToString());
Assert.NotEqual(account.GetReceiveAddress(0).ToString(), account.GetReceiveAddress(1).ToString());
}
}
@@ -0,0 +1,68 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
/// <summary>
/// Vettore di test 1 della specifica BIP32 (seed 000102...0e0f). Gli header
/// Legacy della mainnet PLM coincidono con quelli Bitcoin, quindi il confronto
/// con le stringhe della specifica è diretto sulla rete PLM.
/// </summary>
public class Bip32Tests
{
private static ExtKey Root => ExtKey.CreateFromSeed(
Convert.FromHexString("000102030405060708090a0b0c0d0e0f"));
[Fact]
public void La_root_del_vettore_1_serializza_le_stringhe_attese()
{
Assert.Equal(
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi",
Root.ToString(PalladiumNetworks.Mainnet));
Assert.Equal(
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
Root.Neuter().ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void La_derivazione_hardened_m_0h_produce_le_chiavi_attese()
{
var derived = Root.Derive(new KeyPath("0'"));
Assert.Equal(
"xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7",
derived.ToString(PalladiumNetworks.Mainnet));
Assert.Equal(
"xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw",
derived.Neuter().ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void I_path_hardened_non_sono_derivabili_da_una_xpub()
{
// Garanzia §17: da sole chiavi pubbliche niente derivazione hardened.
Assert.ThrowsAny<InvalidOperationException>(() =>
Root.Neuter().Derive(new KeyPath("0'")));
}
[Theory]
[InlineData("m/84'/746'/0'", true)]
[InlineData("84h/746h/0h", true)]
[InlineData("0/5", true)]
[InlineData("m/84'/abc", false)]
[InlineData("", false)]
public void TryParse_accetta_path_validi_e_rifiuta_malformati(string path, bool expected)
{
Assert.Equal(expected, DerivationPaths.TryParse(path, out var parsed));
if (expected)
Assert.NotNull(parsed);
}
[Fact]
public void Gli_hardened_marker_apostrofo_e_h_sono_equivalenti()
{
Assert.True(DerivationPaths.TryParse("m/84'/746'/0'", out var a));
Assert.True(DerivationPaths.TryParse("84h/746h/0h", out var b));
Assert.Equal(a!.ToString(), b!.ToString());
}
}
@@ -0,0 +1,93 @@
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
public class Bip39Tests
{
// Vettori ufficiali Trezor (python-mnemonic/vectors.json), passphrase "TREZOR".
[Theory]
[InlineData(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04")]
[InlineData(
"legal winner thank year wave sausage worth useful legal winner thank yellow",
"2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607")]
[InlineData(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
"bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8")]
[InlineData(
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
"dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad")]
[InlineData(
"void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
"01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998")]
public void I_vettori_trezor_producono_il_seed_atteso(string words, string expectedSeedHex)
{
Assert.True(Bip39.TryParse(words, out var mnemonic));
Assert.Equal(expectedSeedHex, Convert.ToHexString(Bip39.ToSeed(mnemonic!, "TREZOR")).ToLowerInvariant());
}
[Fact]
public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
{
// Vettore ufficiale bip32JP (test_JP_BIP39.json[0]): mnemonica con spazi
// ideografici U+3000 e passphrase con caratteri da normalizzare NFKD.
const string words =
"あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
const string expectedSeed =
"a262d6fb6122ecf45be09c50492b31f92e9beb7d9a845987a02cefda57a15f9c467a17872029a9e92299b5cbdf306e3a0ee620245cbd508959b6cb7ca637bd55";
Assert.True(Bip39.TryParse(words, out var mnemonic, MnemonicLanguage.Japanese));
Assert.Equal(expectedSeed, Convert.ToHexString(Bip39.ToSeed(mnemonic!, passphrase)).ToLowerInvariant());
}
[Theory]
[InlineData(MnemonicLength.Twelve)]
[InlineData(MnemonicLength.TwentyFour)]
public void Generate_produce_il_numero_di_parole_richiesto_con_checksum_valido(MnemonicLength length)
{
var mnemonic = Bip39.Generate(length);
Assert.Equal((int)length, mnemonic.Words.Length);
Assert.True(Bip39.TryParse(mnemonic.ToString(), out _));
}
[Fact]
public void Checksum_invalido_viene_rifiutato()
{
// 12 × "abandon": parole valide ma checksum errato — il caso che il
// costruttore NBitcoin non controlla da solo.
var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
Assert.False(Bip39.TryParse(words, out _));
}
[Fact]
public void Numero_di_parole_errato_viene_rifiutato()
{
var words = string.Join(' ', Enumerable.Repeat("abandon", 12)) + " about";
Assert.False(Bip39.TryParse(words, out _));
}
[Fact]
public void La_lingua_viene_riconosciuta_automaticamente()
{
var spanish = Bip39.Generate(MnemonicLength.Twelve, MnemonicLanguage.Spanish);
Assert.True(Bip39.TryParse(spanish.ToString(), out var parsed));
Assert.Equal(spanish.ToString(), parsed!.ToString());
}
[Fact]
public void Passphrase_diverse_producono_seed_diversi()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
var noPass = Bip39.ToSeed(mnemonic!);
var withPass = Bip39.ToSeed(mnemonic!, "TREZOR");
var otherPass = Bip39.ToSeed(mnemonic!, "trezor"); // case-sensitive (§4.1)
Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
}
}
@@ -0,0 +1,100 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
/// <summary>
/// Vettore di test della specifica BIP84 (mnemonica abandon-about, senza
/// passphrase). Gli header SLIP-132 della mainnet PLM coincidono con quelli
/// Bitcoin, quindi zprv/zpub si confrontano direttamente; gli indirizzi si
/// confrontano sul witness program (chain-independent) + prefisso PLM.
/// Il path m/84'/0'/0' è volutamente "personalizzato" (coin type 0, non 746):
/// esercita anche l'import con path custom (§4.2).
/// </summary>
public class Bip84Slip132Tests
{
private static HdAccount Account()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromSeed(
Bip39.ToSeed(mnemonic!),
ScriptKind.NativeSegwit,
ChainProfiles.Mainnet,
new KeyPath("84'/0'/0'"));
}
[Fact]
public void L_account_serializza_la_zprv_e_la_zpub_del_vettore()
{
var account = Account();
Assert.Equal(
"zprvAdG4iTXWBoARxkkzNpNh8r6Qag3irQB8PzEMkAFeTRXxHpbF9z4QgEvBRmfvqWvGp42t42nvgGpNgYSJA9iefm1yYNZKEm7z6qUWCroSQnE",
account.ToSlip132Private());
Assert.Equal(
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs",
account.ToSlip132());
}
[Fact]
public void La_zpub_fa_roundtrip_e_viene_riconosciuta_come_native_segwit()
{
var account = Account();
var encoded = account.ToSlip132();
Assert.True(Slip132.TryDecodePublic(encoded, ChainProfiles.Mainnet, out var decoded, out var kind));
Assert.Equal(ScriptKind.NativeSegwit, kind);
Assert.Equal(account.AccountXpub.ToString(PalladiumNetworks.Mainnet),
decoded!.ToString(PalladiumNetworks.Mainnet));
}
[Fact]
public void La_zprv_fa_roundtrip_con_riconoscimento_del_tipo()
{
var account = Account();
Assert.True(Slip132.TryDecodePrivate(account.ToSlip132Private(), ChainProfiles.Mainnet,
out var decoded, out var kind));
Assert.Equal(ScriptKind.NativeSegwit, kind);
Assert.Equal(account.ToSlip132Private(),
Slip132.Encode(decoded!, kind, ChainProfiles.Mainnet));
}
[Fact]
public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
{
// xpub Bitcoin valida ma con header Legacy: non è una zpub.
var account = Account();
var asXpub = account.AccountXpub.ToString(Network.Main);
Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
Assert.Equal(ScriptKind.Legacy, kind); // header xpub → riconosciuto come Legacy
Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
}
[Theory]
[InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
[InlineData(false, 1, null, "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g")]
[InlineData(true, 0, null, "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el")]
public void Gli_indirizzi_del_vettore_hanno_lo_stesso_witness_program_su_plm(
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
{
var account = Account();
var pubKey = account.GetPublicKey(isChange, index);
if (expectedPubKeyHex is not null)
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
// Il witness program (hash160 della pubkey) è chain-independent: deve
// coincidere con quello dell'indirizzo bc1 del vettore ufficiale.
var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
var plmAddress = account.GetAddress(isChange, index);
Assert.StartsWith("plm1q", plmAddress.ToString());
Assert.Equal(pubKey.WitHash, ((BitcoinWitPubKeyAddress)plmAddress).Hash);
}
}
@@ -0,0 +1,88 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
namespace PalladiumWallet.Tests.Crypto;
public class HdAccountTests
{
private static Mnemonic AbandonAbout()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return mnemonic!;
}
[Fact]
public void Il_watch_only_da_xpub_deriva_gli_stessi_indirizzi_del_seed()
{
var full = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), ChainProfiles.Mainnet, out var xpub, out var kind));
var watchOnly = HdAccount.FromAccountXpub(xpub!, kind, ChainProfiles.Mainnet);
Assert.True(watchOnly.IsWatchOnly);
Assert.False(full.IsWatchOnly);
for (var i = 0; i < 3; i++)
{
Assert.Equal(full.GetReceiveAddress(i).ToString(), watchOnly.GetReceiveAddress(i).ToString());
Assert.Equal(full.GetChangeAddress(i).ToString(), watchOnly.GetChangeAddress(i).ToString());
}
}
[Fact]
public void Il_watch_only_non_espone_chiavi_private()
{
var full = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), ChainProfiles.Mainnet, out var xpub, out _));
var watchOnly = HdAccount.FromAccountXpub(xpub!, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Throws<InvalidOperationException>(() => watchOnly.GetExtPrivateKey(false, 0));
Assert.Throws<InvalidOperationException>(() => watchOnly.ToSlip132Private());
}
[Fact]
public void La_master_fingerprint_di_abandon_about_e_quella_nota()
{
// 73c5da0a: fingerprint usata nei vettori PSBT di mezzo ecosistema.
var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Equal("73c5da0a", Convert.ToHexString(account.MasterFingerprint.ToBytes()).ToLowerInvariant());
}
[Fact]
public void La_chiave_privata_derivata_corrisponde_all_indirizzo()
{
var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
var priv = account.GetExtPrivateKey(isChange: false, 0);
Assert.Equal(account.GetPublicKey(false, 0), priv.PrivateKey.PubKey);
}
[Fact]
public void La_passphrase_cambia_completamente_l_account()
{
var without = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
var with = HdAccount.FromMnemonic(AbandonAbout(), "extension", ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.NotEqual(without.GetReceiveAddress(0).ToString(), with.GetReceiveAddress(0).ToString());
Assert.NotEqual(without.MasterFingerprint, with.MasterFingerprint);
}
[Theory]
[InlineData(ScriptKind.WrappedSegwitMultisig)]
[InlineData(ScriptKind.NativeSegwitMultisig)]
public void I_tipi_multisig_non_sono_ancora_supportati(ScriptKind kind)
{
Assert.Throws<NotSupportedException>(() =>
HdAccount.FromMnemonic(AbandonAbout(), null, kind, ChainProfiles.Mainnet));
Assert.Throws<NotSupportedException>(() => DerivationPaths.ScriptPubKeyTypeFor(kind));
}
[Fact]
public void L_account_di_default_usa_il_path_standard_del_profilo()
{
var account = HdAccount.FromMnemonic(AbandonAbout(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
Assert.Equal("84'/746'/0'", account.AccountPath.ToString());
}
}
@@ -0,0 +1,97 @@
using System.Text.Json;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Tests.Net;
public class PeerParsingTests
{
[Fact]
public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx()
{
// Formato reale: [ip, hostname, ["v...", "pN", "tPORTA", "sPORTA"]].
const string json = """
[
["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]],
["10.0.0.1", "nodo.esempio.org", ["v1.4", "t"]],
["10.0.0.2", "solo-ssl.esempio.org", ["v1.4", "s50002"]],
["10.0.0.3", "senza-porte.esempio.org", ["v1.4"]]
]
""";
var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement);
Assert.Equal(3, peers.Count); // l'ultimo non offre porte → scartato
Assert.Equal(new PeerInfo("173.212.224.67", 50001, 50002, "1.4.2"), peers[0]);
// "t" senza numero = porta di default (0 segnala "da risolvere col profilo").
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
}
}
public class ServerRegistryTests
{
private static string TempPath() =>
Path.Combine(Path.GetTempPath(), $"plm-servers-{Guid.NewGuid()}.json");
[Fact]
public void Il_registro_parte_dai_bootstrap_del_profilo()
{
var path = TempPath();
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(ChainProfiles.Mainnet.BootstrapServers.Count, registry.All.Count);
Assert.Equal("173.212.224.67", registry.Default!.Host);
Assert.Equal(50001, registry.Default.PortFor(useSsl: false));
Assert.Equal(50002, registry.Default.PortFor(useSsl: true));
}
[Fact]
public void I_peer_scoperti_si_aggiungono_e_persistono_senza_duplicati()
{
var path = TempPath();
try
{
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
var bootstrapCount = registry.All.Count;
// Merge diretto via DiscoverAsync richiede un client: si testa la
// persistenza simulando il file degli scoperti.
var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") };
File.WriteAllText(path, JsonSerializer.Serialize(discovered));
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(bootstrapCount + 1, reloaded.All.Count);
Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org");
// Un bootstrap duplicato nel file non raddoppia.
File.WriteAllText(path, JsonSerializer.Serialize(new[]
{
new KnownServer("173.212.224.67", 50001, 50002),
new KnownServer("nuovo.esempio.org", 50001, 50002),
}));
var deduped = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(bootstrapCount + 1, deduped.All.Count);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Un_file_server_corrotto_non_blocca_l_avvio()
{
var path = TempPath();
try
{
File.WriteAllText(path, "{ non-json ");
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
Assert.Equal(ChainProfiles.Mainnet.BootstrapServers.Count, registry.All.Count);
}
finally
{
File.Delete(path);
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Spv;
namespace PalladiumWallet.Tests.Spv;
public class ScripthashTests
{
// Vettori calcolati indipendentemente con Python (hashlib), non con NBitcoin.
[Theory]
[InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
"5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
[InlineData("00140102030405060708090a0b0c0d0e0f1011121314",
"8639a8f75edb01f890138755277a84283c26fcba6f3289725d19cead464aa78a")]
public void Lo_scripthash_e_sha256_dello_script_con_byte_invertiti(string scriptHex, string expected)
{
var script = Script.FromHex(scriptHex);
Assert.Equal(expected, Scripthash.FromScript(script));
}
}
public class MerkleProofTests
{
// Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna
// per la convenzione di hashing/ordinamento.
private static readonly uint256[] Block100000Txids =
[
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
uint256.Parse("fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4"),
uint256.Parse("6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4"),
uint256.Parse("e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d"),
];
private static readonly uint256 Block100000Root =
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
[Fact]
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
{
Assert.Equal(Block100000Root, MerkleProof.ComputeRootFromLeaves(Block100000Txids));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Ogni_prova_di_merkle_del_blocco_verifica_contro_la_radice(int position)
{
var branch = BuildBranch(Block100000Txids, position);
Assert.True(MerkleProof.Verify(Block100000Txids[position], position, branch, Block100000Root));
}
[Fact]
public void Una_prova_per_la_posizione_sbagliata_fallisce()
{
var branch = BuildBranch(Block100000Txids, 0);
Assert.False(MerkleProof.Verify(Block100000Txids[0], 1, branch, Block100000Root));
}
[Fact]
public void Un_txid_estraneo_non_verifica()
{
var branch = BuildBranch(Block100000Txids, 0);
Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root));
}
/// <summary>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary>
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
{
var branch = new List<uint256>();
var level = leaves.ToList();
while (level.Count > 1)
{
var sibling = (position ^ 1) < level.Count ? level[position ^ 1] : level[position];
branch.Add(sibling);
var next = new List<uint256>();
for (var i = 0; i < level.Count; i += 2)
{
var pair = new[] { level[i], i + 1 < level.Count ? level[i + 1] : level[i] };
next.Add(MerkleProof.ComputeRootFromLeaves(pair));
}
level = next;
position >>= 1;
}
return branch;
}
}
public class BlockHeaderInfoTests
{
// Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3).
private const string GenesisHeaderHex =
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
[Fact]
public void L_header_della_genesi_si_parsa_con_hash_e_merkle_root_attesi()
{
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.Equal(ChainProfiles.Mainnet.GenesisHash, header.Hash.ToString());
Assert.Equal(
"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b",
header.MerkleRoot.ToString());
Assert.Equal(uint256.Zero, header.PrevHash);
Assert.Equal(1, header.Version);
Assert.Equal(0x1d00ffffu, header.Bits);
}
[Fact]
public void Il_collegamento_prev_hash_viene_verificato()
{
var genesis = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.True(genesis.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
Assert.False(genesis.IsValidChild(uint256.One, ChainProfiles.Mainnet));
}
[Fact]
public void Con_skip_pow_la_validazione_non_controlla_il_target()
{
// La genesi ha PoW valido, ma il punto è che con SkipPowValidation=true
// (LWMA, §3) il check si limita al collegamento.
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
Assert.True(ChainProfiles.Mainnet.SkipPowValidation);
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
}
}
@@ -0,0 +1,95 @@
using PalladiumWallet.Core.Storage;
namespace PalladiumWallet.Tests.Storage;
public class StorageTests
{
private static WalletDocument SampleDoc() => new()
{
Network = "regtest",
ScriptKind = "NativeSegwit",
Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
AccountPath = "84'/1'/0'",
AccountXpub = "vpub-fittizia-per-test",
Labels = { ["txid123"] = "caffè" },
};
[Fact]
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
{
var cipher = EncryptedFile.Encrypt("contenuto segreto", "pass-forte");
Assert.True(EncryptedFile.IsEncrypted(cipher));
Assert.DoesNotContain("segreto", cipher);
Assert.Equal("contenuto segreto", EncryptedFile.Decrypt(cipher, "pass-forte"));
}
[Fact]
public void La_password_errata_viene_rifiutata()
{
var cipher = EncryptedFile.Encrypt("contenuto", "giusta");
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(cipher, "sbagliata"));
}
[Fact]
public void Un_file_manomesso_viene_rifiutato()
{
var cipher = EncryptedFile.Encrypt("contenuto", "pass");
// Corrompe un byte del ciphertext mantenendo base64 e JSON validi.
var node = System.Text.Json.Nodes.JsonNode.Parse(cipher)!;
var data = Convert.FromBase64String(node["Data"]!.GetValue<string>());
data[0] ^= 0xff;
node["Data"] = Convert.ToBase64String(data);
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
}
[Fact]
public void Il_documento_wallet_fa_roundtrip_json()
{
var doc = SampleDoc();
var restored = WalletDocument.FromJson(doc.ToJson());
Assert.Equal(doc.Network, restored.Network);
Assert.Equal(doc.Mnemonic, restored.Mnemonic);
Assert.Equal(doc.AccountXpub, restored.AccountXpub);
Assert.Equal("caffè", restored.Labels["txid123"]);
Assert.False(restored.IsWatchOnly);
}
[Fact]
public void Una_versione_futura_del_file_viene_rifiutata()
{
var doc = SampleDoc();
var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99");
Assert.Throws<InvalidDataException>(() => WalletDocument.FromJson(json));
}
[Fact]
public void Il_wallet_store_salva_e_riapre_con_e_senza_password()
{
var path = Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
try
{
WalletStore.Save(SampleDoc(), path);
Assert.False(WalletStore.RequiresPassword(path));
Assert.Equal("regtest", WalletStore.Load(path).Network);
WalletStore.Save(SampleDoc(), path, "pwd");
Assert.True(WalletStore.RequiresPassword(path));
Assert.Throws<WrongPasswordException>(() => WalletStore.Load(path));
Assert.Throws<WrongPasswordException>(() => WalletStore.Load(path, "altra"));
Assert.Equal("regtest", WalletStore.Load(path, "pwd").Network);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Il_documento_senza_mnemonica_e_watch_only()
{
var doc = SampleDoc();
doc.Mnemonic = null;
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
}
}
@@ -0,0 +1,208 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Tests.Wallet;
public class TransactionFactoryTests
{
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
private static readonly Network Net = PalladiumNetworks.Regtest;
private static HdAccount Account()
{
Assert.True(Bip39.TryParse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
out var mnemonic));
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
}
/// <summary>Tx fittizia che accredita <paramref name="sats"/> sull'indirizzo receiving/0.</summary>
private static (List<CachedUtxo>, Dictionary<string, Transaction>) Fund(HdAccount account, long sats)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
funding.Outputs.Add(Money.Satoshis(sats), account.GetReceiveAddress(0));
var txid = funding.GetHash().ToString();
var utxos = new List<CachedUtxo>
{
new()
{
Txid = txid, Vout = 0, ValueSats = sats,
Address = account.GetReceiveAddress(0).ToString(),
IsChange = false, AddressIndex = 0, Height = 100,
},
};
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
}
[Fact]
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
var destination = BitcoinAddress.Create(account.GetReceiveAddress(5).ToString(), Net);
var built = new TransactionFactory(account).Build(
utxos, txs, destination, amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0);
Assert.True(built.Signed);
// Output: destinatario + change.
Assert.Equal(2, built.Transaction.Outputs.Count);
Assert.Contains(built.Transaction.Outputs,
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
// Fee coerente col rate richiesto (±20% per gli arrotondamenti di stima).
var vsize = built.Transaction.GetVirtualSize();
var expected = vsize * 2;
Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
// RBF abilitato (§6.6).
Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
}
[Fact]
public void Invia_tutto_sottrae_la_fee_e_non_produce_change()
{
var account = Account();
var (utxos, txs) = Fund(account, 500_000);
var destination = account.GetReceiveAddress(7);
var built = new TransactionFactory(account).Build(
utxos, txs, destination, amountSats: 0,
feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
var output = Assert.Single(built.Transaction.Outputs);
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
}
[Fact]
public void Fondi_insufficienti_danno_un_errore_chiaro()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000);
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
feeRateSatPerVByte: 2, changeIndex: 0));
}
[Fact]
public void Gli_utxo_in_mempool_non_sono_spendibili()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Height = 0; // in mempool: visibile nel saldo pending, non spendibile
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
feeRateSatPerVByte: 2, changeIndex: 0));
Assert.Contains("in attesa di conferma", ex.Message);
}
[Fact]
public void Gli_utxo_congelati_sono_esclusi_dalla_spesa()
{
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Frozen = true; // freeze (§6.2)
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
feeRateSatPerVByte: 2, changeIndex: 0));
}
[Fact]
public void Un_account_watch_only_produce_una_psbt_non_firmata()
{
var full = Account();
var (utxos, txs) = Fund(full, 1_000_000);
Assert.True(Slip132.TryDecodePublic(full.ToSlip132(), Profile, out var xpub, out var kind));
var watchOnly = HdAccount.FromAccountXpub(xpub!, kind, Profile);
var built = new TransactionFactory(watchOnly).Build(
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0);
Assert.False(built.Signed);
// Il flusso air-gapped (§6.5): la PSBT della macchina online si firma
// offline con le chiavi e si finalizza.
var psbt = built.Psbt;
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
psbt.Finalize();
var tx = psbt.ExtractTransaction();
Assert.Contains(tx.Outputs, o => o.Value.Satoshi == 400_000);
}
[Theory]
[InlineData("0.00000001", 1L)]
[InlineData("1", 100_000_000L)]
[InlineData("0,5", 50_000_000L)]
[InlineData("21.12345678", 2_112_345_678L)]
public void Gli_importi_si_parsano_in_satoshi(string text, long expected)
{
Assert.True(CoinAmount.TryParseCoins(text, out var sats));
Assert.Equal(expected, sats);
}
[Fact]
public void Gli_importi_invalidi_vengono_rifiutati()
{
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);
}
}
}