Compare commits
14 Commits
bd5e740599
...
data-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e3570da3f4 | |||
| 75583f7a69 | |||
| e8777fb458 | |||
| 73a2d5356d | |||
| ece634f42e | |||
| af10482e54 | |||
| 46d8c19784 | |||
| 08217e5306 | |||
| 1c2dacc27c | |||
| 34c588c6f4 | |||
| 0e32b9bc9b | |||
| bb8563580f | |||
| 3d6597a3b2 | |||
| 4d2ae1fd13 |
@@ -91,6 +91,3 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
CLAUDE.md
|
||||
|
||||
blueprint.md
|
||||
|
||||
@@ -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 1–7 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
@@ -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 (§9–10)**: 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 1–9 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 (§9–10) | 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.*
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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"],
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -34,8 +34,15 @@ public static class ChainProfiles
|
||||
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
||||
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
||||
},
|
||||
// TODO: popolare con i server reali prima della release (host:tcp:ssl).
|
||||
BootstrapServers = [],
|
||||
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
||||
// 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.
|
||||
Checkpoints = [],
|
||||
};
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList
|
||||
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
|
||||
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>
|
||||
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
|
||||
/// 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) =>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class SyncResult
|
||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||
public required IReadOnlyList<CachedUtxo> Utxos { get; init; }
|
||||
public required IReadOnlyList<TrackedAddress> Addresses { get; init; }
|
||||
public required IReadOnlyList<CachedAddress> AddressRows { 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>
|
||||
public event Action<string>? Progress;
|
||||
|
||||
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
||||
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
||||
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
||||
// costano solo ciò che è cambiato (modello Electrum).
|
||||
private readonly Dictionary<string, Transaction> _txCache = [];
|
||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||
|
||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tip = await client.SubscribeHeadersAsync(ct);
|
||||
@@ -55,37 +63,45 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||
txHeights[item.TxHash] = item.Height;
|
||||
|
||||
// 4. Scarica le transazioni.
|
||||
Progress?.Invoke($"scarico {txHeights.Count} transazioni…");
|
||||
// 4. Scarica in parallelo le sole transazioni nuove.
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var transactions = new Dictionary<string, Transaction>();
|
||||
foreach (var txid in txHeights.Keys)
|
||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
var hex = await client.GetTransactionAsync(txid, ct);
|
||||
transactions[txid] = Transaction.Parse(hex, network);
|
||||
Progress?.Invoke($"scarico {missing.Count} transazioni…");
|
||||
var downloaded = await Task.WhenAll(missing.Select(async txid =>
|
||||
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
|
||||
foreach (var (txid, tx) in downloaded)
|
||||
_txCache[txid] = tx;
|
||||
}
|
||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||
|
||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4).
|
||||
var verified = new Dictionary<string, bool>();
|
||||
foreach (var (txid, height) in txHeights)
|
||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
|
||||
// solo per le tx non ancora verificate a quell'altezza.
|
||||
var toVerify = txHeights
|
||||
.Where(kv => kv.Value > 0
|
||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||
.ToList();
|
||||
if (toVerify.Count > 0)
|
||||
{
|
||||
if (height <= 0)
|
||||
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
||||
await Task.WhenAll(toVerify.Select(async kv =>
|
||||
{
|
||||
verified[txid] = false; // in mempool: nessuna prova possibile
|
||||
continue;
|
||||
}
|
||||
var proof = await client.GetMerkleAsync(txid, height, ct);
|
||||
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
|
||||
var ok = MerkleProof.Verify(
|
||||
uint256.Parse(txid),
|
||||
proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse),
|
||||
header.MerkleRoot);
|
||||
if (!ok)
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
||||
var proof = await proofTask;
|
||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||
verified[txid] = true;
|
||||
}));
|
||||
foreach (var (txid, height) in toVerify)
|
||||
_verifiedAtHeight[txid] = height;
|
||||
}
|
||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||
|
||||
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
|
||||
// script; spesi = outpoint consumati da una qualunque tx del wallet.
|
||||
@@ -145,6 +161,22 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
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
|
||||
{
|
||||
TipHeight = tip.Height,
|
||||
@@ -155,13 +187,16 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
History = history,
|
||||
Utxos = utxos,
|
||||
Addresses = tracked,
|
||||
AddressRows = addressRows,
|
||||
Transactions = transactions,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||
/// vuoti consecutivi (§5). Ritorna il primo indice non usato.
|
||||
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
||||
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
||||
/// Ritorna il primo indice non usato.
|
||||
/// </summary>
|
||||
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
||||
@@ -169,23 +204,38 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
var consecutiveEmpty = 0;
|
||||
var index = 0;
|
||||
var firstUnused = 0;
|
||||
for (; consecutiveEmpty < gapLimit; index++)
|
||||
while (consecutiveEmpty < gapLimit)
|
||||
{
|
||||
var address = account.GetAddress(isChange, index);
|
||||
var scripthash = Scripthash.FromAddress(address);
|
||||
tracked.Add(new TrackedAddress(address, scripthash, isChange, index));
|
||||
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
||||
{
|
||||
var address = account.GetAddress(isChange, i);
|
||||
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
|
||||
}).ToList();
|
||||
index += batch.Count;
|
||||
tracked.AddRange(batch);
|
||||
|
||||
// La subscribe registra anche la notifica push per i cambi futuri.
|
||||
var status = await client.SubscribeScripthashAsync(scripthash, ct);
|
||||
if (status is null)
|
||||
var statuses = await Task.WhenAll(
|
||||
batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||
|
||||
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
|
||||
var histories = await Task.WhenAll(
|
||||
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||
for (var i = 0; i < used.Count; i++)
|
||||
historyByAddress[used[i].ScriptHash] = histories[i];
|
||||
|
||||
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||
{
|
||||
if (statuses[i] is null)
|
||||
{
|
||||
consecutiveEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
historyByAddress[scripthash] = await client.GetHistoryAsync(scripthash, ct);
|
||||
else
|
||||
{
|
||||
consecutiveEmpty = 0;
|
||||
firstUnused = index + 1;
|
||||
firstUnused = batch[i].Index + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstUnused;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.") { }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user