Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3570da3f4 |
@@ -2,92 +2,57 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Role
|
## Cos'è questo progetto
|
||||||
|
|
||||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
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).
|
||||||
|
|
||||||
## Language policy
|
**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).
|
||||||
|
|
||||||
- **Conversation with the user**: Italian.
|
## Stack
|
||||||
- **All code, comments, commit messages, and documentation files**: English only.
|
|
||||||
|
|
||||||
## How to assist
|
.NET 8 + Avalonia UI + NBitcoin (§19 del blueprint). Struttura della solution:
|
||||||
|
|
||||||
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
|
||||||
|
|
||||||
## What it is
|
|
||||||
|
|
||||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
|
||||||
|
|
||||||
[blueprint.md](blueprint.md) is a **reference for understanding** (consensus parameters verified against the node, algorithms, network protocol): consult it when it helps to understand an area, but **it is no longer binding** — it need not be followed to the letter or read before every change. The source of truth is the current code; the `§` references below point to the blueprint only as further reading.
|
|
||||||
|
|
||||||
## Stack and structure
|
|
||||||
|
|
||||||
.NET 10 + Avalonia UI 12 + NBitcoin.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
PalladiumWallet.sln
|
||||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
├─ src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (nessuna dipendenza UI)
|
||||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
├─ src/App/ Avalonia UI
|
||||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
├─ src/Cli/ CLI sullo stesso Core
|
||||||
src/Cli/ CLI on the same Core tests/ xUnit
|
└─ tests/ xUnit
|
||||||
```
|
```
|
||||||
|
|
||||||
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
**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.
|
||||||
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
|
|
||||||
by `MainWindow` on desktop and as the single-view root on Android.
|
|
||||||
|
|
||||||
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
|
## Comandi
|
||||||
|
|
||||||
## Commands
|
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`.
|
||||||
|
|
||||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
|
||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- Build: `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
- Test (headless, è il livello principale di verifica): `dotnet test`
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- Singolo test: `dotnet test --filter "FullyQualifiedName~NomeTest"`
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
- CLI contro testnet/regtest: `dotnet run --project src/Cli -- <comando>`
|
||||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
- GUI con hot reload: `dotnet watch --project src/App`
|
||||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
- 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)
|
||||||
|
|
||||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
La logica core e la crypto si testano senza GUI né rete reale; la GUI serve solo per rifinire l'interfaccia (§19.7).
|
||||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
|
||||||
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
|
||||||
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
|
||||||
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
|
||||||
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
|
||||||
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
|
||||||
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
|
||||||
|
|
||||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
## Comandi CLI principali (src/Cli)
|
||||||
|
|
||||||
## Architecture (points that require reading multiple files)
|
`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`).
|
||||||
|
|
||||||
- **Layers (§2):** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
## Architettura — punti che richiedono più file per essere capiti
|
||||||
- **Network profile (§3):** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
|
||||||
- **LWMA / skip PoW (§3, §7):** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints** (§7.3). Custom layer: NBitcoin assumes Bitcoin's retargeting.
|
|
||||||
- **NBitcoin vs custom (§19.2):** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like, §10); SPV sync with Merkle verification (§7.4); header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
|
||||||
- **PSBT-centric (§6.5):** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
|
||||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
|
||||||
|
|
||||||
## GUI conventions (`src/App`)
|
- **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.
|
||||||
|
|
||||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
## Regole di lavoro
|
||||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
|
||||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
|
||||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
|
||||||
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
|
|
||||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
|
||||||
|
|
||||||
## Implementation state (§16 steps 1–7 + GUI)
|
- **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.
|
||||||
`Core/Chain` network profiles; `Core/Crypto` BIP39/32/SLIP-132/`HdAccount`; `Core/Storage` JSON wallet v1 + AES-GCM (PBKDF2-SHA512) + data paths; `Core/Net` `ElectrumClient` (newline JSON-RPC over TCP/TLS, TOFU in `server-certs.json`, concurrent requests) + `ElectrumApi`; `Core/Spv` scripthash, mandatory Merkle verification on every confirmed tx, sync with gap limit; `Core/Wallet` `TransactionFactory` (RBF on, send-all, watch-only PSBT), `TransactionInspector` (tx detail from the server), `WalletLoader`. GUI: setup wizard, dashboard (history/send/receive with QR+copy/addresses/contacts), transaction detail, settings/server/help, multi-wallet. Runs on desktop and Android from one shared UI (debug apk builds end-to-end).
|
- Le funzionalità marcate *(opzionale)* nel blueprint possono essere rimandate ma vanno comunque considerate nella progettazione.
|
||||||
|
|
||||||
**TODO (§16 steps 8–9):** multisig, hardware wallet, coin control UI, fee ETA/mempool, RBF/CPFP UI, on-disk header chain, multi-server pool, proxy/Tor.
|
|
||||||
|
|
||||||
## Working rules
|
|
||||||
|
|
||||||
- **Cross-implementation tests (§16):** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
|
||||||
- **Security (§17):** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
|
||||||
- *(Optional)* blueprint features may be deferred but must still be considered in the design.
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Davide Grilli
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -15,10 +15,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -44,21 +40,11 @@ Global
|
|||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
# Palladium Wallet
|
|
||||||
|
|
||||||
**An SPV wallet built specifically for the Palladium (PLM) cryptocurrency** and optimized for its chain. Runs on desktop (Windows/Linux) and Android from a single shared codebase.
|
|
||||||
|
|
||||||
Unlike generic wallets adapted to many coins, Palladium Wallet is designed around Palladium's consensus parameters — a Bitcoin-derived UTXO chain with 2-minute blocks and LWMA difficulty — and centralizes them in a single network profile. This keeps it lightweight, predictable and faithful to the chain: no client-side difficulty recalculation (trust is anchored to hardcoded checkpoints), mandatory Merkle verification on every confirmed transaction, and a network client written specifically for Palladium's indexing server.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
|
||||||
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
|
||||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub.
|
|
||||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
|
|
||||||
- **Multi-network**: mainnet, testnet, regtest.
|
|
||||||
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
|
||||||
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
PalladiumWallet.sln
|
|
||||||
├─ src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
|
||||||
├─ src/App/ shared Avalonia UI library (Views, ViewModels, Loc, Assets)
|
|
||||||
├─ src/App.Desktop/ desktop head (Windows/Linux) → runnable
|
|
||||||
├─ src/App.Android/ Android head → apk
|
|
||||||
├─ src/Cli/ CLI on the same Core
|
|
||||||
└─ tests/ xUnit
|
|
||||||
```
|
|
||||||
|
|
||||||
The UI is written **once** in `src/App`; the desktop and Android heads only add the per-platform
|
|
||||||
entry point and packages.
|
|
||||||
|
|
||||||
Stack: **.NET 10 + Avalonia UI 12 + NBitcoin**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development environment
|
|
||||||
|
|
||||||
For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto are fully testable without the GUI or a real network.
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
1. Install the .NET 10 SDK:
|
|
||||||
```powershell
|
|
||||||
winget install Microsoft.DotNet.SDK.10
|
|
||||||
```
|
|
||||||
(alternatively, the installer from <https://dotnet.microsoft.com/download/dotnet/10.0>)
|
|
||||||
2. Clone the repository and restore dependencies:
|
|
||||||
```powershell
|
|
||||||
git clone <repo-URL>
|
|
||||||
cd PalladiumWallet
|
|
||||||
dotnet restore
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
|
|
||||||
```bash
|
|
||||||
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0
|
|
||||||
export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"
|
|
||||||
```
|
|
||||||
(add the two `export` lines to your `~/.bashrc` to make them permanent)
|
|
||||||
2. Clone and restore:
|
|
||||||
```bash
|
|
||||||
git clone <repo-URL>
|
|
||||||
cd PalladiumWallet
|
|
||||||
dotnet restore
|
|
||||||
```
|
|
||||||
|
|
||||||
> The GUI uses Avalonia, which runs natively on Windows and Linux with no extra graphics dependencies.
|
|
||||||
|
|
||||||
### Android (additional setup)
|
|
||||||
|
|
||||||
Building the apk also requires the Android workload, a JDK, and the Android SDK:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet workload install android # .NET Android build packs
|
|
||||||
# JDK 17+ must be available (set JAVA_HOME)
|
|
||||||
# Provision the Android SDK once into ~/android-sdk:
|
|
||||||
dotnet build src/App.Android -t:InstallAndroidDependencies \
|
|
||||||
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true
|
|
||||||
```
|
|
||||||
|
|
||||||
To run the apk on an emulator (instead of a physical device), see
|
|
||||||
[*Android emulator (developer setup)*](#android-emulator-developer-setup) below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
### Desktop GUI in debug (Linux & Windows)
|
|
||||||
|
|
||||||
The desktop head runs the same way on both OSes (`Debug` is the default configuration). Run it from
|
|
||||||
the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project src/App.Desktop # single run (Debug)
|
|
||||||
dotnet watch --project src/App.Desktop # with hot reload (edit XAML/C# and see changes live)
|
|
||||||
dotnet run --project src/App.Desktop -c Release # to try the Release config
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Linux** — runs natively on X11/Wayland, no extra graphics packages. On **WSL2** the window
|
|
||||||
appears on the Windows desktop through WSLg (already working here, nothing to install).
|
|
||||||
- **Windows** — runs natively; use the same commands from PowerShell or a terminal.
|
|
||||||
|
|
||||||
The app writes its data under the per-user data folder (see *User guide → First launch*); delete it
|
|
||||||
to start from a clean first-run wizard.
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
Same core, useful for scripts and headless environments:
|
|
||||||
```bash
|
|
||||||
dotnet run --project src/Cli -- <command>
|
|
||||||
```
|
|
||||||
Run without arguments for the full list of commands.
|
|
||||||
|
|
||||||
### Android
|
|
||||||
|
|
||||||
There is no `dotnet run` for a phone: build the apk and install it (see *Building → Android apk*),
|
|
||||||
or run it on an emulator (see *Android emulator (developer setup)*).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running tests
|
|
||||||
|
|
||||||
Tests are the **primary verification layer** — the core logic and crypto run headless, without the GUI or a real network.
|
|
||||||
|
|
||||||
Run the whole suite:
|
|
||||||
```bash
|
|
||||||
dotnet test
|
|
||||||
```
|
|
||||||
|
|
||||||
Run a single test (or a group) by name:
|
|
||||||
```bash
|
|
||||||
dotnet test --filter "FullyQualifiedName~TestName"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run only the tests in one project:
|
|
||||||
```bash
|
|
||||||
dotnet test tests/PalladiumWallet.Tests
|
|
||||||
```
|
|
||||||
|
|
||||||
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
|
||||||
|
|
||||||
The suite includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
### Development build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build # whole solution (debug)
|
|
||||||
dotnet build src/App.Desktop # desktop head only
|
|
||||||
```
|
|
||||||
|
|
||||||
> A solution-wide `dotnet build` also builds the Android head, which needs the Android SDK
|
|
||||||
> (see *Android emulator (developer setup)* below). If you don't have it, build the specific
|
|
||||||
> non-Android projects (`src/App.Desktop`, `src/Cli`, `tests/...`).
|
|
||||||
|
|
||||||
### Desktop release (self-contained)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
|
|
||||||
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true --self-contained
|
|
||||||
|
|
||||||
# Linux — self-contained; AppImage then produced with PupNet Deploy
|
|
||||||
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
|
|
||||||
dotnet publish src/App.Desktop -c Release -r linux-x64 --self-contained
|
|
||||||
```
|
|
||||||
|
|
||||||
[PupNet Deploy](https://github.com/kuiperzone/PupNet-Deploy) turns the Linux publish into an AppImage.
|
|
||||||
The executable is named `PalladiumWallet` (set via `<AssemblyName>` in the desktop head).
|
|
||||||
|
|
||||||
### Android apk
|
|
||||||
|
|
||||||
Prerequisites: the Android workload + SDK (see *Development environment → Android*). The Android
|
|
||||||
head already sets `<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>`, so the apk is
|
|
||||||
**self-contained** and installs/runs standalone (a Fast-Deployment debug apk crashes at launch
|
|
||||||
with "No assemblies found" when installed without `adb`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default debug apk — all ABIs (arm64-v8a + x86_64): runs on phones AND the x86_64 emulator.
|
|
||||||
# ~79 MB. Output: src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
|
|
||||||
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
|
|
||||||
-p:AndroidSdkDirectory=$HOME/android-sdk
|
|
||||||
|
|
||||||
# Smaller apk for a real phone — arm64 only (~41 MB):
|
|
||||||
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
|
|
||||||
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AbiArm64Only=true
|
|
||||||
```
|
|
||||||
|
|
||||||
The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android head's
|
|
||||||
`<RuntimeIdentifiers>` in its csproj — do **not** pass `-p:RuntimeIdentifiers=android-arm64` on the
|
|
||||||
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
|
||||||
`AndroidSupportedAbis` property is deprecated and ignored.)
|
|
||||||
|
|
||||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) Release signing with your own
|
|
||||||
keystore is not set up yet; the debug apk is fine for personal sideloading.
|
|
||||||
|
|
||||||
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
|
|
||||||
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
|
||||||
> `arm64-v8a` only) but is meant for a physical arm64 phone — on the x86_64 emulator it only runs
|
|
||||||
> through slow ARM translation and stalls on the splash, so **verify it on a real device**.
|
|
||||||
|
|
||||||
### Version
|
|
||||||
|
|
||||||
The application **version** is set in a single place: the `<Version>` tag in
|
|
||||||
[`src/App/PalladiumWallet.App.csproj`](src/App/PalladiumWallet.App.csproj). It appears in the desktop
|
|
||||||
window title, in the Help dialog, and is stamped into the published binaries (and the apk's versionName).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Android emulator (developer setup)
|
|
||||||
|
|
||||||
How to run the apk without a physical device. Paths assume the Android SDK in `~/android-sdk`
|
|
||||||
and a JDK at `JAVA_HOME` (JDK 17+). Tested on Linux / WSL2.
|
|
||||||
|
|
||||||
**1. Install the emulator, a system image and the matching platform** (once):
|
|
||||||
```bash
|
|
||||||
SDK=$HOME/android-sdk
|
|
||||||
$SDK/cmdline-tools/latest/bin/sdkmanager --sdk_root=$SDK \
|
|
||||||
"emulator" "system-images;android-34;google_apis;x86_64" "platforms;android-34"
|
|
||||||
```
|
|
||||||
Use an `x86_64` image so the emulator runs with hardware acceleration (KVM); the app's min SDK is 23.
|
|
||||||
|
|
||||||
**2. Hardware acceleration (Linux/WSL2)** — the emulator needs access to `/dev/kvm`. Add yourself
|
|
||||||
to the `kvm` group once, then start a new shell (or prefix the launch with `sg kvm -c '…'`):
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG kvm $USER
|
|
||||||
```
|
|
||||||
On WSL2, KVM must be enabled on the Windows host (nested virtualization); the emulator window is
|
|
||||||
shown on the Windows desktop through WSLg.
|
|
||||||
|
|
||||||
**3. Create an AVD** (virtual device):
|
|
||||||
```bash
|
|
||||||
echo no | $SDK/cmdline-tools/latest/bin/avdmanager create avd \
|
|
||||||
-n plm -k "system-images;android-34;google_apis;x86_64" -d pixel
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. Launch the emulator** (software GL is the most robust under WSLg):
|
|
||||||
```bash
|
|
||||||
$SDK/emulator/emulator -avd plm -gpu swiftshader_indirect -no-snapshot -no-audio &
|
|
||||||
$SDK/platform-tools/adb wait-for-device
|
|
||||||
# wait for full boot:
|
|
||||||
until [ "$($SDK/platform-tools/adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done
|
|
||||||
```
|
|
||||||
If the window won't render, add `-no-window` and rely on `adb` + screenshots
|
|
||||||
(`adb exec-out screencap -p > shot.png`).
|
|
||||||
|
|
||||||
**5. Install and run the apk; capture logs to debug crashes:**
|
|
||||||
```bash
|
|
||||||
ADB=$SDK/platform-tools/adb
|
|
||||||
$ADB install -r src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
|
|
||||||
$ADB shell monkey -p io.github.davide3011.palladiumwallet -c android.intent.category.LAUNCHER 1
|
|
||||||
$ADB logcat -d | grep -iE "monodroid|exception|fatal|avalonia"
|
|
||||||
```
|
|
||||||
|
|
||||||
**VS Code "Android iOS Emulator" extension** (optional, click-to-launch): it only starts an
|
|
||||||
existing AVD, so create one first (step 3). Point it at the emulator binary:
|
|
||||||
```jsonc
|
|
||||||
// VS Code settings.json
|
|
||||||
"emulator.emulatorPathLinux": "/home/<user>/android-sdk/emulator"
|
|
||||||
// on WSL, use: "emulator.emulatorPathWSL": "/home/<user>/android-sdk/emulator"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User guide (quick)
|
|
||||||
|
|
||||||
### First launch
|
|
||||||
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
|
||||||
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
|
||||||
3. If you create a wallet, **write the seed phrase down on paper**: it will not be shown again. You can protect the file with a password.
|
|
||||||
|
|
||||||
> **Desktop vs Android.** The UI and features are the same on both. Differences: on Android the
|
|
||||||
> data-location step is skipped (fixed app sandbox) and *File → Open wallet from file* (importing a
|
|
||||||
> wallet from an arbitrary file) is hidden — open wallets from the in-app chooser instead. The
|
|
||||||
> version is shown in the desktop window title and, on every platform, in the Help dialog. The CLI
|
|
||||||
> is desktop/headless only.
|
|
||||||
|
|
||||||
### Main tabs
|
|
||||||
- **History** — list of transactions. *Double-click* (double-tap on touch) a row to open the full detail (amount, fee, addresses, sizes, confirmations).
|
|
||||||
- **Send** — recipient + amount (or "send all"), adjustable fee; for watch-only wallets a PSBT is produced to be signed offline.
|
|
||||||
- **Receive** — next unused address, with a **QR code** and a **Copy** button.
|
|
||||||
- **Addresses** — all derived addresses with balances; click for details (keys, derivation path).
|
|
||||||
- **Contacts** — address book with labels.
|
|
||||||
|
|
||||||
### Connection
|
|
||||||
- The status indicator at the bottom shows the connection to the **indexing server**; tapping it opens the server settings.
|
|
||||||
- Sync is SPV: it downloads only what concerns your wallet and verifies every confirmed transaction with a Merkle proof.
|
|
||||||
|
|
||||||
### Settings and Help
|
|
||||||
- **Settings**: language, display unit (PLM / mPLM / µPLM / sat), server.
|
|
||||||
- **Help**: software information and version.
|
|
||||||
|
|
||||||
### CLI in brief
|
|
||||||
```bash
|
|
||||||
# Wallet
|
|
||||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
|
||||||
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
|
||||||
dotnet run --project src/Cli -- info [--net ...] [--password P]
|
|
||||||
|
|
||||||
# Network
|
|
||||||
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
|
||||||
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
|
||||||
```
|
|
||||||
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Security
|
|
||||||
|
|
||||||
## Threat model
|
|
||||||
|
|
||||||
Palladium Wallet is a self-custody SPV wallet. It is designed to protect funds against:
|
|
||||||
|
|
||||||
- Theft of the wallet file at rest (AES-256-GCM encryption with PBKDF2-HMAC-SHA512)
|
|
||||||
- Memory snooping of private keys after unlock (keys are held only in process memory, never written to disk in plaintext unless the user explicitly disables encryption)
|
|
||||||
- Fraudulent transaction injection by a malicious server (every confirmed transaction is verified with a Merkle proof against SPV-validated block headers anchored to hardcoded checkpoints)
|
|
||||||
|
|
||||||
It does **not** protect against:
|
|
||||||
|
|
||||||
- A fully compromised operating system or process (malware with memory access can extract keys from RAM)
|
|
||||||
- An attacker who obtains the wallet file **and** the password
|
|
||||||
- Denial of service or eclipse attacks against the indexing server
|
|
||||||
- Network-level traffic analysis (no Tor/proxy support in the first release)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SPV trust model
|
|
||||||
|
|
||||||
This wallet is an SPV client, not a full node. It validates:
|
|
||||||
|
|
||||||
- Block headers (proof of work checked up to the last checkpoint; `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side — trust is anchored to hardcoded checkpoints in `Core/Chain/ChainProfiles.cs`)
|
|
||||||
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleVerifier.cs`)
|
|
||||||
|
|
||||||
It does **not** validate:
|
|
||||||
|
|
||||||
- Script execution (P2WPKH scripts are assumed valid if the server returns a confirmed transaction with a valid Merkle proof)
|
|
||||||
- Double-spend detection beyond what the server reports (an eclipse attack on the indexing server could hide a conflicting transaction)
|
|
||||||
- Full block validity (coinbase, consensus rules beyond the header)
|
|
||||||
|
|
||||||
The indexing server (ElectrumX-compatible, port 50001/50002) is a **semi-trusted** component. It can:
|
|
||||||
|
|
||||||
- Lie about unconfirmed (mempool) transactions — the wallet shows mempool transactions as unconfirmed and non-spendable
|
|
||||||
- Refuse to relay a broadcast transaction
|
|
||||||
- Delay reporting of new blocks
|
|
||||||
|
|
||||||
It cannot (given correct Merkle verification):
|
|
||||||
|
|
||||||
- Fabricate a confirmed transaction with a valid Merkle proof
|
|
||||||
- Forge a payment to a wrong address
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key and seed management
|
|
||||||
|
|
||||||
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock
|
|
||||||
- Private keys are never written to disk, logged, or sent over the network
|
|
||||||
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
|
|
||||||
- Watch-only wallets (`restore-xpub`) hold no private keys and cannot sign transactions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Encryption at rest
|
|
||||||
|
|
||||||
- Algorithm: AES-256-GCM
|
|
||||||
- Key derivation: PBKDF2-HMAC-SHA512, 100 000 iterations, 32-byte random salt
|
|
||||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
|
||||||
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TLS certificate pinning
|
|
||||||
|
|
||||||
Connections to the indexing server use TOFU (Trust On First Use): the server's TLS certificate is pinned on first connection and stored in `server-certs.json`. A certificate change triggers a hard error (`CertificatePinMismatchException`) requiring explicit reset by the user. This prevents silent MITM substitution after first connection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backup
|
|
||||||
|
|
||||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known limitations and out-of-scope for v1
|
|
||||||
|
|
||||||
- No Tor/proxy support (network traffic reveals which addresses are being queried)
|
|
||||||
- No multi-server pooling (single point of failure for the indexing server)
|
|
||||||
- No hardware wallet integration
|
|
||||||
- No coin control (automatic UTXO selection only)
|
|
||||||
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
|
||||||
- No Lightning Network support
|
|
||||||
@@ -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.*
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Threading.Tasks;
|
|
||||||
using Android.App;
|
|
||||||
using Android.Content;
|
|
||||||
using Android.Content.PM;
|
|
||||||
using Android.OS;
|
|
||||||
using Avalonia.Android;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
|
||||||
|
|
||||||
[Activity(
|
|
||||||
Label = "Palladium Wallet",
|
|
||||||
Theme = "@style/MyTheme.NoActionBar",
|
|
||||||
MainLauncher = true,
|
|
||||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
|
||||||
public class MainActivity : AvaloniaMainActivity
|
|
||||||
{
|
|
||||||
internal const int ScanRequestCode = 9001;
|
|
||||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
|
||||||
internal static MainActivity? Current;
|
|
||||||
|
|
||||||
protected override void OnCreate(Bundle? savedInstanceState)
|
|
||||||
{
|
|
||||||
base.OnCreate(savedInstanceState);
|
|
||||||
Current = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
|
||||||
{
|
|
||||||
base.OnActivityResult(requestCode, resultCode, data);
|
|
||||||
if (requestCode == ScanRequestCode)
|
|
||||||
{
|
|
||||||
var text = resultCode == Result.Ok ? data?.GetStringExtra(ScannerActivity.ResultKey) : null;
|
|
||||||
ScanTcs?.TrySetResult(text);
|
|
||||||
ScanTcs = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Android.App;
|
|
||||||
using Android.Content;
|
|
||||||
using Android.Runtime;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Android;
|
|
||||||
using PalladiumWallet.App.Services;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
|
||||||
|
|
||||||
// In Avalonia 12 the Android AppBuilder is configured in the Application subclass
|
|
||||||
// (AvaloniaAndroidApplication<TApp>), no longer in the Activity. allowBackup=false:
|
|
||||||
// the encrypted wallet file/seed must not end up in automatic cloud backups.
|
|
||||||
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
|
||||||
Icon = "@mipmap/ic_launcher", RoundIcon = "@mipmap/ic_launcher_round")]
|
|
||||||
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
|
||||||
{
|
|
||||||
public MainApplication(IntPtr javaReference, JniHandleOwnership transfer)
|
|
||||||
: base(javaReference, transfer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnCreate()
|
|
||||||
{
|
|
||||||
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
|
||||||
|
|
||||||
// Registers the QR scanner: opens ScannerActivity and waits for its result.
|
|
||||||
PlatformServices.ScanQrAsync = async () =>
|
|
||||||
{
|
|
||||||
var activity = MainActivity.Current;
|
|
||||||
if (activity == null) return null;
|
|
||||||
var tcs = new TaskCompletionSource<string?>();
|
|
||||||
MainActivity.ScanTcs = tcs;
|
|
||||||
activity.StartActivityForResult(
|
|
||||||
new Intent(activity, typeof(ScannerActivity)),
|
|
||||||
MainActivity.ScanRequestCode);
|
|
||||||
return await tcs.Task;
|
|
||||||
};
|
|
||||||
|
|
||||||
base.OnCreate();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
|
||||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<!-- Head Android: entry-point e pacchetti specifici. La UI vera vive nella
|
|
||||||
libreria condivisa PalladiumWallet.App (stessa di desktop). -->
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0-android</TargetFramework>
|
|
||||||
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
|
||||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
|
||||||
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
|
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
|
||||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
|
||||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
|
||||||
a mano — sideload sul telefono o `adb install` — crasha all'avvio con
|
|
||||||
"monodroid: No assemblies found ... Exiting". Così l'apk è autosufficiente. -->
|
|
||||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
|
||||||
<!-- Default: ABI multiple (l'apk gira sia su emulatore x86_64 sia su telefoni arm64).
|
|
||||||
Per un apk più piccolo solo-telefono: aggiungi -p:AbiArm64Only=true alla build. -->
|
|
||||||
<RuntimeIdentifiers Condition="'$(AbiArm64Only)' == 'true'">android-arm64</RuntimeIdentifiers>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
|
||||||
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<!-- Necessario per parlare col server di indicizzazione (TCP/TLS).
|
|
||||||
L'elemento <application> (label, allowBackup) è generato dall'attributo
|
|
||||||
[Application] su MainApplication. usesCleartextTraffic resta al default
|
|
||||||
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
|
||||||
</manifest>
|
|
||||||
|
Before Width: | Height: | Size: 40 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- DEVE derivare da Theme.AppCompat: AvaloniaActivity estende AppCompatActivity,
|
|
||||||
che con un tema non-AppCompat lancia IllegalStateException all'avvio.
|
|
||||||
Avalonia disegna comunque l'intera UI (NoActionBar). -->
|
|
||||||
<style name="MyTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar" />
|
|
||||||
</resources>
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Android.App;
|
|
||||||
using Android.Content;
|
|
||||||
using Android.Graphics;
|
|
||||||
using Android.Hardware.Camera2;
|
|
||||||
using Android.Media;
|
|
||||||
using Android.OS;
|
|
||||||
using Android.Views;
|
|
||||||
using Android.Widget;
|
|
||||||
using ZXing;
|
|
||||||
using ZXing.Common;
|
|
||||||
using AndroidResult = Android.App.Result;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Mobile;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Full-screen activity for QR scanning via Camera2 + ZXing.Net.
|
|
||||||
/// Returns to MainActivity via SetResult with the extra "qr" = code text.
|
|
||||||
/// </summary>
|
|
||||||
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
|
||||||
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
|
||||||
internal sealed class ScannerActivity : Activity, TextureView.ISurfaceTextureListener
|
|
||||||
{
|
|
||||||
internal const string ResultKey = "qr";
|
|
||||||
private const int PermReq = 1;
|
|
||||||
|
|
||||||
private HandlerThread? _bgThread;
|
|
||||||
private Handler? _bgHandler;
|
|
||||||
private CameraDevice? _camera;
|
|
||||||
private CameraCaptureSession? _session;
|
|
||||||
private ImageReader? _imageReader;
|
|
||||||
private SurfaceTexture? _surfaceTex;
|
|
||||||
private bool _permOk;
|
|
||||||
private volatile bool _done;
|
|
||||||
private long _lastDecode;
|
|
||||||
|
|
||||||
protected override void OnCreate(Bundle? savedInstanceState)
|
|
||||||
{
|
|
||||||
base.OnCreate(savedInstanceState);
|
|
||||||
Window!.SetFlags(WindowManagerFlags.Fullscreen, WindowManagerFlags.Fullscreen);
|
|
||||||
|
|
||||||
var root = new FrameLayout(this);
|
|
||||||
|
|
||||||
var preview = new TextureView(this) { SurfaceTextureListener = this };
|
|
||||||
root.AddView(preview, new FrameLayout.LayoutParams(
|
|
||||||
FrameLayout.LayoutParams.MatchParent, FrameLayout.LayoutParams.MatchParent));
|
|
||||||
|
|
||||||
var hint = new TextView(this) { Text = "Inquadra un codice QR" };
|
|
||||||
hint.SetTextColor(Color.White);
|
|
||||||
hint.SetBackgroundColor(Color.ParseColor("#AA000000"));
|
|
||||||
hint.SetPadding(24, 12, 24, 12);
|
|
||||||
root.AddView(hint, new FrameLayout.LayoutParams(
|
|
||||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
|
||||||
GravityFlags.Top | GravityFlags.CenterHorizontal) { TopMargin = 60 });
|
|
||||||
|
|
||||||
var cancel = new Button(this) { Text = "Annulla" };
|
|
||||||
cancel.Click += (_, _) => Finish();
|
|
||||||
root.AddView(cancel, new FrameLayout.LayoutParams(
|
|
||||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
|
||||||
GravityFlags.Bottom | GravityFlags.CenterHorizontal) { BottomMargin = 80 });
|
|
||||||
|
|
||||||
SetContentView(root);
|
|
||||||
|
|
||||||
_bgThread = new HandlerThread("CamBg");
|
|
||||||
_bgThread.Start();
|
|
||||||
_bgHandler = new Handler(_bgThread.Looper!);
|
|
||||||
|
|
||||||
if (CheckSelfPermission(Android.Manifest.Permission.Camera) == Android.Content.PM.Permission.Granted)
|
|
||||||
_permOk = true;
|
|
||||||
else
|
|
||||||
RequestPermissions([Android.Manifest.Permission.Camera], PermReq);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnRequestPermissionsResult(int req, string[] perms, Android.Content.PM.Permission[] grants)
|
|
||||||
{
|
|
||||||
_permOk = grants.Length > 0 && grants[0] == Android.Content.PM.Permission.Granted;
|
|
||||||
if (_permOk) TryOpen(); else Finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { _surfaceTex = st; TryOpen(); }
|
|
||||||
public bool OnSurfaceTextureDestroyed(SurfaceTexture st) { CloseCamera(); return true; }
|
|
||||||
public void OnSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { }
|
|
||||||
public void OnSurfaceTextureUpdated(SurfaceTexture st) { }
|
|
||||||
|
|
||||||
private void TryOpen() { if (_permOk && _surfaceTex != null) OpenCamera(_surfaceTex); }
|
|
||||||
|
|
||||||
private void OpenCamera(SurfaceTexture st)
|
|
||||||
{
|
|
||||||
var mgr = (CameraManager)GetSystemService(CameraService)!;
|
|
||||||
|
|
||||||
var ids = mgr.GetCameraIdList()!;
|
|
||||||
string cameraId = ids[0];
|
|
||||||
foreach (var id in ids)
|
|
||||||
{
|
|
||||||
var chars = mgr.GetCameraCharacteristics(id)!;
|
|
||||||
if (chars.Get(CameraCharacteristics.LensFacing) is Java.Lang.Integer f
|
|
||||||
&& f.IntValue() == (int)LensFacing.Back)
|
|
||||||
{ cameraId = id; break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
var map = mgr.GetCameraCharacteristics(cameraId)!
|
|
||||||
.Get(CameraCharacteristics.ScalerStreamConfigurationMap)
|
|
||||||
as Android.Hardware.Camera2.Params.StreamConfigurationMap;
|
|
||||||
var allSizes = map!.GetOutputSizes((int)ImageFormatType.Jpeg)!;
|
|
||||||
var size = allSizes.OrderBy(s => Math.Abs(s.Width - 640)).First();
|
|
||||||
|
|
||||||
st.SetDefaultBufferSize(size.Width, size.Height);
|
|
||||||
_imageReader = ImageReader.NewInstance(size.Width, size.Height, ImageFormatType.Jpeg, 2);
|
|
||||||
_imageReader.SetOnImageAvailableListener(new ImageListener(this), _bgHandler);
|
|
||||||
|
|
||||||
mgr.OpenCamera(cameraId, new OpenCb(this, st), _bgHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseCamera()
|
|
||||||
{
|
|
||||||
_session?.Close(); _session = null;
|
|
||||||
_camera?.Close(); _camera = null;
|
|
||||||
_imageReader?.Close(); _imageReader = null;
|
|
||||||
_bgThread?.QuitSafely();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void OnCameraOpened(CameraDevice dev, SurfaceTexture st)
|
|
||||||
{
|
|
||||||
_camera = dev;
|
|
||||||
var prevSurf = new Surface(st);
|
|
||||||
var capSurf = _imageReader!.Surface!;
|
|
||||||
#pragma warning disable CA1422 // CreateCaptureSession(IList,...) obsoleted on API 30; replacement needs API 28, our min is 23
|
|
||||||
dev.CreateCaptureSession([prevSurf, capSurf], new SessionCb(this, prevSurf, capSurf), _bgHandler);
|
|
||||||
#pragma warning restore CA1422
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void OnSessionReady(CameraCaptureSession session, Surface prev, Surface cap)
|
|
||||||
{
|
|
||||||
_session = session;
|
|
||||||
var req = _camera!.CreateCaptureRequest(CameraTemplate.Preview);
|
|
||||||
req.AddTarget(prev);
|
|
||||||
req.AddTarget(cap);
|
|
||||||
session.SetRepeatingRequest(req.Build()!, null, _bgHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void TryDecode(ImageReader reader)
|
|
||||||
{
|
|
||||||
if (_done) return;
|
|
||||||
var image = reader.AcquireLatestImage();
|
|
||||||
if (image == null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var now = SystemClock.ElapsedRealtime();
|
|
||||||
if (now - _lastDecode < 400) return; // max ~2.5 fps
|
|
||||||
_lastDecode = now;
|
|
||||||
|
|
||||||
var buf = image.GetPlanes()![0].Buffer!;
|
|
||||||
var bytes = new byte[buf.Remaining()];
|
|
||||||
buf.Get(bytes);
|
|
||||||
|
|
||||||
var bmp = BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length);
|
|
||||||
if (bmp == null) return;
|
|
||||||
int w = bmp.Width, h = bmp.Height;
|
|
||||||
var pixels = new int[w * h];
|
|
||||||
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
|
||||||
bmp.Recycle();
|
|
||||||
|
|
||||||
// ARGB int[] → RGB byte[] for ZXing.RGBLuminanceSource
|
|
||||||
var rgb = new byte[pixels.Length * 3];
|
|
||||||
for (int i = 0; i < pixels.Length; i++)
|
|
||||||
{
|
|
||||||
rgb[i * 3] = (byte)(pixels[i] >> 16);
|
|
||||||
rgb[i * 3 + 1] = (byte)(pixels[i] >> 8);
|
|
||||||
rgb[i * 3 + 2] = (byte) pixels[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
var source = new RGBLuminanceSource(rgb, w, h, RGBLuminanceSource.BitmapFormat.RGB24);
|
|
||||||
var zreader = new BarcodeReaderGeneric { AutoRotate = true };
|
|
||||||
zreader.Options.PossibleFormats = [BarcodeFormat.QR_CODE];
|
|
||||||
zreader.Options.TryHarder = true;
|
|
||||||
var result = zreader.Decode(source);
|
|
||||||
if (result == null) return;
|
|
||||||
|
|
||||||
_done = true;
|
|
||||||
RunOnUiThread(() =>
|
|
||||||
{
|
|
||||||
var intent = new Intent().PutExtra(ResultKey, result.Text);
|
|
||||||
SetResult(AndroidResult.Ok, intent);
|
|
||||||
Finish();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
finally { image.Close(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDestroy()
|
|
||||||
{
|
|
||||||
CloseCamera();
|
|
||||||
base.OnDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Callback helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private sealed class OpenCb(ScannerActivity a, SurfaceTexture st) : CameraDevice.StateCallback
|
|
||||||
{
|
|
||||||
public override void OnOpened(CameraDevice cam) => a.OnCameraOpened(cam, st);
|
|
||||||
public override void OnDisconnected(CameraDevice cam) { cam.Close(); a.Finish(); }
|
|
||||||
public override void OnError(CameraDevice cam, CameraError err) { cam.Close(); a.Finish(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class SessionCb(ScannerActivity a, Surface prev, Surface cap) : CameraCaptureSession.StateCallback
|
|
||||||
{
|
|
||||||
public override void OnConfigured(CameraCaptureSession s) => a.OnSessionReady(s, prev, cap);
|
|
||||||
public override void OnConfigureFailed(CameraCaptureSession s) => a.Finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ImageListener(ScannerActivity a) : Java.Lang.Object, ImageReader.IOnImageAvailableListener
|
|
||||||
{
|
|
||||||
// Interface declares ImageReader? but reader is never null in practice
|
|
||||||
public void OnImageAvailable(ImageReader? reader) => a.TryDecode(reader!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<!-- Head Desktop (Windows/Linux): entry-point e pacchetti specifici del
|
|
||||||
desktop. La UI vera vive nella libreria condivisa PalladiumWallet.App. -->
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>WinExe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
|
||||||
<ApplicationIcon>..\App\Assets\logo.ico</ApplicationIcon>
|
|
||||||
<AssemblyName>PalladiumWallet</AssemblyName>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
|
||||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -8,20 +8,8 @@
|
|||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<!-- Semantic design tokens (colors, radii, brand accent): defined for
|
|
||||||
both light/dark variants. See Styles/Theme.axaml. -->
|
|
||||||
<Application.Resources>
|
|
||||||
<ResourceDictionary>
|
|
||||||
<ResourceDictionary.MergedDictionaries>
|
|
||||||
<ResourceInclude Source="/Styles/Theme.axaml"/>
|
|
||||||
</ResourceDictionary.MergedDictionaries>
|
|
||||||
</ResourceDictionary>
|
|
||||||
</Application.Resources>
|
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<!-- Control refinements and reusable classes (on top of Fluent) -->
|
|
||||||
<StyleInclude Source="/Styles/Controls.axaml"/>
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
@@ -1,34 +1,31 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Data.Core;
|
||||||
using PalladiumWallet.App.ViewModels;
|
using Avalonia.Data.Core.Plugins;
|
||||||
using PalladiumWallet.App.Views;
|
using System.Linq;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
namespace PalladiumWallet.App;
|
using PalladiumWallet.App.ViewModels;
|
||||||
|
using PalladiumWallet.App.Views;
|
||||||
public partial class App : Application
|
|
||||||
{
|
namespace PalladiumWallet.App;
|
||||||
public override void Initialize()
|
|
||||||
{
|
public partial class App : Application
|
||||||
AvaloniaXamlLoader.Load(this);
|
{
|
||||||
}
|
public override void Initialize()
|
||||||
|
{
|
||||||
public override void OnFrameworkInitializationCompleted()
|
AvaloniaXamlLoader.Load(this);
|
||||||
{
|
}
|
||||||
var vm = new MainWindowViewModel();
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
{
|
||||||
// view. Same shared UI (MainView) and same ViewModel.
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
switch (ApplicationLifetime)
|
{
|
||||||
{
|
desktop.MainWindow = new MainWindow
|
||||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
{
|
||||||
desktop.MainWindow = new MainWindow { DataContext = vm };
|
DataContext = new MainWindowViewModel(),
|
||||||
break;
|
};
|
||||||
case ISingleViewApplicationLifetime singleView:
|
}
|
||||||
singleView.MainView = new MainView { DataContext = vm };
|
|
||||||
break;
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -1,395 +1,166 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
namespace PalladiumWallet.App.Localization;
|
namespace PalladiumWallet.App.Localization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// UI localisation: key → per-language translations dictionary, with an
|
/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con
|
||||||
/// indexer bindable from XAML ({Binding Loc[key]}). On language change the
|
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
|
||||||
/// ViewModel replaces the instance so Avalonia re-evaluates all bindings.
|
/// notifica "Item[]" e tutte le binding si aggiornano.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Loc
|
public sealed class Loc : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public static Loc Instance { get; private set; } = new();
|
public static Loc Instance { get; } = new();
|
||||||
|
|
||||||
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
|
public static readonly string[] Languages = ["it", "en"];
|
||||||
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
|
public static readonly string[] LanguageNames = ["Italiano", "English"];
|
||||||
|
|
||||||
public string Language { get; private set; } = "en";
|
public string Language { get; private set; } = "it";
|
||||||
|
|
||||||
private Loc() { }
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
private Loc(string language) { Language = language; }
|
|
||||||
|
|
||||||
/// <summary>
|
public void SetLanguage(string language)
|
||||||
/// Creates a new instance for the specified language and updates the singleton
|
|
||||||
/// used by <see cref="Tr"/>. The ViewModel assigns this instance to its Loc
|
|
||||||
/// property so Avalonia sees a different reference and re-evaluates all
|
|
||||||
/// {Binding Loc[key]} bindings.
|
|
||||||
/// </summary>
|
|
||||||
internal static Loc SwitchTo(string language)
|
|
||||||
{
|
{
|
||||||
if (System.Array.IndexOf(Languages, language) < 0) language = "en";
|
if (Language == language || System.Array.IndexOf(Languages, language) < 0)
|
||||||
var loc = new Loc(language);
|
return;
|
||||||
Instance = loc;
|
Language = language;
|
||||||
return loc;
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string this[string key] =>
|
public string this[string key] =>
|
||||||
Strings.TryGetValue(key, out var values)
|
Strings.TryGetValue(key, out var values)
|
||||||
? values[System.Math.Max(0, System.Array.IndexOf(Languages, Language))]
|
? values[Language == "en" ? 1 : 0]
|
||||||
: key;
|
: key;
|
||||||
|
|
||||||
public static string Tr(string key) => Instance[key];
|
public static string Tr(string key) => Instance[key];
|
||||||
|
|
||||||
private static readonly Dictionary<string, string[]> Strings = new()
|
private static readonly Dictionary<string, string[]> Strings = new()
|
||||||
{
|
{
|
||||||
// Menu it en es fr pt de
|
// Menu
|
||||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
|
["menu.file"] = ["_File", "_File"],
|
||||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
|
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"],
|
||||||
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…", "Abrir wallet desde archivo…", "Ouvrir le wallet depuis un fichier…", "Abrir carteira de arquivo…", "Wallet aus Datei öffnen…"],
|
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"],
|
||||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["menu.file.close"] = ["Chiudi wallet", "Close wallet"],
|
||||||
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
|
["menu.net"] = ["_Rete", "_Network"],
|
||||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
|
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"],
|
||||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
|
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"],
|
||||||
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
|
["menu.settings"] = ["_Impostazioni", "_Settings"],
|
||||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
["settings.unit.short"] = ["Unità", "Unit"],
|
||||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
|
||||||
["help.info"] = [
|
|
||||||
"Wallet SPV per la criptovaluta Palladium (PLM).",
|
|
||||||
"SPV wallet for the Palladium (PLM) cryptocurrency.",
|
|
||||||
"Monedero SPV para la criptomoneda Palladium (PLM).",
|
|
||||||
"Portefeuille SPV pour la cryptomonnaie Palladium (PLM).",
|
|
||||||
"Carteira SPV para a criptomoeda Palladium (PLM).",
|
|
||||||
"SPV-Wallet für die Kryptowährung Palladium (PLM)."],
|
|
||||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
|
||||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
|
||||||
["donate.desc"] = [
|
|
||||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
|
||||||
"If you find this wallet useful, consider a small donation to the developer.",
|
|
||||||
"Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.",
|
|
||||||
"Si ce portefeuille vous est utile, envisagez un petit don au développeur.",
|
|
||||||
"Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.",
|
|
||||||
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler."],
|
|
||||||
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse"],
|
|
||||||
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag"],
|
|
||||||
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten"],
|
|
||||||
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden"],
|
|
||||||
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
|
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
|
["wiz.net"] = ["Rete:", "Network:"],
|
||||||
["wiz.data.info"] = [
|
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"],
|
||||||
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
|
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"],
|
||||||
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
|
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"],
|
||||||
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
|
["wiz.open.title"] = ["Apri il wallet", "Open the wallet"],
|
||||||
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
|
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"],
|
||||||
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
|
["wiz.open.ok"] = ["Apri", "Open"],
|
||||||
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
|
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"],
|
||||||
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
|
["wiz.seed.warning"] = [
|
||||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
|
||||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
|
||||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
|
||||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
|
||||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
|
|
||||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
|
||||||
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren"],
|
|
||||||
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"],
|
|
||||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
|
|
||||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"],
|
|
||||||
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
|
|
||||||
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)"],
|
|
||||||
["wiz.seed.warning"] = [
|
|
||||||
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
|
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
|
||||||
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable.",
|
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable."],
|
||||||
"Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
|
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next"],
|
||||||
"Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
|
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"],
|
||||||
"Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
|
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"],
|
||||||
"Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren."],
|
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"],
|
||||||
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter"],
|
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"],
|
||||||
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"],
|
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"],
|
||||||
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben"],
|
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"],
|
||||||
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
["wiz.password.title"] = ["Password del file wallet", "Wallet file password"],
|
||||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
|
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"],
|
||||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
["wiz.password.create"] = ["Crea il wallet", "Create wallet"],
|
||||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
|
["wiz.back"] = ["Indietro", "Back"],
|
||||||
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
|
["wiz.next"] = ["Avanti", "Next"],
|
||||||
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)"],
|
|
||||||
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen."],
|
|
||||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
|
|
||||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
|
|
||||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
|
|
||||||
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"],
|
|
||||||
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"],
|
|
||||||
["wiz.password.encrypt.hint"] = [
|
|
||||||
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
|
|
||||||
"Warning: without encryption the seed stays in plaintext on disk.",
|
|
||||||
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
|
|
||||||
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
|
|
||||||
"Atenção: sem criptografia a semente fica em texto simples no disco.",
|
|
||||||
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
|
|
||||||
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
|
|
||||||
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
|
|
||||||
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen"],
|
|
||||||
["wiz.scripttype.hint"] = [
|
|
||||||
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
|
|
||||||
"Determines the address format. If unsure, use Native SegWit.",
|
|
||||||
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
|
|
||||||
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
|
|
||||||
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
|
|
||||||
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit."],
|
|
||||||
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen"],
|
|
||||||
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen"],
|
|
||||||
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen"],
|
|
||||||
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen"],
|
|
||||||
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"],
|
|
||||||
["wiz.importxkey.hint"] = [
|
|
||||||
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
|
|
||||||
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
|
|
||||||
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
|
|
||||||
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
|
|
||||||
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
|
|
||||||
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt."],
|
|
||||||
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
|
||||||
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren"],
|
|
||||||
["wiz.importwif.hint"] = [
|
|
||||||
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
|
|
||||||
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
|
|
||||||
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
|
|
||||||
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
|
|
||||||
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
|
|
||||||
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."],
|
|
||||||
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"],
|
|
||||||
|
|
||||||
// Import error messages
|
// Pannello wallet
|
||||||
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."],
|
["wallet.close"] = ["Chiudi wallet", "Close wallet"],
|
||||||
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."],
|
["wallet.server"] = ["Server:", "Server:"],
|
||||||
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."],
|
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"],
|
||||||
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."],
|
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"],
|
||||||
|
["wallet.discover"] = ["Cerca altri server", "Discover servers"],
|
||||||
// Wallet panel
|
["wallet.resetcert"] = ["Reset cert.", "Reset certs"],
|
||||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["tab.receive"] = ["Ricevi", "Receive"],
|
||||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
["tab.history"] = ["Storico", "History"],
|
||||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
["tab.addresses"] = ["Indirizzi", "Addresses"],
|
||||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
["tab.send"] = ["Invia", "Send"],
|
||||||
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
|
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"],
|
||||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
["receive.hint"] = [
|
||||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
|
||||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
|
||||||
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
|
|
||||||
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
|
|
||||||
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"],
|
|
||||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"],
|
|
||||||
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"],
|
|
||||||
["receive.hint"] = [
|
|
||||||
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
||||||
"Payments received here will appear in the history at the next synchronization.",
|
"Payments received here will appear in the history at the next synchronization."],
|
||||||
"Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
|
["addr.type"] = ["Tipo", "Type"],
|
||||||
"Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
|
["addr.index"] = ["Indice", "Index"],
|
||||||
"Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
|
["addr.address"] = ["Indirizzo", "Address"],
|
||||||
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."],
|
["addr.balance"] = ["Saldo", "Balance"],
|
||||||
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"],
|
["addr.receive"] = ["ricezione", "receive"],
|
||||||
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
|
["addr.change"] = ["change", "change"],
|
||||||
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
["send.to"] = ["Indirizzo destinatario", "Recipient address"],
|
||||||
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
|
["send.amount"] = ["Importo", "Amount"],
|
||||||
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"],
|
["send.all"] = ["Invia tutto", "Send all"],
|
||||||
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
|
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"],
|
||||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
|
["send.prepare"] = ["Prepara transazione", "Prepare transaction"],
|
||||||
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
|
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"],
|
||||||
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
|
|
||||||
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
|
||||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
|
||||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
|
||||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
|
||||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
|
||||||
|
|
||||||
// History → transaction detail
|
// Stato connessione
|
||||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
["conn.none"] = ["non connesso", "not connected"],
|
||||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
["conn.disconnected"] = ["disconnesso", "disconnected"],
|
||||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
["conn.reconnecting"] = ["riconnessione…", "reconnecting…"],
|
||||||
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"],
|
["conn.error"] = ["errore di connessione", "connection error"],
|
||||||
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"],
|
["conn.certchanged"] = ["certificato cambiato", "certificate changed"],
|
||||||
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"],
|
["conn.connectedto"] = ["connesso a", "connected to"],
|
||||||
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
|
["conn.connectingto"] = ["connessione a", "connecting to"],
|
||||||
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"],
|
|
||||||
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"],
|
|
||||||
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
|
|
||||||
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
|
|
||||||
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
|
|
||||||
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
|
|
||||||
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"],
|
|
||||||
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"],
|
|
||||||
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"],
|
|
||||||
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"],
|
|
||||||
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
|
|
||||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
|
|
||||||
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
|
|
||||||
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
|
|
||||||
["tx.verified"] = ["Verifica SPV", "SPV verification", "Verificación SPV", "Vérification SPV", "Verificação SPV", "SPV-Prüfung"],
|
|
||||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
|
||||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
|
||||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
|
||||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
|
||||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
|
||||||
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht"],
|
|
||||||
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren"],
|
|
||||||
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details"],
|
|
||||||
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase"],
|
|
||||||
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)"],
|
|
||||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
|
||||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
|
||||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
|
||||||
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
|
|
||||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
|
||||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
|
||||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
|
||||||
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
|
||||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
|
||||||
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger"],
|
|
||||||
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr"],
|
|
||||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
|
|
||||||
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
|
|
||||||
|
|
||||||
// Connection status
|
// Messaggi di stato principali
|
||||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
["msg.welcome.existing"] = [
|
||||||
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
|
||||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
|
|
||||||
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"],
|
|
||||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
|
|
||||||
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
|
|
||||||
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
|
|
||||||
|
|
||||||
// Main status messages
|
|
||||||
["msg.welcome.existing"] = [
|
|
||||||
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
||||||
"Found an existing wallet on this network: open it, or create another one.",
|
"Found an existing wallet on this network: open it, or create another one."],
|
||||||
"Se encontró un wallet existente en esta red: ábrelo o crea otro.",
|
["msg.welcome.new"] = [
|
||||||
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
|
|
||||||
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
|
|
||||||
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues."],
|
|
||||||
["msg.welcome.new"] = [
|
|
||||||
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
||||||
"Welcome: create a new wallet or restore from seed.",
|
"Welcome: create a new wallet or restore from seed."],
|
||||||
"Bienvenido: crea un nuevo wallet o restaura desde semilla.",
|
["msg.open.password"] = [
|
||||||
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
|
|
||||||
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
|
|
||||||
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."],
|
|
||||||
["msg.open.password"] = [
|
|
||||||
"Inserisci la password del file (lascia vuoto se non impostata).",
|
"Inserisci la password del file (lascia vuoto se non impostata).",
|
||||||
"Enter the file password (leave empty if not set).",
|
"Enter the file password (leave empty if not set)."],
|
||||||
"Ingresa la contraseña del archivo (deja vacío si no establecida).",
|
["msg.seed.write"] = [
|
||||||
"Entrez le mot de passe du fichier (laisser vide si non défini).",
|
|
||||||
"Digite a senha do arquivo (deixe vazio se não definida).",
|
|
||||||
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."],
|
|
||||||
["msg.seed.write"] = [
|
|
||||||
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
||||||
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
|
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet."],
|
||||||
"Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
|
["msg.seed.retype"] = [
|
||||||
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
|
|
||||||
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
|
|
||||||
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets."],
|
|
||||||
["msg.seed.retype"] = [
|
|
||||||
"Reinserisci le 12 parole per confermare di averle scritte.",
|
"Reinserisci le 12 parole per confermare di averle scritte.",
|
||||||
"Re-enter the 12 words to confirm you wrote them down.",
|
"Re-enter the 12 words to confirm you wrote them down."],
|
||||||
"Reingresa las 12 palabras para confirmar que las has anotado.",
|
["msg.seed.mismatch"] = [
|
||||||
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
|
|
||||||
"Reinsira as 12 palavras para confirmar que as anotou.",
|
|
||||||
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."],
|
|
||||||
["msg.seed.mismatch"] = [
|
|
||||||
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
||||||
"The words do not match: check what you wrote on paper.",
|
"The words do not match: check what you wrote on paper."],
|
||||||
"Las palabras no coinciden: revisa lo que escribiste en papel.",
|
["msg.words.enter"] = [
|
||||||
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
|
|
||||||
"As palavras não correspondem: verifique o que escreveu no papel.",
|
|
||||||
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."],
|
|
||||||
["msg.words.enter"] = [
|
|
||||||
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
||||||
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
|
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces)."],
|
||||||
"Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
|
["msg.words.invalid"] = [
|
||||||
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
|
|
||||||
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
|
|
||||||
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."],
|
|
||||||
["msg.words.invalid"] = [
|
|
||||||
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
||||||
"Invalid mnemonic (wrong words or checksum): check again.",
|
"Invalid mnemonic (wrong words or checksum): check again."],
|
||||||
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
|
["msg.passphrase.info"] = [
|
||||||
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
|
|
||||||
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
|
|
||||||
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."],
|
|
||||||
["msg.passphrase.info"] = [
|
|
||||||
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
|
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
|
||||||
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip.",
|
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip."],
|
||||||
"Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
|
["msg.password.info"] = [
|
||||||
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
|
|
||||||
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
|
|
||||||
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen."],
|
|
||||||
["msg.password.info"] = [
|
|
||||||
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
|
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
|
||||||
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file.",
|
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file."],
|
||||||
"Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
|
["msg.wrongpassword"] = ["Password errata.", "Wrong password."],
|
||||||
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
|
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…"],
|
||||||
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
|
["msg.synced"] = ["Sincronizzato", "Synchronized"],
|
||||||
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
|
["msg.synced.detail"] = [
|
||||||
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
|
|
||||||
["msg.password.required"] = [
|
|
||||||
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
|
|
||||||
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
|
|
||||||
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
|
|
||||||
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
|
|
||||||
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
|
|
||||||
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
|
|
||||||
["msg.password.mismatch"] = [
|
|
||||||
"Le due password non coincidono.",
|
|
||||||
"The two passwords do not match.",
|
|
||||||
"Las dos contraseñas no coinciden.",
|
|
||||||
"Les deux mots de passe ne correspondent pas.",
|
|
||||||
"As duas senhas não coincidem.",
|
|
||||||
"Die beiden Passwörter stimmen nicht überein."],
|
|
||||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
|
|
||||||
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."],
|
|
||||||
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."],
|
|
||||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
|
|
||||||
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
|
|
||||||
["msg.synced.detail"] = [
|
|
||||||
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
||||||
"SPV-verified transactions. Real-time updates active.",
|
"SPV-verified transactions. Real-time updates active."],
|
||||||
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
|
["msg.height"] = ["altezza", "height"],
|
||||||
"transactions vérifiées SPV. Mises à jour en temps réel actives.",
|
["msg.pending"] = ["in attesa di conferma", "pending confirmation"],
|
||||||
"transações verificadas SPV. Atualizações em tempo real ativas.",
|
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"],
|
||||||
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
|
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."],
|
||||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
["msg.certreset"] = [
|
||||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
|
||||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
|
||||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
|
||||||
["msg.certreset"] = [
|
|
||||||
"Certificati SSL azzerati: riprova la connessione.",
|
"Certificati SSL azzerati: riprova la connessione.",
|
||||||
"SSL certificates cleared: retry the connection.",
|
"SSL certificates cleared: retry the connection."],
|
||||||
"Certificados SSL restablecidos: reintenta la conexión.",
|
["msg.error"] = ["Errore", "Error"],
|
||||||
"Certificats SSL réinitialisés : réessayez la connexion.",
|
|
||||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
|
||||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
|
||||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
|
||||||
|
|
||||||
// Contacts
|
// Finestra impostazioni
|
||||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
["settings.title"] = ["Impostazioni", "Settings"],
|
||||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
["settings.language"] = ["Lingua", "Language"],
|
||||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
|
["settings.unit"] = ["Unità degli importi", "Amount unit"],
|
||||||
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
|
["settings.ok"] = ["Salva", "Save"],
|
||||||
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
|
["settings.cancel"] = ["Annulla", "Cancel"],
|
||||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
|
||||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
|
||||||
|
|
||||||
// Settings window
|
|
||||||
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
|
|
||||||
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
|
|
||||||
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
|
|
||||||
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
|
|
||||||
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
|
|
||||||
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
|
|
||||||
|
|
||||||
// Server window
|
|
||||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
|
||||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
|
||||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
|
||||||
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
|
|
||||||
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<!-- Libreria UI condivisa: il codice Avalonia (App, Views, ViewModels, Loc,
|
<PropertyGroup>
|
||||||
Assets) usato sia dall'head Desktop sia dall'head Android. Gli head
|
<OutputType>WinExe</OutputType>
|
||||||
portano l'entry-point e i pacchetti specifici di piattaforma. -->
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<PropertyGroup>
|
<Nullable>enable</Nullable>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
</PropertyGroup>
|
||||||
<Version>0.9.0</Version>
|
|
||||||
<Nullable>enable</Nullable>
|
<ItemGroup>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<Folder Include="Models\" />
|
||||||
</PropertyGroup>
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Models\" />
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<ItemGroup>
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
</PackageReference>
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seam for platform-specific services (Android/desktop).
|
|
||||||
/// The Android head sets the delegates in OnCreate; desktop leaves them null.
|
|
||||||
/// </summary>
|
|
||||||
public static class PlatformServices
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the native QR scanner and returns the raw code text,
|
|
||||||
/// or null if the user cancels or the scanner is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
|
||||||
<!-- ============================================================
|
|
||||||
Palladium wallet control styles ("clean fintech").
|
|
||||||
Applied on top of FluentTheme: refine spacing, states and
|
|
||||||
reusable classes without re-templating controls. The "accent"
|
|
||||||
buttons remain managed by Fluent but inherit the brand blue
|
|
||||||
via SystemAccentColor (see Theme.axaml).
|
|
||||||
============================================================ -->
|
|
||||||
|
|
||||||
<Design.PreviewWith>
|
|
||||||
<Border Padding="20" Width="320">
|
|
||||||
<StackPanel Spacing="12">
|
|
||||||
<TextBlock Classes="h1" Text="Saldo disponibile"/>
|
|
||||||
<TextBlock Classes="amount" Text="1 234,56 PLM"/>
|
|
||||||
<Button Classes="accent" Content="Invia"/>
|
|
||||||
<Button Content="Annulla"/>
|
|
||||||
<Border Classes="card" Padding="16">
|
|
||||||
<TextBlock Text="Card di esempio"/>
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Design.PreviewWith>
|
|
||||||
|
|
||||||
<!-- ===== Typography ===== -->
|
|
||||||
<Style Selector="TextBlock.h1">
|
|
||||||
<Setter Property="FontSize" Value="20"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.label">
|
|
||||||
<Setter Property="FontSize" Value="11"/>
|
|
||||||
<Setter Property="FontWeight" Value="Medium"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.muted">
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Amounts and numeric data: tabular figures to prevent layout jumps -->
|
|
||||||
<Style Selector="TextBlock.amount">
|
|
||||||
<Setter Property="FontSize" Value="34"/>
|
|
||||||
<Setter Property="FontWeight" Value="Bold"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
|
||||||
<Setter Property="FontFeatures" Value="+tnum"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
|
|
||||||
<Setter Property="FontFamily" Value="monospace"/>
|
|
||||||
<Setter Property="FontFeatures" Value="+tnum"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Section heading (e.g. transaction detail) -->
|
|
||||||
<Style Selector="TextBlock.section">
|
|
||||||
<Setter Property="FontSize" Value="11"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}"/>
|
|
||||||
<Setter Property="LetterSpacing" Value="0.8"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ===== Buttons ===== -->
|
|
||||||
<Style Selector="Button">
|
|
||||||
<Setter Property="MinHeight" Value="36"/>
|
|
||||||
<Setter Property="Padding" Value="14,8"/>
|
|
||||||
<Setter Property="Cursor" Value="Hand"/>
|
|
||||||
<Setter Property="FontWeight" Value="Medium"/>
|
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Accent (CTA) buttons are already blue via Fluent+SystemAccentColor.
|
|
||||||
Ensure readable white text in both light and dark variants. -->
|
|
||||||
<Style Selector="Button.accent">
|
|
||||||
<Setter Property="Foreground" Value="#FFFFFF"/>
|
|
||||||
</Style>
|
|
||||||
<!-- "ghost" variant: subtle secondary action, no fill -->
|
|
||||||
<Style Selector="Button.ghost">
|
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
|
||||||
<Setter Property="BorderThickness" Value="0"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ===== Inputs ===== -->
|
|
||||||
<Style Selector="TextBox">
|
|
||||||
<Setter Property="MinHeight" Value="36"/>
|
|
||||||
<Setter Property="Padding" Value="10,7"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ComboBox">
|
|
||||||
<Setter Property="MinHeight" Value="36"/>
|
|
||||||
<Setter Property="Padding" Value="10,6"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ===== Lists ===== -->
|
|
||||||
<Style Selector="ListBox">
|
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
|
||||||
<Setter Property="BorderThickness" Value="0"/>
|
|
||||||
<Setter Property="Padding" Value="0"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBoxItem">
|
|
||||||
<Setter Property="Padding" Value="12,10"/>
|
|
||||||
<Setter Property="CornerRadius" Value="8"/>
|
|
||||||
<Setter Property="Margin" Value="0,1"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Hover: neutral tint; selected: subtle brand-blue tint -->
|
|
||||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource PrimarySoftBrush}"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ===== Tabs (dashboard) ===== -->
|
|
||||||
<!-- Selected item: brand blue + bold; unselected: muted -->
|
|
||||||
<Style Selector="TabItem">
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
|
||||||
<Setter Property="FontWeight" Value="Medium"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TabItem:selected">
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TabItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Push tab header content up so the 2px selection indicator (drawn at Panel bottom)
|
|
||||||
does not overlap the text label. -->
|
|
||||||
<Style Selector="TabItem /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ===== Reusable classes ===== -->
|
|
||||||
<!-- Card: elevated surface with a subtle border and soft corners -->
|
|
||||||
<Style Selector="Border.card">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
|
||||||
<Setter Property="BorderThickness" Value="1"/>
|
|
||||||
<Setter Property="CornerRadius" Value="14"/>
|
|
||||||
<Setter Property="BoxShadow" Value="0 1 3 0 #14000000"/>
|
|
||||||
<Setter Property="Transitions">
|
|
||||||
<Transitions>
|
|
||||||
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.18"/>
|
|
||||||
</Transitions>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.card:pointerover">
|
|
||||||
<Setter Property="BoxShadow" Value="0 6 16 -4 #26000000"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Balance card: subtle blue accent, slightly more prominent -->
|
|
||||||
<Style Selector="Border.balance-card">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
|
||||||
<Setter Property="BorderThickness" Value="1"/>
|
|
||||||
<Setter Property="CornerRadius" Value="14"/>
|
|
||||||
<Setter Property="Padding" Value="20,16"/>
|
|
||||||
<Setter Property="BoxShadow" Value="0 2 8 0 #1A000000"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Hero balance card: brand gradient + blue glow. Signature dashboard
|
|
||||||
element; overlaid text is white (on-hero class). -->
|
|
||||||
<Style Selector="Border.balance-hero">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource BalanceGradientBrush}"/>
|
|
||||||
<Setter Property="CornerRadius" Value="16"/>
|
|
||||||
<Setter Property="Padding" Value="24,20"/>
|
|
||||||
<Setter Property="BoxShadow" Value="0 8 24 -6 #663B82F6"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.balance-hero TextBlock.amount">
|
|
||||||
<Setter Property="Foreground" Value="#FFFFFF"/>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.balance-hero TextBlock.on-hero">
|
|
||||||
<Setter Property="Foreground" Value="#D6E2FF"/>
|
|
||||||
</Style>
|
|
||||||
<!-- Status pill / badge -->
|
|
||||||
<Style Selector="Border.chip">
|
|
||||||
<Setter Property="CornerRadius" Value="20"/>
|
|
||||||
<Setter Property="Padding" Value="10,5"/>
|
|
||||||
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
|
||||||
</Style>
|
|
||||||
</Styles>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
|
||||||
<!-- ============================================================
|
|
||||||
Palladium wallet design tokens.
|
|
||||||
Identity: cobalt blue (trust) + green (money/positive),
|
|
||||||
derived from the logo. "Clean fintech" style.
|
|
||||||
|
|
||||||
All colors are SEMANTIC (role, not name): components
|
|
||||||
must never use inline hex but these brushes. Defined for
|
|
||||||
both variants (light/dark) via ThemeDictionaries, so
|
|
||||||
the app follows the system theme with no further configuration.
|
|
||||||
============================================================ -->
|
|
||||||
|
|
||||||
<ResourceDictionary.ThemeDictionaries>
|
|
||||||
|
|
||||||
<!-- ============ LIGHT THEME ============ -->
|
|
||||||
<ResourceDictionary x:Key="Light">
|
|
||||||
<!-- Surfaces -->
|
|
||||||
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F1F5F9"/> <!-- slate-100 -->
|
|
||||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
|
|
||||||
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
|
||||||
<SolidColorBrush x:Key="OverlayCardBrush" Color="#FFFFFF"/>
|
|
||||||
<SolidColorBrush x:Key="ScrimBrush" Color="#66000000"/> <!-- 40% -->
|
|
||||||
<!-- Borders and separators -->
|
|
||||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#E2E8F0"/> <!-- slate-200 -->
|
|
||||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CBD5E1"/> <!-- slate-300 -->
|
|
||||||
<!-- Text -->
|
|
||||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#0F172A"/> <!-- slate-900 -->
|
|
||||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#475569"/> <!-- slate-600 -->
|
|
||||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
|
||||||
<!-- Brand / state -->
|
|
||||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB"/> <!-- blue-600 -->
|
|
||||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#1D4ED8"/> <!-- blue-700 -->
|
|
||||||
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#FFFFFF"/>
|
|
||||||
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#EFF6FF"/> <!-- blue-50 -->
|
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#16A34A"/> <!-- green-600 -->
|
|
||||||
<SolidColorBrush x:Key="WarningBrush" Color="#D97706"/> <!-- amber-600 -->
|
|
||||||
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626"/> <!-- red-600 -->
|
|
||||||
<!-- Hero gradient of the balance card: blue → indigo -->
|
|
||||||
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
|
||||||
<GradientStop Offset="0" Color="#2563EB"/>
|
|
||||||
<GradientStop Offset="1" Color="#4F46E5"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</ResourceDictionary>
|
|
||||||
|
|
||||||
<!-- ============ DARK THEME ============ -->
|
|
||||||
<ResourceDictionary x:Key="Dark">
|
|
||||||
<!-- Surfaces -->
|
|
||||||
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#0F172A"/> <!-- slate-900 -->
|
|
||||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E293B"/> <!-- slate-800 -->
|
|
||||||
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#273449"/>
|
|
||||||
<SolidColorBrush x:Key="OverlayCardBrush" Color="#1E293B"/>
|
|
||||||
<SolidColorBrush x:Key="ScrimBrush" Color="#99000000"/> <!-- 60% -->
|
|
||||||
<!-- Borders and separators -->
|
|
||||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#334155"/> <!-- slate-700 -->
|
|
||||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#475569"/> <!-- slate-600 -->
|
|
||||||
<!-- Text -->
|
|
||||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
|
||||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
|
||||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#64748B"/> <!-- slate-500 -->
|
|
||||||
<!-- Brand / state (brighter tones on dark background) -->
|
|
||||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#3B82F6"/> <!-- blue-500 -->
|
|
||||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#60A5FA"/> <!-- blue-400 -->
|
|
||||||
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#0B1120"/>
|
|
||||||
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#1E3A8A"/> <!-- blue-900 -->
|
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#22C55E"/> <!-- green-500 -->
|
|
||||||
<SolidColorBrush x:Key="WarningBrush" Color="#F59E0B"/> <!-- amber-500 -->
|
|
||||||
<SolidColorBrush x:Key="DangerBrush" Color="#EF4444"/> <!-- red-500 -->
|
|
||||||
<!-- Hero gradient of the balance card: blue → indigo (deeper tones) -->
|
|
||||||
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
|
||||||
<GradientStop Offset="0" Color="#1D4ED8"/>
|
|
||||||
<GradientStop Offset="1" Color="#4338CA"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</ResourceDictionary>
|
|
||||||
|
|
||||||
</ResourceDictionary.ThemeDictionaries>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Override of the Fluent accent → brand blue. Recolors
|
|
||||||
consistently the controls that read SystemAccentColor (accent
|
|
||||||
buttons, selections, tab indicator, focus) without retemplating anything.
|
|
||||||
Variant-independent: #2563EB works on light and dark.
|
|
||||||
============================================================ -->
|
|
||||||
<Color x:Key="SystemAccentColor">#2563EB</Color>
|
|
||||||
<Color x:Key="SystemAccentColorDark1">#1D4ED8</Color>
|
|
||||||
<Color x:Key="SystemAccentColorDark2">#1E40AF</Color>
|
|
||||||
<Color x:Key="SystemAccentColorDark3">#1E3A8A</Color>
|
|
||||||
<Color x:Key="SystemAccentColorLight1">#3B82F6</Color>
|
|
||||||
<Color x:Key="SystemAccentColorLight2">#60A5FA</Color>
|
|
||||||
<Color x:Key="SystemAccentColorLight3">#93C5FD</Color>
|
|
||||||
|
|
||||||
<!-- Global Fluent control corner radii: rounds buttons, inputs, combos,
|
|
||||||
and lists uniformly without retemplating each control. -->
|
|
||||||
<CornerRadius x:Key="ControlCornerRadius">8</CornerRadius>
|
|
||||||
<CornerRadius x:Key="OverlayCornerRadius">12</CornerRadius>
|
|
||||||
</ResourceDictionary>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Data.Converters;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// true (mobile) → Dock.Bottom — tab strip at the bottom, Android standard.
|
|
||||||
/// false (desktop) → Dock.Top — Avalonia default behavior.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BoolToTabPlacementConverter : IValueConverter
|
|
||||||
{
|
|
||||||
public static readonly BoolToTabPlacementConverter Instance = new();
|
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
|
||||||
value is true ? Dock.Bottom : Dock.Top;
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Linq;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
public ObservableCollection<ContactEntry> Contacts { get; } = [];
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private ContactEntry? selectedContactInList;
|
|
||||||
|
|
||||||
/// <summary>Contact selected in the Send panel's ComboBox: fills SendTo.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private ContactEntry? sendToContact;
|
|
||||||
|
|
||||||
partial void OnSendToContactChanged(ContactEntry? value)
|
|
||||||
{
|
|
||||||
if (value is not null)
|
|
||||||
SendTo = value.Address;
|
|
||||||
}
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string newContactName = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string newContactAddress = "";
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void AddContact()
|
|
||||||
{
|
|
||||||
var name = NewContactName.Trim();
|
|
||||||
var addr = NewContactAddress.Trim();
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return;
|
|
||||||
Contacts.Add(new ContactEntry(name, addr));
|
|
||||||
NewContactName = NewContactAddress = "";
|
|
||||||
PersistContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void RemoveSelectedContact()
|
|
||||||
{
|
|
||||||
if (SelectedContactInList is { } c)
|
|
||||||
{
|
|
||||||
Contacts.Remove(c);
|
|
||||||
SelectedContactInList = null;
|
|
||||||
PersistContacts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PersistContacts()
|
|
||||||
{
|
|
||||||
if (_doc is null || _walletPath is null) return;
|
|
||||||
_doc.Contacts = Contacts
|
|
||||||
.Select(c => new StoredContact { Name = c.Name, Address = c.Address })
|
|
||||||
.ToList();
|
|
||||||
WalletStore.Save(_doc, _walletPath, _password);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadContacts()
|
|
||||||
{
|
|
||||||
Contacts.Clear();
|
|
||||||
if (_doc is null) return;
|
|
||||||
foreach (var c in _doc.Contacts)
|
|
||||||
Contacts.Add(new ContactEntry(c.Name, c.Address));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Net;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
public const string DevAddress = "plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string donateAmount = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string donatePreview = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool hasPendingDonate;
|
|
||||||
|
|
||||||
private BuiltTransaction? _pendingDonate;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task PrepareDonate()
|
|
||||||
{
|
|
||||||
_pendingDonate = null;
|
|
||||||
HasPendingDonate = false;
|
|
||||||
|
|
||||||
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
|
||||||
{
|
|
||||||
DonatePreview = "Sincronizza prima di inviare.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
|
||||||
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
|
||||||
{
|
|
||||||
DonatePreview = "Importo non valido.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture, out var feeRate) || feeRate <= 0)
|
|
||||||
feeRate = 1m;
|
|
||||||
|
|
||||||
_pendingDonate = new TransactionFactory(_account).Build(
|
|
||||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
|
||||||
_doc.Cache.NextChangeIndex, sendAll: false);
|
|
||||||
|
|
||||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
|
||||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
|
||||||
$"({_pendingDonate.Transaction.GetVirtualSize()} vB)" +
|
|
||||||
(_pendingDonate.Signed ? "" : " · watch-only");
|
|
||||||
HasPendingDonate = _pendingDonate.Signed;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_pendingDonate = null;
|
|
||||||
HasPendingDonate = false;
|
|
||||||
DonatePreview = $"Errore: {ex.Message}";
|
|
||||||
}
|
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ConfirmDonate()
|
|
||||||
{
|
|
||||||
if (_pendingDonate is null || _client is null)
|
|
||||||
return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
|
||||||
DonatePreview = $"Grazie! txid: {txid}";
|
|
||||||
DonateAmount = "";
|
|
||||||
_pendingDonate = null;
|
|
||||||
HasPendingDonate = false;
|
|
||||||
await ConnectAndSync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
DonatePreview = $"Errore broadcast: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ResetDonate()
|
|
||||||
{
|
|
||||||
DonateAmount = "";
|
|
||||||
DonatePreview = "";
|
|
||||||
HasPendingDonate = false;
|
|
||||||
_pendingDonate = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using PalladiumWallet.App.Localization;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Spv;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
using QRCoder;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
// ---- balance and wallet info ----
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string balanceText = "—";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string unconfirmedText = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string networkInfo = "";
|
|
||||||
|
|
||||||
// ---- receive address and QR ----
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string receiveAddress = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private Bitmap? receiveQr;
|
|
||||||
|
|
||||||
partial void OnReceiveAddressChanged(string value)
|
|
||||||
{
|
|
||||||
var previous = ReceiveQr;
|
|
||||||
ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value);
|
|
||||||
previous?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied");
|
|
||||||
|
|
||||||
private static Bitmap? GenerateQr(string text)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var generator = new QRCodeGenerator();
|
|
||||||
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
|
|
||||||
var png = new PngByteQRCode(data).GetGraphic(8);
|
|
||||||
return new Bitmap(new MemoryStream(png));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- addresses tab ----
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private AddressRow? selectedAddressRow;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private AddressInfo? addressInfo;
|
|
||||||
|
|
||||||
public void ShowAddressInfo(AddressRow row) =>
|
|
||||||
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CloseAddressInfo() => AddressInfo = null;
|
|
||||||
|
|
||||||
// ---- transaction detail overlay ----
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool isTxDetailsOpen;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool isTxDetailsLoading;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private TransactionDetailsViewModel? txDetails;
|
|
||||||
|
|
||||||
public async Task ShowTransactionDetailsAsync(string txid)
|
|
||||||
{
|
|
||||||
if (_client is null || !_client.IsConnected)
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("tx.needconnection");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_txDetailsCts?.Cancel();
|
|
||||||
_txDetailsCts = new CancellationTokenSource();
|
|
||||||
var ct = _txDetailsCts.Token;
|
|
||||||
|
|
||||||
TxDetails = null;
|
|
||||||
IsTxDetailsLoading = true;
|
|
||||||
IsTxDetailsOpen = true;
|
|
||||||
|
|
||||||
var details = await BuildTransactionDetailsAsync(txid, ct);
|
|
||||||
|
|
||||||
if (ct.IsCancellationRequested || !IsTxDetailsOpen)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (details is null)
|
|
||||||
{
|
|
||||||
IsTxDetailsOpen = false;
|
|
||||||
IsTxDetailsLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TxDetails = details;
|
|
||||||
IsTxDetailsLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CloseTransactionDetails()
|
|
||||||
{
|
|
||||||
_txDetailsCts?.Cancel();
|
|
||||||
IsTxDetailsOpen = false;
|
|
||||||
IsTxDetailsLoading = false;
|
|
||||||
TxDetails = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TransactionDetailsViewModel?> BuildTransactionDetailsAsync(
|
|
||||||
string txid, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (_client is null || !_client.IsConnected)
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("tx.needconnection");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (_doc?.Cache is not { } cache)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var client = _client;
|
|
||||||
var network = PalladiumNetworks.For(Net);
|
|
||||||
var row = cache.History.FirstOrDefault(t => t.Txid == txid);
|
|
||||||
var owned = cache.Addresses.Select(a => a.Address).ToHashSet();
|
|
||||||
var tipHeight = cache.TipHeight;
|
|
||||||
var height = row?.Height ?? 0;
|
|
||||||
var delta = row?.DeltaSats ?? 0;
|
|
||||||
var verified = row?.Verified ?? false;
|
|
||||||
var transactions = _lastTransactions;
|
|
||||||
var loc = _loc;
|
|
||||||
var unit = _config.Unit;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var details = await TransactionInspector.FetchAsync(
|
|
||||||
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
|
|
||||||
return new TransactionDetailsViewModel(details, loc, unit);
|
|
||||||
}, ct);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- update display from the sync result ----
|
|
||||||
|
|
||||||
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();
|
|
||||||
Addresses.Clear();
|
|
||||||
for (var i = 0; i < 10; i++)
|
|
||||||
Addresses.Add(new AddressRow(_loc["addr.receive"], i,
|
|
||||||
_account.GetReceiveAddress(i).ToString(), "—", "—",
|
|
||||||
false,
|
|
||||||
_account.GetPublicKey(false, i)?.ToHex() ?? "",
|
|
||||||
KeyWif(false, i),
|
|
||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
BalanceText = Fmt(cache.ConfirmedSats);
|
|
||||||
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 ? _loc["addr.change"] : _loc["addr.receive"],
|
|
||||||
a.Index,
|
|
||||||
a.Address,
|
|
||||||
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
|
||||||
a.TxCount > 0 ? a.TxCount.ToString() : "—",
|
|
||||||
a.IsChange,
|
|
||||||
_account.GetPublicKey(a.IsChange, a.Index)?.ToHex() ?? "",
|
|
||||||
KeyWif(a.IsChange, a.Index),
|
|
||||||
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.App.Services;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Net;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
[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;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ScanQr()
|
|
||||||
{
|
|
||||||
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
|
||||||
var raw = await scan();
|
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return;
|
|
||||||
// Handle URIs like "palladium:ADDRESS?amount=X" — extract address only
|
|
||||||
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
|
||||||
if (address.Contains('?')) address = address.Split('?')[0];
|
|
||||||
SendTo = address.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
[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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
// ---- spunte del menu Impostazioni (ToggleType Radio) ----
|
|
||||||
|
|
||||||
public bool IsLangIt => _config.Language == "it";
|
|
||||||
public bool IsLangEn => _config.Language == "en";
|
|
||||||
public bool IsLangEs => _config.Language == "es";
|
|
||||||
public bool IsLangFr => _config.Language == "fr";
|
|
||||||
public bool IsLangPt => _config.Language == "pt";
|
|
||||||
public bool IsLangDe => _config.Language == "de";
|
|
||||||
public bool IsUnitPlm => _config.Unit == "PLM";
|
|
||||||
public bool 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ApplySettings(PalladiumWallet.Core.Storage.AppConfig config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_config.Save();
|
|
||||||
_loc = Localization.Loc.SwitchTo(config.Language);
|
|
||||||
OnPropertyChanged(nameof(Loc));
|
|
||||||
OnPropertyChanged(nameof(UnitLabel));
|
|
||||||
OnPropertyChanged(nameof(IsLangIt));
|
|
||||||
OnPropertyChanged(nameof(IsLangEn));
|
|
||||||
OnPropertyChanged(nameof(IsLangEs));
|
|
||||||
OnPropertyChanged(nameof(IsLangFr));
|
|
||||||
OnPropertyChanged(nameof(IsLangPt));
|
|
||||||
OnPropertyChanged(nameof(IsLangDe));
|
|
||||||
OnPropertyChanged(nameof(IsUnitPlm));
|
|
||||||
OnPropertyChanged(nameof(IsUnitMilli));
|
|
||||||
OnPropertyChanged(nameof(IsUnitMicro));
|
|
||||||
OnPropertyChanged(nameof(IsUnitSat));
|
|
||||||
ApplyCache(_doc?.Cache);
|
|
||||||
StatusMessage = Localization.Loc.Tr("msg.settings.saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- overlay impostazioni, server, help ----
|
|
||||||
|
|
||||||
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
|
|
||||||
private bool isSettingsOpen;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenSettings() => IsSettingsOpen = true;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CloseSettings() => IsSettingsOpen = false;
|
|
||||||
|
|
||||||
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
|
|
||||||
private bool isHelpOpen;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenHelp() => IsHelpOpen = true;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CloseHelp() { IsHelpOpen = false; ResetDonate(); }
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using PalladiumWallet.App.Localization;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Net;
|
|
||||||
using PalladiumWallet.Core.Spv;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
// ---- server and connection ----
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string serverHost = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string serverPort = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool useSsl = true;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool isServerSettingsOpen;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenServerSettings()
|
|
||||||
{
|
|
||||||
IsSettingsOpen = false;
|
|
||||||
IsServerSettingsOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CloseServerSettings() => IsServerSettingsOpen = false;
|
|
||||||
|
|
||||||
[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;
|
|
||||||
|
|
||||||
partial void OnSelectedKnownServerChanged(KnownServer? value)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
return;
|
|
||||||
_syncingServerFields = true;
|
|
||||||
ServerHost = value.Host;
|
|
||||||
ServerPort = value.PortFor(UseSsl).ToString();
|
|
||||||
_syncingServerFields = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnUseSslChanged(bool value)
|
|
||||||
{
|
|
||||||
if (_syncingServerFields)
|
|
||||||
return;
|
|
||||||
_syncingServerFields = true;
|
|
||||||
ServerPort = SelectedKnownServer is { } server
|
|
||||||
? server.PortFor(value).ToString()
|
|
||||||
: (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
|
|
||||||
_syncingServerFields = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnServerPortChanged(string value)
|
|
||||||
{
|
|
||||||
if (_syncingServerFields)
|
|
||||||
return;
|
|
||||||
if (!int.TryParse(value.Trim(), out var port))
|
|
||||||
return;
|
|
||||||
bool? wantSsl =
|
|
||||||
SelectedKnownServer is { } s && port == s.SslPort ? true :
|
|
||||||
SelectedKnownServer is { } t && port == t.TcpPort ? false :
|
|
||||||
port == Profile.DefaultSslPort ? true :
|
|
||||||
port == Profile.DefaultTcpPort ? false :
|
|
||||||
null;
|
|
||||||
if (wantSsl is bool b && b != UseSsl)
|
|
||||||
{
|
|
||||||
_syncingServerFields = true;
|
|
||||||
UseSsl = b;
|
|
||||||
_syncingServerFields = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshServers()
|
|
||||||
{
|
|
||||||
KnownServers.Clear();
|
|
||||||
foreach (var server in Registry.All)
|
|
||||||
KnownServers.Add(server);
|
|
||||||
SelectedKnownServer = KnownServers.FirstOrDefault();
|
|
||||||
if (SelectedKnownServer is null)
|
|
||||||
{
|
|
||||||
ServerHost = "127.0.0.1";
|
|
||||||
ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private (string Host, int Port) ParseServer()
|
|
||||||
{
|
|
||||||
var host = ServerHost.Trim();
|
|
||||||
var port = int.TryParse(ServerPort.Trim(), out var p)
|
|
||||||
? p
|
|
||||||
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
|
|
||||||
return (host, port);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns servers to try in order: first the one selected in the UI
|
|
||||||
/// (or manually typed), then the remaining KnownServers, with duplicates skipped.
|
|
||||||
/// </summary>
|
|
||||||
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
|
||||||
{
|
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
// 1. Current server (selected or manually typed).
|
|
||||||
if (!string.IsNullOrWhiteSpace(selectedHost))
|
|
||||||
{
|
|
||||||
var key = $"{selectedHost}:{selectedPort}";
|
|
||||||
if (seen.Add(key))
|
|
||||||
yield return (selectedHost, selectedPort);
|
|
||||||
}
|
|
||||||
// 2. Other known servers, in list order.
|
|
||||||
foreach (var s in KnownServers)
|
|
||||||
{
|
|
||||||
var p = s.PortFor(UseSsl);
|
|
||||||
var key = $"{s.Host}:{p}";
|
|
||||||
if (seen.Add(key))
|
|
||||||
yield return (s.Host, p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ConnectAndSync()
|
|
||||||
{
|
|
||||||
if (IsSyncing)
|
|
||||||
{
|
|
||||||
_resyncRequested = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
IsSyncing = true;
|
|
||||||
StatusMessage = "";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (host, port) = ParseServer();
|
|
||||||
|
|
||||||
if (_client is { } current &&
|
|
||||||
(current.Host != host || current.Port != port || current.UseSsl != UseSsl))
|
|
||||||
{
|
|
||||||
await DisconnectAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_client is null || !_client.IsConnected)
|
|
||||||
{
|
|
||||||
// Persist caches before destroying the synchronizer: the new
|
|
||||||
// synchronizer will use a different client and must be recreated,
|
|
||||||
// but already-downloaded data is preserved via _doc.Cache.
|
|
||||||
PersistPartialTxCache();
|
|
||||||
_synchronizer = null;
|
|
||||||
|
|
||||||
// Try all known servers in order; starts with the selected one
|
|
||||||
// and walks the list until the first that responds.
|
|
||||||
var candidates = BuildServerCandidates(host, port);
|
|
||||||
Exception? lastError = null;
|
|
||||||
foreach (var (h, p) in candidates)
|
|
||||||
{
|
|
||||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
|
||||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
|
||||||
_client.NotificationReceived += OnServerNotification;
|
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
IsConnected = false;
|
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
|
||||||
});
|
|
||||||
IsConnected = true;
|
|
||||||
_autoReconnect = true;
|
|
||||||
ConnectionStatus = Loc.Tr("conn.connectedto");
|
|
||||||
// Update the UI to reflect the server actually connected.
|
|
||||||
_syncingServerFields = true;
|
|
||||||
ServerHost = h;
|
|
||||||
ServerPort = p.ToString();
|
|
||||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == h)
|
|
||||||
?? SelectedKnownServer;
|
|
||||||
_syncingServerFields = false;
|
|
||||||
lastError = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
lastError = ex;
|
|
||||||
_client = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastError is not null)
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_account is null || _doc is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (_synchronizer is null)
|
|
||||||
{
|
|
||||||
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
|
|
||||||
// Reload from disk cache: avoids re-downloading already known transactions
|
|
||||||
// (essential for wallets with thousands of historical txs — prevents -101).
|
|
||||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
|
||||||
_synchronizer.PreloadCaches(
|
|
||||||
_doc.Cache?.RawTxHex ?? [],
|
|
||||||
_doc.Cache?.VerifiedAt ?? [],
|
|
||||||
_doc.Cache?.BlockHeaders,
|
|
||||||
_doc.Cache?.NextReceiveIndex ?? 0,
|
|
||||||
_doc.Cache?.NextChangeIndex ?? 0,
|
|
||||||
net);
|
|
||||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
_resyncRequested = false;
|
|
||||||
var result = await _synchronizer.SyncOnceAsync();
|
|
||||||
_lastTransactions = result.Transactions;
|
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
|
||||||
PalladiumNetworks.For(_account.Profile.Kind));
|
|
||||||
_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],
|
|
||||||
RawTxHex = rawHex,
|
|
||||||
VerifiedAt = verifiedAt,
|
|
||||||
BlockHeaders = blockHeaders,
|
|
||||||
};
|
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
|
||||||
ApplyCache(_doc.Cache);
|
|
||||||
_syncFailed = false;
|
|
||||||
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.none");
|
|
||||||
StatusMessage = ex.Message;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
IsConnected = _client?.IsConnected == true;
|
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
|
||||||
if (_account is not null)
|
|
||||||
{
|
|
||||||
_syncFailed = true;
|
|
||||||
// Persist already-downloaded transactions: on retry the synchronizer
|
|
||||||
// resumes from here instead of starting from scratch (e.g. after -101).
|
|
||||||
PersistPartialTxCache();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsSyncing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[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)
|
|
||||||
{
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persists the transactions and Merkle proofs already accumulated by the synchronizer
|
|
||||||
/// into SyncCache, even if sync is not yet complete. Allows the next retry
|
|
||||||
/// (or app restart) to resume without re-downloading from scratch.
|
|
||||||
/// </summary>
|
|
||||||
private void PersistPartialTxCache()
|
|
||||||
{
|
|
||||||
if (_synchronizer is null || _doc is null || _walletPath is null || _account is null)
|
|
||||||
return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(net);
|
|
||||||
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
|
||||||
return;
|
|
||||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
|
||||||
_doc.Cache.VerifiedAt = verifiedAt;
|
|
||||||
_doc.Cache.BlockHeaders = blockHeaders;
|
|
||||||
WalletStore.Save(_doc, _walletPath, _password);
|
|
||||||
}
|
|
||||||
catch { /* non-fatal: the next full save will recover */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DisconnectAsync()
|
|
||||||
{
|
|
||||||
if (_client is { } client)
|
|
||||||
{
|
|
||||||
_client = null;
|
|
||||||
_synchronizer = null;
|
|
||||||
try { await client.DisposeAsync(); } catch { }
|
|
||||||
}
|
|
||||||
IsConnected = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using PalladiumWallet.App.Localization;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Crypto;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
public partial class MainWindowViewModel
|
|
||||||
{
|
|
||||||
// ---- setup wizard
|
|
||||||
|
|
||||||
public const string StepDataLocation = "data-location";
|
|
||||||
public const string StepStart = "start";
|
|
||||||
public const string StepChooseWallet = "choose-wallet";
|
|
||||||
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 StepScriptType = "script-type";
|
|
||||||
public const string StepImportXkey = "import-xkey";
|
|
||||||
public const string StepImportWif = "import-wif";
|
|
||||||
public const string StepPassword = "password";
|
|
||||||
|
|
||||||
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
|
|
||||||
private WizardFlowKind _wizardFlow;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepStart))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepChooseWallet))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepOpen))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
|
||||||
private string setupStep = StepStart;
|
|
||||||
|
|
||||||
public bool IsStepDataLocation => SetupStep == StepDataLocation;
|
|
||||||
public bool IsStepStart => SetupStep == StepStart;
|
|
||||||
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
|
|
||||||
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 IsStepScriptType => SetupStep == StepScriptType;
|
|
||||||
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
|
||||||
public bool IsStepImportWif => SetupStep == StepImportWif;
|
|
||||||
public bool IsStepPassword => SetupStep == StepPassword;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsLegacySelected))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsWrappedSegwitSelected))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsNativeSegwitSelected))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsTaprootSelected))]
|
|
||||||
private ScriptKind selectedScriptKind = ScriptKind.NativeSegwit;
|
|
||||||
|
|
||||||
public bool IsLegacySelected => SelectedScriptKind == ScriptKind.Legacy;
|
|
||||||
public bool IsWrappedSegwitSelected => SelectedScriptKind == ScriptKind.WrappedSegwit;
|
|
||||||
public bool IsNativeSegwitSelected => SelectedScriptKind == ScriptKind.NativeSegwit;
|
|
||||||
public bool IsTaprootSelected => SelectedScriptKind == ScriptKind.Taproot;
|
|
||||||
|
|
||||||
[RelayCommand] private void SelectLegacy() => SelectedScriptKind = ScriptKind.Legacy;
|
|
||||||
[RelayCommand] private void SelectWrappedSegwit() => SelectedScriptKind = ScriptKind.WrappedSegwit;
|
|
||||||
[RelayCommand] private void SelectNativeSegwit() => SelectedScriptKind = ScriptKind.NativeSegwit;
|
|
||||||
[RelayCommand] private void SelectTaproot() => SelectedScriptKind = ScriptKind.Taproot;
|
|
||||||
|
|
||||||
public string DefaultDataPath => AppPaths.DefaultDataRoot();
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string importXkeyInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string importWifInput = "";
|
|
||||||
|
|
||||||
// Script type detected during xkey decoding (to display to the user)
|
|
||||||
[ObservableProperty]
|
|
||||||
private string importXkeyDetectedKind = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string mnemonicInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string confirmMnemonicInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string passphraseInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string walletNameInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string passwordInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string confirmPasswordInput = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool encryptWallet = true;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool walletFileExists;
|
|
||||||
|
|
||||||
public ObservableCollection<WalletFileEntry> WalletList { get; } = [];
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot());
|
|
||||||
|
|
||||||
public void ApplyDataLocation(string root)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
AppPaths.ConfigureDataLocation(root);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_config = AppConfig.Load();
|
|
||||||
_loc = Loc.SwitchTo(_config.Language);
|
|
||||||
OnPropertyChanged(nameof(Loc));
|
|
||||||
OnPropertyChanged(nameof(UnitLabel));
|
|
||||||
RefreshSetupState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshSetupState()
|
|
||||||
{
|
|
||||||
SetupStep = StepStart;
|
|
||||||
SelectedScriptKind = ScriptKind.NativeSegwit;
|
|
||||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
|
|
||||||
WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
|
|
||||||
StatusMessage = "";
|
|
||||||
RefreshServers();
|
|
||||||
_ = ConnectAndSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardStartOpen()
|
|
||||||
{
|
|
||||||
var files = AppPaths.WalletFiles(Net);
|
|
||||||
if (files.Count > 1)
|
|
||||||
{
|
|
||||||
WalletList.Clear();
|
|
||||||
foreach (var path in files)
|
|
||||||
WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path));
|
|
||||||
SetupStep = StepChooseWallet;
|
|
||||||
StatusMessage = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net);
|
|
||||||
PasswordInput = "";
|
|
||||||
SetupStep = StepOpen;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ChooseWallet(WalletFileEntry? entry)
|
|
||||||
{
|
|
||||||
if (entry is null)
|
|
||||||
return;
|
|
||||||
_pendingOpenPath = entry.Path;
|
|
||||||
PasswordInput = "";
|
|
||||||
SetupStep = StepOpen;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardStartNew()
|
|
||||||
{
|
|
||||||
_wizardFlow = WizardFlowKind.New;
|
|
||||||
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
|
||||||
SetupStep = StepShowSeed;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardStartRestore()
|
|
||||||
{
|
|
||||||
_wizardFlow = WizardFlowKind.Restore;
|
|
||||||
MnemonicInput = "";
|
|
||||||
SetupStep = StepWords;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardStartImportXkey()
|
|
||||||
{
|
|
||||||
_wizardFlow = WizardFlowKind.ImportXkey;
|
|
||||||
ImportXkeyInput = "";
|
|
||||||
ImportXkeyDetectedKind = "";
|
|
||||||
SetupStep = StepImportXkey;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardStartImportWif()
|
|
||||||
{
|
|
||||||
_wizardFlow = WizardFlowKind.ImportWif;
|
|
||||||
ImportWifInput = "";
|
|
||||||
SetupStep = StepImportWif;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardNextFromShowSeed()
|
|
||||||
{
|
|
||||||
ConfirmMnemonicInput = "";
|
|
||||||
SetupStep = StepConfirmSeed;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[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 = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardNextFromPassphrase()
|
|
||||||
{
|
|
||||||
SetupStep = StepScriptType;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardNextFromImportXkey()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(ImportXkeyInput))
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.xkey.required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Validate and detect the type: try as xpub first, then as xprv.
|
|
||||||
if (Slip132.TryDecodePublic(ImportXkeyInput.Trim(), Profile, out _, out var pubKind))
|
|
||||||
{
|
|
||||||
SelectedScriptKind = pubKind;
|
|
||||||
ImportXkeyDetectedKind = $"{pubKind} (watch-only)";
|
|
||||||
}
|
|
||||||
else if (Slip132.TryDecodePrivate(ImportXkeyInput.Trim(), Profile, out _, out var prvKind))
|
|
||||||
{
|
|
||||||
SelectedScriptKind = prvKind;
|
|
||||||
ImportXkeyDetectedKind = prvKind.ToString();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.xkey.invalid");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
PasswordInput = ConfirmPasswordInput = "";
|
|
||||||
EncryptWallet = true;
|
|
||||||
SetupStep = StepPassword;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardNextFromImportWif()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(ImportWifInput))
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.wif.required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Validate the WIF with an early parse.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_ = new NBitcoin.BitcoinSecret(ImportWifInput.Trim(), PalladiumNetworks.For(Net));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.wif.invalid");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
SetupStep = StepScriptType;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardNextFromScriptType()
|
|
||||||
{
|
|
||||||
PasswordInput = ConfirmPasswordInput = "";
|
|
||||||
EncryptWallet = true;
|
|
||||||
SetupStep = StepPassword;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void WizardBack()
|
|
||||||
{
|
|
||||||
SetupStep = SetupStep switch
|
|
||||||
{
|
|
||||||
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
|
||||||
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart,
|
|
||||||
StepConfirmSeed => StepShowSeed,
|
|
||||||
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
|
|
||||||
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
|
|
||||||
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
|
|
||||||
_ => StepStart,
|
|
||||||
};
|
|
||||||
if (SetupStep == StepStart)
|
|
||||||
RefreshSetupState();
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void CreateOrRestore()
|
|
||||||
{
|
|
||||||
string? password;
|
|
||||||
if (EncryptWallet)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(PasswordInput))
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.password.required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (PasswordInput != ConfirmPasswordInput)
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.password.mismatch");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
password = PasswordInput;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
password = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WalletDocument doc;
|
|
||||||
IWalletAccount account;
|
|
||||||
|
|
||||||
switch (_wizardFlow)
|
|
||||||
{
|
|
||||||
case WizardFlowKind.ImportXkey:
|
|
||||||
{
|
|
||||||
var input = ImportXkeyInput.Trim();
|
|
||||||
if (Slip132.TryDecodePublic(input, Profile, out _, out _))
|
|
||||||
{
|
|
||||||
var (d, a) = WalletLoader.NewFromXpub(input, Profile, SelectedScriptKind);
|
|
||||||
(doc, account) = (d, a);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var (d, a) = WalletLoader.NewFromXprv(input, Profile, SelectedScriptKind);
|
|
||||||
(doc, account) = (d, a);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case WizardFlowKind.ImportWif:
|
|
||||||
{
|
|
||||||
var wifLines = ImportWifInput.Split('\n',
|
|
||||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
||||||
var (d, a) = WalletLoader.NewFromWif(wifLines, SelectedScriptKind, Profile);
|
|
||||||
(doc, account) = (d, a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
{
|
|
||||||
var (d, a) = WalletLoader.NewFromMnemonic(
|
|
||||||
MnemonicInput,
|
|
||||||
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
|
|
||||||
SelectedScriptKind,
|
|
||||||
Profile);
|
|
||||||
(doc, account) = (d, a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = WalletPathFromName(WalletNameInput);
|
|
||||||
if (WalletStore.Exists(path))
|
|
||||||
{
|
|
||||||
StatusMessage = Loc.Tr("msg.wallet.exists");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WalletStore.Save(doc, path, password);
|
|
||||||
var newLock = WalletLock.TryAcquire(path);
|
|
||||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
|
||||||
OpenLoaded(doc, account, path, password, newLock);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the file path from the name entered by the user.
|
|
||||||
/// If the name is empty, generates an automatic name (default, wallet-2, …).
|
|
||||||
/// Removes characters that are invalid for the filesystem.
|
|
||||||
/// </summary>
|
|
||||||
private string WalletPathFromName(string name)
|
|
||||||
{
|
|
||||||
var clean = string.IsNullOrWhiteSpace(name)
|
|
||||||
? ""
|
|
||||||
: string.Concat(name.Trim()
|
|
||||||
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c))
|
|
||||||
.Trim('.');
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(clean))
|
|
||||||
{
|
|
||||||
// No name provided → auto-generated name
|
|
||||||
var def = AppPaths.DefaultWalletPath(Net);
|
|
||||||
for (var n = 2; WalletStore.Exists(def); n++)
|
|
||||||
def = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.Combine(AppPaths.WalletsDir(Net), $"{clean}.wallet.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void OpenExisting()
|
|
||||||
{
|
|
||||||
var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net);
|
|
||||||
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
|
|
||||||
|
|
||||||
var newLock = WalletLock.TryAcquire(path);
|
|
||||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doc = WalletStore.Load(path, password);
|
|
||||||
_pendingOpenPath = null;
|
|
||||||
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password, newLock);
|
|
||||||
}
|
|
||||||
catch (WrongPasswordException)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = Loc.Tr("msg.wrongpassword");
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = Loc.Tr("msg.wallet.noaccess");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OpenFromPath(string path)
|
|
||||||
{
|
|
||||||
var newLock = WalletLock.TryAcquire(path);
|
|
||||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doc = WalletStore.Load(path);
|
|
||||||
if (IsWalletOpen)
|
|
||||||
CloseWallet();
|
|
||||||
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock);
|
|
||||||
}
|
|
||||||
catch (WrongPasswordException)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
if (IsWalletOpen)
|
|
||||||
CloseWallet();
|
|
||||||
_pendingOpenPath = path;
|
|
||||||
WalletFileExists = true;
|
|
||||||
PasswordInput = "";
|
|
||||||
SetupStep = StepOpen;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = Loc.Tr("msg.wallet.noaccess");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void NewWallet()
|
|
||||||
{
|
|
||||||
if (IsWalletOpen)
|
|
||||||
CloseWallet();
|
|
||||||
_pendingOpenPath = null;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenLoaded(
|
|
||||||
WalletDocument doc, IWalletAccount account, string path, string? password, WalletLock walletLock)
|
|
||||||
{
|
|
||||||
_walletLock?.Dispose();
|
|
||||||
_walletLock = walletLock;
|
|
||||||
|
|
||||||
_doc = doc;
|
|
||||||
_account = account;
|
|
||||||
_walletPath = path;
|
|
||||||
_password = password;
|
|
||||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
|
|
||||||
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
|
|
||||||
SetupStep = StepStart;
|
|
||||||
|
|
||||||
var walletKindTag = account switch
|
|
||||||
{
|
|
||||||
ImportedKeyAccount => " · imported",
|
|
||||||
{ IsWatchOnly: true } => " · watch-only",
|
|
||||||
_ => ""
|
|
||||||
};
|
|
||||||
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
|
|
||||||
NetworkInfo = $"{doc.Network} · {doc.ScriptKind}{pathTag}{walletKindTag}";
|
|
||||||
LoadContacts();
|
|
||||||
ApplyCache(doc.Cache);
|
|
||||||
IsWalletOpen = true;
|
|
||||||
StatusMessage = Loc.Tr("msg.opened");
|
|
||||||
_autoReconnect = true; // keepalive retries the connection even if the first attempt fails
|
|
||||||
_syncFailed = true; // force the first automatic sync
|
|
||||||
_ = ConnectAndSync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Threading;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -16,95 +18,99 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>Transaction history row for the view.</summary>
|
/// <summary>Riga dello storico transazioni per la vista.</summary>
|
||||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
||||||
|
|
||||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary>
|
||||||
public sealed record AddressRow(
|
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx);
|
||||||
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
|
|
||||||
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
|
|
||||||
{
|
|
||||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Full address data passed to the address detail overlay.</summary>
|
|
||||||
public sealed record AddressInfo(
|
|
||||||
Loc Loc,
|
|
||||||
string Address, string DerivPath, string PubKey, string PrivKey)
|
|
||||||
{
|
|
||||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Address book contact: name + blockchain address.</summary>
|
|
||||||
public sealed record ContactEntry(string Name, string Address);
|
|
||||||
|
|
||||||
/// <summary>Wallet list entry: display name + full file path.</summary>
|
|
||||||
public sealed record WalletFileEntry(string Name, string Path);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single application ViewModel (wizard + dashboard). Split into partial files
|
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
|
||||||
/// by area: Wizard, Settings, Sync, Send, Contacts, Receive.
|
/// pannello di setup (crea/ripristina/apri) e pannello wallet
|
||||||
|
/// (saldo, ricevi, storico, invia, server).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindowViewModel : ViewModelBase
|
public partial class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
// ---- wallet session state ----
|
|
||||||
private WalletDocument? _doc;
|
private WalletDocument? _doc;
|
||||||
private IWalletAccount? _account;
|
private HdAccount? _account;
|
||||||
private string? _walletPath;
|
private string? _walletPath;
|
||||||
private string? _password;
|
private string? _password;
|
||||||
private WalletLock? _walletLock;
|
|
||||||
|
|
||||||
// ---- network ----
|
|
||||||
private ElectrumClient? _client;
|
private ElectrumClient? _client;
|
||||||
private WalletSynchronizer? _synchronizer;
|
private WalletSynchronizer? _synchronizer;
|
||||||
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
||||||
private bool _resyncRequested;
|
|
||||||
|
|
||||||
// ---- send ----
|
|
||||||
private BuiltTransaction? _pendingSend;
|
private BuiltTransaction? _pendingSend;
|
||||||
|
|
||||||
// ---- transaction detail ----
|
/// <summary>Notifica arrivata durante una sync: si risincronizza appena finita.</summary>
|
||||||
private CancellationTokenSource? _txDetailsCts;
|
private bool _resyncRequested;
|
||||||
|
|
||||||
// ---- configuration and localisation ----
|
/// <summary>Configurazione globale (§8): lingua e unità.</summary>
|
||||||
private AppConfig _config = AppConfig.Load();
|
private AppConfig _config = AppConfig.Load();
|
||||||
private Loc _loc = Loc.Instance;
|
|
||||||
public Loc Loc => _loc;
|
|
||||||
|
|
||||||
// ---- wizard ----
|
/// <summary>Stringhe localizzate, bindabili da XAML come Loc[chiave].</summary>
|
||||||
private string? _pendingOpenPath;
|
public Loc Loc => Loc.Instance;
|
||||||
|
|
||||||
// ---- keep-alive ----
|
|
||||||
private bool _autoReconnect;
|
|
||||||
private bool _syncFailed;
|
|
||||||
private readonly DispatcherTimer _keepAliveTimer;
|
|
||||||
|
|
||||||
// ---- server UI sync ----
|
|
||||||
private bool _syncingServerFields;
|
|
||||||
|
|
||||||
// ---- app properties ----
|
|
||||||
|
|
||||||
public static string AppVersion =>
|
|
||||||
typeof(MainWindowViewModel).Assembly.GetName().Version is { } v
|
|
||||||
? $"{v.Major}.{v.Minor}.{v.Build}"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
public string WindowTitle => $"Palladium Wallet {AppVersion}";
|
|
||||||
|
|
||||||
/// <summary>True on desktop; false on Android/iOS — hides filesystem-dependent features.</summary>
|
|
||||||
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
|
|
||||||
|
|
||||||
public bool IsMobile => !IsDesktop;
|
|
||||||
|
|
||||||
// Tab bar sizing: compact on mobile so all 5 tabs fit in one row.
|
|
||||||
public double TabIconSize => IsMobile ? 20 : 24;
|
|
||||||
public double TabFontSize => IsMobile ? 10 : 13;
|
|
||||||
public double TabSpacing => IsMobile ? 4 : 4;
|
|
||||||
|
|
||||||
|
/// <summary>Unità corrente per il campo importo del pannello Invia.</summary>
|
||||||
public string UnitLabel => _config.Unit;
|
public string UnitLabel => _config.Unit;
|
||||||
|
|
||||||
public AppConfig CurrentConfig => _config;
|
public AppConfig CurrentConfig => _config;
|
||||||
|
|
||||||
// ---- panel state ----
|
// 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]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
||||||
@@ -113,67 +119,137 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
public bool IsSetupVisible => !IsWalletOpen;
|
public bool IsSetupVisible => !IsWalletOpen;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string statusMessage = "";
|
private bool walletFileExists;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int selectedTabIndex;
|
private string statusMessage = "";
|
||||||
|
|
||||||
[RelayCommand]
|
// ---- wizard di setup (§15): un passo alla volta ----
|
||||||
private void SelectTab(string index) => SelectedTabIndex = int.Parse(index);
|
|
||||||
|
|
||||||
// ---- dashboard collections ----
|
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<HistoryRow> History { get; } = [];
|
||||||
|
|
||||||
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- pannello invia ----
|
||||||
|
|
||||||
private NetKind Net => NetKind.Mainnet;
|
[ObservableProperty]
|
||||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
private string sendTo = "";
|
||||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
|
||||||
|
|
||||||
private string Fmt(long sats, bool withLabel = true) =>
|
[ObservableProperty]
|
||||||
CoinAmount.FormatIn(sats, _config.Unit, withLabel);
|
private string sendAmount = "";
|
||||||
|
|
||||||
private string KeyWif(bool isChange, int index)
|
[ObservableProperty]
|
||||||
{
|
private string sendFeeRate = "1";
|
||||||
if (_account is null or { IsWatchOnly: true }) return "";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return _account.GetPrivateKey(isChange, index)
|
|
||||||
?.GetWif(PalladiumNetworks.For(Net)).ToString() ?? "";
|
|
||||||
}
|
|
||||||
catch { return ""; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- constructor ----
|
[ObservableProperty]
|
||||||
|
private bool sendAll;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string sendPreview = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool hasPendingSend;
|
||||||
|
|
||||||
public MainWindowViewModel()
|
public MainWindowViewModel()
|
||||||
{
|
{
|
||||||
_loc = Loc.SwitchTo(_config.Language);
|
RefreshSetupState();
|
||||||
if (!AppPaths.IsDataLocationConfigured())
|
// Aggiornamenti continui (§9): ping periodico per tenere viva la
|
||||||
SetupStep = StepDataLocation;
|
// connessione e accorgersi subito della caduta; se cade, riconnette
|
||||||
else
|
// e risincronizza da solo. Le notifiche push restano la via principale.
|
||||||
RefreshSetupState();
|
_keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) };
|
||||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
|
||||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||||
_keepAliveTimer.Start();
|
_keepAliveTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
private async Task KeepAliveTickAsync()
|
||||||
{
|
{
|
||||||
if (IsSyncing)
|
if (!IsWalletOpen || IsSyncing)
|
||||||
return;
|
return;
|
||||||
if (_client is { IsConnected: true })
|
if (_client is { IsConnected: true })
|
||||||
{
|
{
|
||||||
// If the wallet is open and the last sync failed, retry automatically.
|
try
|
||||||
if (_syncFailed && _account is not null)
|
|
||||||
{
|
{
|
||||||
await ConnectAndSync();
|
await _client.PingAsync();
|
||||||
return;
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// La caduta viene gestita dall'evento Disconnected.
|
||||||
}
|
}
|
||||||
try { await _client.PingAsync(); }
|
|
||||||
catch { }
|
|
||||||
}
|
}
|
||||||
else if (_autoReconnect)
|
else if (_autoReconnect)
|
||||||
{
|
{
|
||||||
@@ -182,32 +258,506 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wallet lifecycle ----
|
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]
|
[RelayCommand]
|
||||||
private void CloseWallet()
|
private void CloseWallet()
|
||||||
{
|
{
|
||||||
_walletLock?.Dispose();
|
|
||||||
_walletLock = null;
|
|
||||||
_ = _client?.DisposeAsync().AsTask();
|
_ = _client?.DisposeAsync().AsTask();
|
||||||
_client = null;
|
_client = null;
|
||||||
_synchronizer = null;
|
_synchronizer = null;
|
||||||
_autoReconnect = false;
|
_autoReconnect = false;
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
_syncFailed = false;
|
|
||||||
_doc = null;
|
_doc = null;
|
||||||
_account = null;
|
_account = null;
|
||||||
_lastTransactions = null;
|
_lastTransactions = null;
|
||||||
_pendingSend = null;
|
_pendingSend = null;
|
||||||
HasPendingSend = false;
|
HasPendingSend = false;
|
||||||
History.Clear();
|
History.Clear();
|
||||||
Contacts.Clear();
|
|
||||||
SelectedContactInList = null;
|
|
||||||
SendToContact = null;
|
|
||||||
SelectedAddressRow = null;
|
|
||||||
IsWalletOpen = false;
|
IsWalletOpen = false;
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
RefreshSetupState();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Data.Converters;
|
|
||||||
using Avalonia.Media;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// bool → brush: the wallet's own addresses ("our" inputs/outputs) are
|
|
||||||
/// highlighted in green, the others use the default text color.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class MineColorConverter : IValueConverter
|
|
||||||
{
|
|
||||||
public static readonly MineColorConverter Instance = new();
|
|
||||||
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
|
||||||
value is true ? Brushes.MediumSeaGreen : AvaloniaProperty.UnsetValue;
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Linq;
|
|
||||||
using PalladiumWallet.App.Localization;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>Input/output row for the transaction detail tables.</summary>
|
|
||||||
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ViewModel for the transaction detail view: takes a
|
|
||||||
/// <see cref="TransactionDetails"/> (assembled from server data) and produces
|
|
||||||
/// all strings already formatted and localised for the view. Read-only.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TransactionDetailsViewModel
|
|
||||||
{
|
|
||||||
private readonly string _unit;
|
|
||||||
|
|
||||||
public Loc Loc { get; }
|
|
||||||
|
|
||||||
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
|
|
||||||
{
|
|
||||||
Loc = loc;
|
|
||||||
_unit = unit;
|
|
||||||
|
|
||||||
Txid = d.Txid;
|
|
||||||
IsCoinbase = d.IsCoinbase;
|
|
||||||
StatusText = BuildStatus(d, loc);
|
|
||||||
DateText = d.BlockTime is { } t
|
|
||||||
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
|
||||||
: loc["tx.mempool"];
|
|
||||||
|
|
||||||
var counterparties = d.CounterpartyAddresses;
|
|
||||||
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
|
|
||||||
// Coinbase has no sender: coins are newly generated by mining.
|
|
||||||
CounterpartyText = d.IsCoinbase
|
|
||||||
? loc["tx.coinbase.newcoins"]
|
|
||||||
: counterparties.Count > 0
|
|
||||||
? string.Join(Environment.NewLine, counterparties)
|
|
||||||
: "—";
|
|
||||||
|
|
||||||
// Debit (outflow to third parties) or Credit (net inflow on our outputs).
|
|
||||||
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
|
|
||||||
AmountText = d.IsIncoming
|
|
||||||
? Signed(d.ReceivedSats)
|
|
||||||
: Signed(-d.SentToOthersSats);
|
|
||||||
|
|
||||||
FeeText = d.FeeSats is { } fee
|
|
||||||
? (d.IsIncoming ? Abs(fee) : Signed(-fee))
|
|
||||||
: "—";
|
|
||||||
|
|
||||||
NetText = Signed(d.NetSats);
|
|
||||||
|
|
||||||
TotalSizeText = $"{d.TotalSize} byte";
|
|
||||||
VirtualSizeText = $"{d.VirtualSize} byte";
|
|
||||||
FeeRateText = d.FeeRateSatPerVb is { } r
|
|
||||||
? r.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " sat/vB"
|
|
||||||
: "—";
|
|
||||||
VersionText = d.Version.ToString();
|
|
||||||
LockTimeText = d.LockTime.ToString();
|
|
||||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
|
||||||
VerifiedText = d.Verified ? "✓ SPV" : "—";
|
|
||||||
|
|
||||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
|
||||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
|
|
||||||
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
|
||||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
|
||||||
i.IsMine)));
|
|
||||||
|
|
||||||
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
|
||||||
$"#{o.Index}",
|
|
||||||
o.Address ?? $"({o.ScriptType})",
|
|
||||||
CoinAmount.FormatIn(o.AmountSats, unit),
|
|
||||||
o.IsMine)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Txid { get; }
|
|
||||||
public bool IsCoinbase { get; }
|
|
||||||
public string StatusText { get; }
|
|
||||||
public string DateText { get; }
|
|
||||||
public string CounterpartyHeader { get; }
|
|
||||||
public string CounterpartyText { get; }
|
|
||||||
public string AmountHeader { get; }
|
|
||||||
public string AmountText { get; }
|
|
||||||
public string FeeText { get; }
|
|
||||||
public string NetText { get; }
|
|
||||||
public string TotalSizeText { get; }
|
|
||||||
public string VirtualSizeText { get; }
|
|
||||||
public string FeeRateText { get; }
|
|
||||||
public string VersionText { get; }
|
|
||||||
public string LockTimeText { get; }
|
|
||||||
public string RbfText { get; }
|
|
||||||
public string VerifiedText { get; }
|
|
||||||
public ObservableCollection<TxIoRow> Inputs { get; }
|
|
||||||
public ObservableCollection<TxIoRow> Outputs { get; }
|
|
||||||
|
|
||||||
private static string BuildStatus(TransactionDetails d, Loc loc)
|
|
||||||
{
|
|
||||||
if (d.Confirmations <= 0)
|
|
||||||
return loc["tx.status.mempool"];
|
|
||||||
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Signed(long sats)
|
|
||||||
{
|
|
||||||
var sign = sats > 0 ? "+" : sats < 0 ? "-" : "";
|
|
||||||
return sign + CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
|
||||||
|
|
||||||
private static string Shorten(string txid) =>
|
|
||||||
txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid;
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Input.Platform;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using Avalonia.Platform.Storage;
|
|
||||||
using Avalonia.VisualTree;
|
|
||||||
using PalladiumWallet.App.ViewModels;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.App.Views;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// App root view, shared between desktop (hosted in <see cref="MainWindow"/>)
|
|
||||||
/// and mobile (single-view root). All overlays are in-app so no separate windows
|
|
||||||
/// are needed. Top-level APIs (file picker, clipboard) are reached via
|
|
||||||
/// <see cref="TopLevel.GetTopLevel"/> because a UserControl does not expose them.
|
|
||||||
/// </summary>
|
|
||||||
public partial class MainView : UserControl
|
|
||||||
{
|
|
||||||
public MainView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel vm) return;
|
|
||||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
|
|
||||||
|
|
||||||
var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
|
|
||||||
{
|
|
||||||
Title = "Apri file wallet",
|
|
||||||
AllowMultiple = false,
|
|
||||||
FileTypeFilter =
|
|
||||||
[
|
|
||||||
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
|
||||||
vm.OpenFromPath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
|
||||||
if (lb.SelectedItem is not HistoryRow row) return;
|
|
||||||
|
|
||||||
// In-app overlay: appears immediately with a spinner; data arrives from
|
|
||||||
// the server in the background. No top-level window (slow to open/close).
|
|
||||||
_ = vm.ShowTransactionDetailsAsync(row.Txid);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTxDetailsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
|
||||||
if (DataContext is MainWindowViewModel vm)
|
|
||||||
vm.CloseTransactionDetailsCommand.Execute(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAddressListTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
|
|
||||||
return;
|
|
||||||
vm.ShowAddressInfo(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAddressListPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!e.GetCurrentPoint(null).Properties.IsRightButtonPressed) return;
|
|
||||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
|
||||||
|
|
||||||
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>();
|
|
||||||
if (item is not { DataContext: AddressRow row }) return;
|
|
||||||
|
|
||||||
lb.SelectedItem = row;
|
|
||||||
vm.ShowAddressInfo(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the address-detail overlay: click on the dark backdrop
|
|
||||||
// (backdrop only, not the card itself) or press Esc.
|
|
||||||
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
|
||||||
if (DataContext is MainWindowViewModel vm)
|
|
||||||
vm.AddressInfo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnServerOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
|
||||||
if (DataContext is MainWindowViewModel vm)
|
|
||||||
vm.IsServerSettingsOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnChooseDataFolderClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel vm) return;
|
|
||||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
|
|
||||||
|
|
||||||
var folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
|
||||||
{
|
|
||||||
Title = "Cartella dati Palladium Wallet",
|
|
||||||
AllowMultiple = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
|
||||||
vm.ApplyDataLocation(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnCopyReceiveAddressClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.ReceiveAddress))
|
|
||||||
return;
|
|
||||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
|
||||||
{
|
|
||||||
await clipboard.SetTextAsync(vm.ReceiveAddress);
|
|
||||||
vm.NotifyAddressCopied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is MainWindowViewModel vm)
|
|
||||||
vm.IsServerSettingsOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
|
||||||
if (DataContext is MainWindowViewModel vm)
|
|
||||||
vm.IsSettingsOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHelpOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
|
||||||
if (DataContext is MainWindowViewModel vm)
|
|
||||||
vm.IsHelpOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
|
|
||||||
{
|
|
||||||
if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
|
|
||||||
if (vm.AddressInfo is not null) { vm.AddressInfo = null; e.Handled = true; return; }
|
|
||||||
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
|
|
||||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
|
||||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
|
||||||
}
|
|
||||||
base.OnKeyDown(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,302 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PalladiumWallet.App.ViewModels"
|
xmlns:vm="using:PalladiumWallet.App.ViewModels"
|
||||||
xmlns:views="using:PalladiumWallet.App.Views"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="1020" d:DesignHeight="680"
|
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
|
||||||
x:Class="PalladiumWallet.App.Views.MainWindow"
|
x:Class="PalladiumWallet.App.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Icon="/Assets/logo.ico"
|
Icon="/Assets/avalonia-logo.ico"
|
||||||
Width="1020" Height="680"
|
Width="900" Height="620"
|
||||||
MinWidth="1020" MinHeight="680"
|
Title="Palladium Wallet">
|
||||||
MaxWidth="1020" MaxHeight="680"
|
|
||||||
CanResize="False"
|
|
||||||
Title="{Binding WindowTitle}">
|
|
||||||
|
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:MainWindowViewModel/>
|
<vm:MainWindowViewModel/>
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
|
|
||||||
<views:MainView/>
|
<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>
|
</Window>
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
namespace PalladiumWallet.App.Views;
|
namespace PalladiumWallet.App.Views;
|
||||||
|
|
||||||
/// <summary>Finestra desktop: ospita <see cref="MainView"/> (la UI condivisa con mobile).</summary>
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
|
||||||
|
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Apri file wallet",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter =
|
||||||
|
[
|
||||||
|
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
||||||
|
vm.OpenFromPath(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -131,17 +131,8 @@ static async Task<int> Sync(string[] o)
|
|||||||
|
|
||||||
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
|
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
|
||||||
sync.Progress += msg => Console.WriteLine($" {msg}");
|
sync.Progress += msg => Console.WriteLine($" {msg}");
|
||||||
var net = PalladiumNetworks.For(account.Profile.Kind);
|
|
||||||
sync.PreloadCaches(
|
|
||||||
doc.Cache?.RawTxHex ?? [],
|
|
||||||
doc.Cache?.VerifiedAt ?? [],
|
|
||||||
doc.Cache?.BlockHeaders,
|
|
||||||
doc.Cache?.NextReceiveIndex ?? 0,
|
|
||||||
doc.Cache?.NextChangeIndex ?? 0,
|
|
||||||
net);
|
|
||||||
var result = await sync.SyncOnceAsync();
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
|
|
||||||
doc.Cache = new SyncCache
|
doc.Cache = new SyncCache
|
||||||
{
|
{
|
||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
@@ -152,9 +143,6 @@ static async Task<int> Sync(string[] o)
|
|||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
Utxos = [.. result.Utxos],
|
Utxos = [.. result.Utxos],
|
||||||
Addresses = [.. result.AddressRows],
|
Addresses = [.. result.AddressRows],
|
||||||
RawTxHex = rawHex,
|
|
||||||
VerifiedAt = verifiedAt,
|
|
||||||
BlockHeaders = blockHeaders,
|
|
||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
@@ -294,7 +282,7 @@ static int SaveWallet(string words, string[] o)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
static (WalletDocument, HdAccount, string) OpenWallet(string[] o)
|
||||||
{
|
{
|
||||||
var path = WalletPath(o, Profile(o));
|
var path = WalletPath(o, Profile(o));
|
||||||
if (!WalletStore.Exists(path))
|
if (!WalletStore.Exists(path))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace PalladiumWallet.Core.Chain;
|
namespace PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Supported network type (single selector for the whole wallet, blueprint §3).
|
/// Tipo di rete supportato (selettore unico per tutto il wallet, blueprint §3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum NetKind
|
public enum NetKind
|
||||||
{
|
{
|
||||||
@@ -11,7 +11,7 @@ public enum NetKind
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Supported script/address type (blueprint §4.3).
|
/// Tipo di script/indirizzo supportato (blueprint §4.3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum ScriptKind
|
public enum ScriptKind
|
||||||
{
|
{
|
||||||
@@ -21,7 +21,7 @@ public enum ScriptKind
|
|||||||
/// <summary>P2SH-P2WPKH (segwit wrapped).</summary>
|
/// <summary>P2SH-P2WPKH (segwit wrapped).</summary>
|
||||||
WrappedSegwit,
|
WrappedSegwit,
|
||||||
|
|
||||||
/// <summary>P2WPKH (native segwit) — recommended default.</summary>
|
/// <summary>P2WPKH (native segwit) — default consigliato.</summary>
|
||||||
NativeSegwit,
|
NativeSegwit,
|
||||||
|
|
||||||
/// <summary>P2SH-P2WSH (multisig wrapped).</summary>
|
/// <summary>P2SH-P2WSH (multisig wrapped).</summary>
|
||||||
@@ -29,21 +29,18 @@ public enum ScriptKind
|
|||||||
|
|
||||||
/// <summary>P2WSH (multisig native).</summary>
|
/// <summary>P2WSH (multisig native).</summary>
|
||||||
NativeSegwitMultisig,
|
NativeSegwitMultisig,
|
||||||
|
|
||||||
/// <summary>P2TR key-path only (Taproot, BIP86) — witness v1, bech32m.</summary>
|
|
||||||
Taproot,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pair of BIP32 version headers (4-byte big-endian) to serialize
|
/// Coppia di header di versione BIP32 (4 byte big-endian) per serializzare
|
||||||
/// xprv/xpub and SLIP-132 variants (y/z/Y/Z) — blueprint §3.
|
/// xprv/xpub e varianti SLIP-132 (y/z/Y/Z) — blueprint §3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
||||||
{
|
{
|
||||||
/// <summary>Private header as 4 big-endian bytes (to prepend to the BIP32 payload).</summary>
|
/// <summary>Header privato come 4 byte big-endian (da anteporre al payload BIP32).</summary>
|
||||||
public byte[] PrivateBytes() => ToBytes(Private);
|
public byte[] PrivateBytes() => ToBytes(Private);
|
||||||
|
|
||||||
/// <summary>Public header as 4 big-endian bytes.</summary>
|
/// <summary>Header pubblico come 4 byte big-endian.</summary>
|
||||||
public byte[] PublicBytes() => ToBytes(Public);
|
public byte[] PublicBytes() => ToBytes(Public);
|
||||||
|
|
||||||
internal static byte[] ToBytes(uint header) =>
|
internal static byte[] ToBytes(uint header) =>
|
||||||
@@ -56,82 +53,82 @@ public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bootstrap indexing server: host + TCP port + SSL port (blueprint §3/§9).
|
/// Server di indicizzazione di bootstrap: host + porta TCP + porta SSL (blueprint §3/§9).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ServerEndpoint(string Host, int TcpPort, int SslPort);
|
public readonly record struct ServerEndpoint(string Host, int TcpPort, int SslPort);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Chain checkpoint: anchors trust when PoW validation is disabled
|
/// Checkpoint di catena: ancora la fiducia quando la verifica PoW è disattivata
|
||||||
/// (blueprint §7.3). Target serialized as compact "bits".
|
/// (blueprint §7.3). Target serializzato come "bits" compatti.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct Checkpoint(int Height, string BlockHash, uint Bits);
|
public readonly record struct Checkpoint(int Height, string BlockHash, uint Bits);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Network/chain profile (blueprint §3): all chain-specific constants,
|
/// Profilo di rete/catena (blueprint §3): tutte le costanti specifiche della catena,
|
||||||
/// centralized in one place. No magic numbers elsewhere in the code.
|
/// centralizzate in un solo punto. Nessun magic number altrove nel codice.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ChainProfile
|
public sealed record ChainProfile
|
||||||
{
|
{
|
||||||
public required NetKind Kind { get; init; }
|
public required NetKind Kind { get; init; }
|
||||||
|
|
||||||
/// <summary>Network name, also used as the data subfolder.</summary>
|
/// <summary>Nome rete, usato anche come sottocartella dati.</summary>
|
||||||
public required string NetName { get; init; }
|
public required string NetName { get; init; }
|
||||||
|
|
||||||
/// <summary>Unit symbol (e.g. PLM).</summary>
|
/// <summary>Simbolo dell'unità (es. PLM).</summary>
|
||||||
public required string CoinUnit { get; init; }
|
public required string CoinUnit { get; init; }
|
||||||
|
|
||||||
/// <summary>WIF prefix for private keys.</summary>
|
/// <summary>Prefisso WIF delle chiavi private.</summary>
|
||||||
public required byte WifPrefix { get; init; }
|
public required byte WifPrefix { get; init; }
|
||||||
|
|
||||||
/// <summary>Version byte for P2PKH addresses.</summary>
|
/// <summary>Byte di versione indirizzi P2PKH.</summary>
|
||||||
public required byte AddrP2pkh { get; init; }
|
public required byte AddrP2pkh { get; init; }
|
||||||
|
|
||||||
/// <summary>Version byte for P2SH addresses.</summary>
|
/// <summary>Byte di versione indirizzi P2SH.</summary>
|
||||||
public required byte AddrP2sh { get; init; }
|
public required byte AddrP2sh { get; init; }
|
||||||
|
|
||||||
/// <summary>Human-readable part bech32/bech32m.</summary>
|
/// <summary>Human-readable part bech32/bech32m.</summary>
|
||||||
public required string SegwitHrp { get; init; }
|
public required string SegwitHrp { get; init; }
|
||||||
|
|
||||||
/// <summary>HRP for BOLT11 invoices (Lightning, later phase — §11).</summary>
|
/// <summary>HRP fatture BOLT11 (Lightning, fase successiva — §11).</summary>
|
||||||
public required string Bolt11Hrp { get; init; }
|
public required string Bolt11Hrp { get; init; }
|
||||||
|
|
||||||
/// <summary>Genesis block hash (hex, explorer byte order).</summary>
|
/// <summary>Hash del blocco genesi (hex, byte order da explorer).</summary>
|
||||||
public required string GenesisHash { get; init; }
|
public required string GenesisHash { get; init; }
|
||||||
|
|
||||||
/// <summary>TCP port of the indexing server (not the node's P2P port).</summary>
|
/// <summary>Porta TCP del server di indicizzazione (non è la porta P2P del nodo).</summary>
|
||||||
public required int DefaultTcpPort { get; init; }
|
public required int DefaultTcpPort { get; init; }
|
||||||
|
|
||||||
/// <summary>SSL port of the indexing server.</summary>
|
/// <summary>Porta SSL del server di indicizzazione.</summary>
|
||||||
public required int DefaultSslPort { get; init; }
|
public required int DefaultSslPort { get; init; }
|
||||||
|
|
||||||
/// <summary>Node's P2P port — informational only: the SPV wallet does not use it.</summary>
|
/// <summary>Porta P2P del nodo — solo informativa: il wallet SPV non la usa.</summary>
|
||||||
public required int NodeP2pPort { get; init; }
|
public required int NodeP2pPort { get; init; }
|
||||||
|
|
||||||
/// <summary>SLIP-0044 coin type in derivation paths (wallet convention).</summary>
|
/// <summary>Coin type SLIP-0044 nei derivation path (convenzione wallet).</summary>
|
||||||
public required int Bip44CoinType { get; init; }
|
public required int Bip44CoinType { get; init; }
|
||||||
|
|
||||||
/// <summary>BIP21 payment URI scheme (without the colon).</summary>
|
/// <summary>Schema URI pagamenti BIP21 (senza i due punti).</summary>
|
||||||
public required string UriScheme { get; init; }
|
public required string UriScheme { get; init; }
|
||||||
|
|
||||||
/// <summary>Default block explorer.</summary>
|
/// <summary>Block explorer di default.</summary>
|
||||||
public required string ExplorerUrl { get; init; }
|
public required string ExplorerUrl { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The chain uses LWMA (per-block retargeting, 2 minutes): an SPV client cannot
|
/// La catena usa LWMA (retargeting per-blocco, 2 minuti): un client SPV non può
|
||||||
/// recompute it, so bits/target validation must be skipped and trust anchored
|
/// ricalcolarla, quindi la verifica bits/target va saltata e la fiducia ancorata
|
||||||
/// to checkpoints (§3/§7). For the reference chain it is always true.
|
/// ai checkpoint (§3/§7). Per la catena di riferimento è sempre true.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required bool SkipPowValidation { get; init; }
|
public required bool SkipPowValidation { get; init; }
|
||||||
|
|
||||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
/// <summary>Tempo di blocco target in secondi (LWMA v2: 120s).</summary>
|
||||||
public required int BlockTimeSeconds { get; init; }
|
public required int BlockTimeSeconds { get; init; }
|
||||||
|
|
||||||
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
/// <summary>Header BIP32/SLIP-132 per ciascun tipo di script.</summary>
|
||||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||||
|
|
||||||
/// <summary>Indexing servers for first contact (bootstrap).</summary>
|
/// <summary>Server di indicizzazione per il primo contatto (bootstrap).</summary>
|
||||||
public required IReadOnlyList<ServerEndpoint> BootstrapServers { get; init; }
|
public required IReadOnlyList<ServerEndpoint> BootstrapServers { get; init; }
|
||||||
|
|
||||||
/// <summary>Hardcoded checkpoints, updated at every release (§7.3).</summary>
|
/// <summary>Checkpoint hardcoded, aggiornati a ogni release (§7.3).</summary>
|
||||||
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
|
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace PalladiumWallet.Core.Chain;
|
namespace PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The wallet's three network profiles (blueprint §3). Mainnet values verified
|
/// I tre profili di rete del wallet (blueprint §3). Valori mainnet verificati
|
||||||
/// against the node source (chainparams.cpp / pow.cpp) per the blueprint.
|
/// contro il sorgente del nodo (chainparams.cpp / pow.cpp) secondo il blueprint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChainProfiles
|
public static class ChainProfiles
|
||||||
{
|
{
|
||||||
@@ -12,11 +12,11 @@ public static class ChainProfiles
|
|||||||
NetName = "mainnet",
|
NetName = "mainnet",
|
||||||
CoinUnit = "PLM",
|
CoinUnit = "PLM",
|
||||||
WifPrefix = 0x80,
|
WifPrefix = 0x80,
|
||||||
AddrP2pkh = 55, // addresses starting with 'P'
|
AddrP2pkh = 55, // indirizzi che iniziano con 'P'
|
||||||
AddrP2sh = 5, // addresses starting with '3'
|
AddrP2sh = 5, // indirizzi che iniziano con '3'
|
||||||
SegwitHrp = "plm",
|
SegwitHrp = "plm",
|
||||||
Bolt11Hrp = "plm",
|
Bolt11Hrp = "plm",
|
||||||
// Mainnet reuses Bitcoin's genesis (blueprint §3).
|
// La mainnet riusa la genesi di Bitcoin (blueprint §3).
|
||||||
GenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
GenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
||||||
DefaultTcpPort = 50001,
|
DefaultTcpPort = 50001,
|
||||||
DefaultSslPort = 50002,
|
DefaultSslPort = 50002,
|
||||||
@@ -33,11 +33,9 @@ public static class ChainProfiles
|
|||||||
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
||||||
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
||||||
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
||||||
// No SLIP-132 for P2TR: the context is given by the m/86'/… path
|
|
||||||
[ScriptKind.Taproot] = new(0x0488ade4, 0x0488b21e), // xprv / xpub (BIP32 standard)
|
|
||||||
},
|
},
|
||||||
// Known indexing servers for first contact (§3/§9); other
|
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
||||||
// peers are discovered via server.peers.subscribe.
|
// peer vengono scoperti via server.peers.subscribe.
|
||||||
BootstrapServers =
|
BootstrapServers =
|
||||||
[
|
[
|
||||||
new ServerEndpoint("173.212.224.67", 50001, 50002),
|
new ServerEndpoint("173.212.224.67", 50001, 50002),
|
||||||
@@ -45,7 +43,7 @@ public static class ChainProfiles
|
|||||||
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
||||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||||
],
|
],
|
||||||
// TODO: populate with the chain's real [hash, bits] (§7.3) before release.
|
// TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release.
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,21 +56,20 @@ public static class ChainProfiles
|
|||||||
AddrP2sh = 115,
|
AddrP2sh = 115,
|
||||||
SegwitHrp = "tplm",
|
SegwitHrp = "tplm",
|
||||||
Bolt11Hrp = "tplm",
|
Bolt11Hrp = "tplm",
|
||||||
// TODO: verify against the node's chainparams.cpp (the blueprint does not report it;
|
// TODO: verificare contro chainparams.cpp del nodo (il blueprint non la riporta;
|
||||||
// here Bitcoin testnet3 genesis is assumed, to be confirmed).
|
// qui si assume la genesi di Bitcoin testnet3, da confermare).
|
||||||
GenesisHash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943",
|
GenesisHash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943",
|
||||||
NodeP2pPort = 12333,
|
NodeP2pPort = 12333,
|
||||||
Bip44CoinType = 1,
|
Bip44CoinType = 1,
|
||||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||||
{
|
{
|
||||||
// tprv/tpub from the blueprint; the testnet SLIP-132 variants (u/v/U/V) are
|
// tprv/tpub dal blueprint; le varianti SLIP-132 testnet (u/v/U/V) sono
|
||||||
// the standard Electrum values.
|
// i valori standard Electrum.
|
||||||
[ScriptKind.Legacy] = new(0x04358394, 0x043587cf), // tprv / tpub
|
[ScriptKind.Legacy] = new(0x04358394, 0x043587cf), // tprv / tpub
|
||||||
[ScriptKind.WrappedSegwit] = new(0x044a4e28, 0x044a5262), // uprv / upub
|
[ScriptKind.WrappedSegwit] = new(0x044a4e28, 0x044a5262), // uprv / upub
|
||||||
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
||||||
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
|
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
|
||||||
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
|
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
|
||||||
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
|
||||||
},
|
},
|
||||||
BootstrapServers = [],
|
BootstrapServers = [],
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
@@ -84,7 +81,7 @@ public static class ChainProfiles
|
|||||||
NetName = "regtest",
|
NetName = "regtest",
|
||||||
SegwitHrp = "rplm",
|
SegwitHrp = "rplm",
|
||||||
Bolt11Hrp = "rplm",
|
Bolt11Hrp = "rplm",
|
||||||
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
// TODO: verificare contro chainparams.cpp del nodo (assunta genesi regtest Bitcoin).
|
||||||
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
||||||
NodeP2pPort = 28444,
|
NodeP2pPort = 28444,
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ using NBitcoin.DataEncoders;
|
|||||||
namespace PalladiumWallet.Core.Chain;
|
namespace PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds and registers the Palladium NBitcoin <see cref="Network"/> instances from the
|
/// Costruisce e registra le <see cref="Network"/> NBitcoin di Palladium a partire dai
|
||||||
/// <see cref="ChainProfiles"/> (blueprint §19.3). All other code obtains the network
|
/// <see cref="ChainProfiles"/> (blueprint §19.3). Tutto il resto del codice ottiene la
|
||||||
/// from here — never from scattered constants.
|
/// rete da qui, mai da costanti sparse.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Coin identity for NBitcoin: groups the three networks under the PLM code.
|
/// Identità della moneta per NBitcoin: raggruppa le tre reti sotto il codice PLM.
|
||||||
/// Getters are lazy, so no recursion occurs during construction.
|
/// I getter sono lazy, quindi nessuna ricorsione durante la costruzione.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PalladiumNetworkSet : INetworkSet
|
public sealed class PalladiumNetworkSet : INetworkSet
|
||||||
{
|
{
|
||||||
@@ -34,13 +34,13 @@ public sealed class PalladiumNetworkSet : INetworkSet
|
|||||||
|
|
||||||
public static class PalladiumNetworks
|
public static class PalladiumNetworks
|
||||||
{
|
{
|
||||||
// Bitcoin genesis block (Palladium mainnet reuses it, blueprint §3).
|
// Blocco genesi di Bitcoin (la mainnet Palladium lo riusa, blueprint §3).
|
||||||
private const string MainGenesisHex =
|
private const string MainGenesisHex =
|
||||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" +
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" +
|
||||||
Coinbase;
|
Coinbase;
|
||||||
|
|
||||||
// Bitcoin testnet3/regtest genesis: same coinbase, different time/bits/nonce.
|
// Genesi testnet3/regtest di Bitcoin: stessa coinbase, cambiano time/bits/nonce.
|
||||||
// TODO: confirm against the node's chainparams.cpp (see ChainProfiles).
|
// TODO: confermare contro chainparams.cpp del nodo (vedi ChainProfiles).
|
||||||
private const string TestGenesisHex =
|
private const string TestGenesisHex =
|
||||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff001d1aa4ae18" +
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff001d1aa4ae18" +
|
||||||
Coinbase;
|
Coinbase;
|
||||||
@@ -81,9 +81,9 @@ public static class PalladiumNetworks
|
|||||||
NetKind.Testnet => ChainName.Testnet,
|
NetKind.Testnet => ChainName.Testnet,
|
||||||
_ => ChainName.Regtest,
|
_ => ChainName.Regtest,
|
||||||
})
|
})
|
||||||
// P2P magic is a placeholder only: the SPV wallet never opens P2P connections
|
// Magic P2P solo segnaposto: il wallet SPV non apre mai connessioni P2P
|
||||||
// (it only talks to the indexing server, §3). Required by NBitcoin to
|
// (parla solo col server di indicizzazione, §3). Serve a NBitcoin per
|
||||||
// distinguish registered networks.
|
// distinguere le reti registrate.
|
||||||
.SetMagic(magic)
|
.SetMagic(magic)
|
||||||
.SetPort(p.NodeP2pPort)
|
.SetPort(p.NodeP2pPort)
|
||||||
.SetRPCPort(p.NodeP2pPort + 1)
|
.SetRPCPort(p.NodeP2pPort + 1)
|
||||||
@@ -91,9 +91,9 @@ public static class PalladiumNetworks
|
|||||||
.SetBase58Bytes(Base58Type.PUBKEY_ADDRESS, [p.AddrP2pkh])
|
.SetBase58Bytes(Base58Type.PUBKEY_ADDRESS, [p.AddrP2pkh])
|
||||||
.SetBase58Bytes(Base58Type.SCRIPT_ADDRESS, [p.AddrP2sh])
|
.SetBase58Bytes(Base58Type.SCRIPT_ADDRESS, [p.AddrP2sh])
|
||||||
.SetBase58Bytes(Base58Type.SECRET_KEY, [p.WifPrefix])
|
.SetBase58Bytes(Base58Type.SECRET_KEY, [p.WifPrefix])
|
||||||
// NBitcoin handles only one BIP32 header pair per network: the standard
|
// NBitcoin gestisce una sola coppia di header BIP32 per rete: quella
|
||||||
// xprv/xpub. SLIP-132 variants (y/z/Y/Z) are serialised manually via
|
// standard xprv/xpub. Le varianti SLIP-132 (y/z/Y/Z) si serializzano
|
||||||
// ChainProfile.ExtKeyHeaders when needed.
|
// a mano con ChainProfile.ExtKeyHeaders quando servono.
|
||||||
.SetBase58Bytes(Base58Type.EXT_SECRET_KEY, legacy.PrivateBytes())
|
.SetBase58Bytes(Base58Type.EXT_SECRET_KEY, legacy.PrivateBytes())
|
||||||
.SetBase58Bytes(Base58Type.EXT_PUBLIC_KEY, legacy.PublicBytes())
|
.SetBase58Bytes(Base58Type.EXT_PUBLIC_KEY, legacy.PublicBytes())
|
||||||
.SetBech32(Bech32Type.WITNESS_PUBKEY_ADDRESS, Encoders.Bech32(p.SegwitHrp))
|
.SetBech32(Bech32Type.WITNESS_PUBKEY_ADDRESS, Encoders.Bech32(p.SegwitHrp))
|
||||||
@@ -102,8 +102,8 @@ public static class PalladiumNetworks
|
|||||||
.SetUriScheme(p.UriScheme)
|
.SetUriScheme(p.UriScheme)
|
||||||
.SetConsensus(new Consensus
|
.SetConsensus(new Consensus
|
||||||
{
|
{
|
||||||
// Values used only by NBitcoin APIs that require them: real header
|
// Valori usati solo dalle API NBitcoin che li richiedono: la validazione
|
||||||
// validation is custom (Core/Spv) with PoW skip + checkpoints (§7).
|
// header reale è custom (Core/Spv) con skip PoW + checkpoint (§7).
|
||||||
PowLimit = new Target(new uint256("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
|
PowLimit = new Target(new uint256("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
|
||||||
PowTargetSpacing = TimeSpan.FromSeconds(p.BlockTimeSeconds),
|
PowTargetSpacing = TimeSpan.FromSeconds(p.BlockTimeSeconds),
|
||||||
PowTargetTimespan = TimeSpan.FromDays(14),
|
PowTargetTimespan = TimeSpan.FromDays(14),
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ using NBitcoin;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Supported BIP39 wordlist languages (blueprint §4.1). The lists are embedded
|
/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate
|
||||||
/// in NBitcoin: no network or filesystem access required.
|
/// in NBitcoin: nessun accesso a rete o filesystem.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum MnemonicLanguage
|
public enum MnemonicLanguage
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,7 @@ public enum MnemonicLanguage
|
|||||||
Czech,
|
Czech,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Supported word counts for mnemonic creation (blueprint §4.1: 12 or 24).</summary>
|
/// <summary>Numero di parole supportato in creazione (blueprint §4.1: 12 o 24).</summary>
|
||||||
public enum MnemonicLength
|
public enum MnemonicLength
|
||||||
{
|
{
|
||||||
Twelve = 12,
|
Twelve = 12,
|
||||||
@@ -26,16 +26,16 @@ public enum MnemonicLength
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BIP39 facade over NBitcoin.Mnemonic (blueprint §4.1). NBitcoin already handles
|
/// Facciata BIP39 su NBitcoin.Mnemonic (blueprint §4.1). NBitcoin copre già
|
||||||
/// entropy→words, checksum, NFKD normalisation, and PBKDF2-HMAC-SHA512 with 2048
|
/// entropia→parole, checksum, normalizzazione NFKD e PBKDF2-HMAC-SHA512 a 2048
|
||||||
/// rounds using salt "mnemonic"+passphrase. This class narrows the API to the
|
/// round con salt "mnemonic"+passphrase: qui si restringe l'API ai casi del
|
||||||
/// blueprint use-cases and centralises the language map. When the native versioned
|
/// blueprint e si centralizza la mappa delle lingue. Quando arriverà il seed
|
||||||
/// seed arrives (§4.1 point 1), multi-scheme recognition will be a chain of
|
/// nativo versionato (§4.1 punto 1), il riconoscimento multi-schema sarà una
|
||||||
/// TryParse calls per scheme.
|
/// catena di TryParse per schema.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Bip39
|
public static class Bip39
|
||||||
{
|
{
|
||||||
/// <summary>Generates a new mnemonic using entropy from the system CSPRNG.</summary>
|
/// <summary>Genera una nuova mnemonica con entropia dal CSPRNG di sistema.</summary>
|
||||||
public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English)
|
public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English)
|
||||||
{
|
{
|
||||||
var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
|
var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
|
||||||
@@ -43,10 +43,10 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recognises and validates a BIP39 mnemonic: word count, wordlist membership,
|
/// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla
|
||||||
/// and checksum. If <paramref name="language"/> is specified it is tried first;
|
/// wordlist e checksum. Se <paramref name="language"/> è indicata viene provata
|
||||||
/// otherwise the language is auto-detected (words shared between lists can make
|
/// per prima; altrimenti la lingua è auto-rilevata (parole condivise tra liste
|
||||||
/// autodetect ambiguous — the user can override it on import).
|
/// possono rendere ambiguo l'autodetect: in import l'utente può forzarla).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
|
public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
|
||||||
{
|
{
|
||||||
@@ -54,8 +54,8 @@ public static class Bip39
|
|||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Outer trim only: NBitcoin handles NFKD normalisation and Japanese
|
// Solo trim esterno: la normalizzazione NFKD e lo spazio ideografico
|
||||||
// ideographic spaces — the text must not be altered further (§4.1).
|
// giapponese li gestisce NBitcoin, il testo non va alterato (§4.1).
|
||||||
text = text.Trim();
|
text = text.Trim();
|
||||||
|
|
||||||
IEnumerable<Wordlist> candidates = language is not null
|
IEnumerable<Wordlist> candidates = language is not null
|
||||||
@@ -67,7 +67,7 @@ public static class Bip39
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsed = new Mnemonic(text, wordlist);
|
var parsed = new Mnemonic(text, wordlist);
|
||||||
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
// Il costruttore NON verifica il checksum: controllo esplicito obbligatorio.
|
||||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||||
{
|
{
|
||||||
mnemonic = parsed;
|
mnemonic = parsed;
|
||||||
@@ -76,7 +76,7 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
// Words outside the wordlist or invalid count — try the next candidate.
|
// Parole fuori wordlist o conteggio non valido: si prova oltre.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +84,9 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mnemonic → 64-byte seed (PBKDF2-HMAC-SHA512, 2048 rounds, salt
|
/// Mnemonica → seed di 64 byte (PBKDF2-HMAC-SHA512, 2048 round, salt
|
||||||
/// "mnemonic"+passphrase). The passphrase completely changes the derived
|
/// "mnemonic"+passphrase). La passphrase cambia completamente il wallet
|
||||||
/// wallet — mandatory UI warnings required (§4.1).
|
/// derivato: avvisi UI obbligatori (§4.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
|
public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
|
||||||
mnemonic.DeriveSeed(passphrase);
|
mnemonic.DeriveSeed(passphrase);
|
||||||
|
|||||||
@@ -4,57 +4,52 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BIP44/49/84 derivation path construction (blueprint §4.2) and mapping of
|
/// Costruzione dei derivation path BIP44/49/84 (blueprint §4.2) e mappatura
|
||||||
/// ScriptKind → purpose / ScriptPubKeyType. Purposes are BIP protocol constants
|
/// ScriptKind → purpose / ScriptPubKeyType. I purpose sono costanti di protocollo
|
||||||
/// (chain-independent); coin_type always comes from the ChainProfile.
|
/// BIP (chain-independent); il coin_type viene sempre dal ChainProfile.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DerivationPaths
|
public static class DerivationPaths
|
||||||
{
|
{
|
||||||
/// <summary>BIP44 purpose (P2PKH legacy).</summary>
|
/// <summary>Purpose BIP44 (P2PKH legacy).</summary>
|
||||||
public const int PurposeLegacy = 44;
|
public const int PurposeLegacy = 44;
|
||||||
|
|
||||||
/// <summary>BIP49 purpose (P2SH-P2WPKH).</summary>
|
/// <summary>Purpose BIP49 (P2SH-P2WPKH).</summary>
|
||||||
public const int PurposeWrappedSegwit = 49;
|
public const int PurposeWrappedSegwit = 49;
|
||||||
|
|
||||||
/// <summary>BIP84 purpose (native P2WPKH).</summary>
|
/// <summary>Purpose BIP84 (P2WPKH nativo).</summary>
|
||||||
public const int PurposeNativeSegwit = 84;
|
public const int PurposeNativeSegwit = 84;
|
||||||
|
|
||||||
/// <summary>BIP48 purpose (multisig — next phase, §16 step 8).</summary>
|
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
|
||||||
public const int PurposeMultisig = 48;
|
public const int PurposeMultisig = 48;
|
||||||
|
|
||||||
/// <summary>BIP86 purpose (P2TR key-path, Taproot).</summary>
|
|
||||||
public const int PurposeTaproot = 86;
|
|
||||||
|
|
||||||
public static int PurposeFor(ScriptKind kind) => kind switch
|
public static int PurposeFor(ScriptKind kind) => kind switch
|
||||||
{
|
{
|
||||||
ScriptKind.Legacy => PurposeLegacy,
|
ScriptKind.Legacy => PurposeLegacy,
|
||||||
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
|
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
|
||||||
ScriptKind.NativeSegwit => PurposeNativeSegwit,
|
ScriptKind.NativeSegwit => PurposeNativeSegwit,
|
||||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
|
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
|
||||||
ScriptKind.Taproot => PurposeTaproot,
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// NBitcoin scriptPubKey type for single-pubkey derivation.
|
/// Tipo di scriptPubKey NBitcoin per la derivazione da pubkey singola.
|
||||||
/// Multisig types are derived from a redeem script, not a pubkey — they will
|
/// I tipi multisig derivano da redeem script, non da una pubkey: arriveranno
|
||||||
/// arrive with M-of-N wallet support (§4.5).
|
/// con i wallet M-di-N (§4.5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
|
public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
|
||||||
{
|
{
|
||||||
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
|
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
|
||||||
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
|
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
|
||||||
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
|
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
|
||||||
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
|
|
||||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
"Multisig types are derived from a redeem script: support planned with M-of-N wallets (§4.5)."),
|
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
/// Path di account relativo alla root: purpose'/coin'/account' (§4.2).
|
||||||
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
/// Il coin_type è quello del profilo (746 mainnet, 1 testnet).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
|
public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
|
||||||
{
|
{
|
||||||
@@ -64,7 +59,7 @@ public static class DerivationPaths
|
|||||||
$"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'");
|
$"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Non-hardened change/index sub-path under the account (change=0 receiving, change=1 change).</summary>
|
/// <summary>Sottopath non-hardened change/index sotto l'account (change=0 receiving, change=1 change).</summary>
|
||||||
public static KeyPath AddressSubPath(bool isChange, int index)
|
public static KeyPath AddressSubPath(bool isChange, int index)
|
||||||
{
|
{
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
@@ -73,8 +68,8 @@ public static class DerivationPaths
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsing of custom derivation paths on import (Sparrow-like, §4.2):
|
/// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2):
|
||||||
/// accepts the optional "m/" prefix and hardened markers ' or h.
|
/// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryParse(string path, out KeyPath? keyPath)
|
public static bool TryParse(string path, out KeyPath? keyPath)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,39 +4,39 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Crypto;
|
namespace PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single-sig HD account (blueprint §4.2/§4.4): extended account key (xprv,
|
/// Account HD single-sig (blueprint §4.2/§4.4): chiave estesa di account (xprv,
|
||||||
/// or xpub-only = watch-only) + script type + network profile. Derives receiving
|
/// oppure solo xpub = watch-only) + tipo di script + profilo di rete. Deriva
|
||||||
/// (change=0) and change (change=1) addresses on-demand by index.
|
/// indirizzi receiving (change=0) e change (change=1) on-demand per indice.
|
||||||
/// Addresses are always derived from the account xpub (non-hardened sub-path),
|
/// Gli indirizzi si derivano sempre dall'xpub di account (sottopath non-hardened),
|
||||||
/// so watch-only works by construction. The full keystore
|
/// quindi il watch-only funziona per costruzione. Il keystore completo
|
||||||
/// (encryption, factory from wallet file, §4.5) arrives with persistence (§8).
|
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HdAccount : IWalletAccount
|
public sealed class HdAccount
|
||||||
{
|
{
|
||||||
private readonly ExtKey? _accountXprv;
|
private readonly ExtKey? _accountXprv;
|
||||||
|
|
||||||
public ScriptKind Kind { get; }
|
public ScriptKind Kind { get; }
|
||||||
public ChainProfile Profile { get; }
|
public ChainProfile Profile { get; }
|
||||||
|
|
||||||
/// <summary>Account path relative to the root (e.g. 84'/746'/0', or custom).</summary>
|
/// <summary>Path dell'account relativo alla root (es. 84'/746'/0', o personalizzato).</summary>
|
||||||
public KeyPath AccountPath { get; }
|
public KeyPath AccountPath { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Master key fingerprint: together with <see cref="AccountPath"/> forms the
|
/// Fingerprint della chiave master: con <see cref="AccountPath"/> forma le
|
||||||
/// origin info required by PSBTs (§6.5). Zero if unknown (imported xpub
|
/// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata
|
||||||
/// without metadata).
|
/// senza metadati).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HDFingerprint MasterFingerprint { get; }
|
public HDFingerprint MasterFingerprint { get; }
|
||||||
|
|
||||||
public ExtPubKey AccountXpub { get; }
|
public ExtPubKey AccountXpub { get; }
|
||||||
|
|
||||||
/// <summary>True if the account only knows the xpub: can build but not sign (§4.5).</summary>
|
/// <summary>True se l'account conosce solo la xpub: costruisce ma non firma (§4.5).</summary>
|
||||||
public bool IsWatchOnly => _accountXprv is null;
|
public bool IsWatchOnly => _accountXprv is null;
|
||||||
|
|
||||||
private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
|
private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
|
||||||
ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint)
|
ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint)
|
||||||
{
|
{
|
||||||
// Validate the mapping immediately (multisig types are not supported here).
|
// Valida subito la mappatura (i tipi multisig non sono supportati qui).
|
||||||
DerivationPaths.ScriptPubKeyTypeFor(kind);
|
DerivationPaths.ScriptPubKeyTypeFor(kind);
|
||||||
_accountXprv = accountXprv;
|
_accountXprv = accountXprv;
|
||||||
AccountXpub = accountXpub;
|
AccountXpub = accountXpub;
|
||||||
@@ -46,7 +46,7 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
MasterFingerprint = masterFingerprint;
|
MasterFingerprint = masterFingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Main case: BIP39 mnemonic (+ optional passphrase) → standard account.</summary>
|
/// <summary>Caso principale: mnemonica BIP39 (+ passphrase opzionale) → account standard.</summary>
|
||||||
public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase,
|
public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase,
|
||||||
ScriptKind kind, ChainProfile profile, int account = 0) =>
|
ScriptKind kind, ChainProfile profile, int account = 0) =>
|
||||||
FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account);
|
FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account);
|
||||||
@@ -55,22 +55,22 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account));
|
FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Import with a custom derivation path (§4.2, Sparrow-like):
|
/// Import con derivation path personalizzato (§4.2, Sparrow-like):
|
||||||
/// <paramref name="kind"/> only determines the generated address type.
|
/// <paramref name="kind"/> determina solo il tipo di indirizzo generato.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
|
public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
|
||||||
{
|
{
|
||||||
var root = ExtKey.CreateFromSeed(seed);
|
var root = ExtKey.CreateFromSeed(seed);
|
||||||
// The account is always derived from the xprv: hardened path levels
|
// L'account si deriva sempre dalla xprv: i livelli hardened del path
|
||||||
// cannot be derived from an xpub alone.
|
// non sono derivabili da una xpub.
|
||||||
var accountXprv = root.Derive(accountPath);
|
var accountXprv = root.Derive(accountPath);
|
||||||
return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
|
return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
|
||||||
accountPath, root.Neuter().PubKey.GetHDFingerPrint());
|
accountPath, root.Neuter().PubKey.GetHDFingerPrint());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Watch-only from account xpub (§4.4): fingerprint and path are known only
|
/// Watch-only da xpub di account (§4.4): fingerprint e path sono noti solo
|
||||||
/// if supplied by the importer (required for PSBT origin info).
|
/// se forniti da chi importa (servono per le origin info PSBT).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind,
|
public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind,
|
||||||
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
||||||
@@ -78,7 +78,7 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
accountPath ?? DerivationPaths.AccountPath(kind, profile),
|
accountPath ?? DerivationPaths.AccountPath(kind, profile),
|
||||||
masterFingerprint ?? default);
|
masterFingerprint ?? default);
|
||||||
|
|
||||||
/// <summary>Spendable import from account xprv.</summary>
|
/// <summary>Import spendibile da xprv di account.</summary>
|
||||||
public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind,
|
public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind,
|
||||||
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
||||||
new(accountXprv, accountXprv.Neuter(), kind, profile,
|
new(accountXprv, accountXprv.Neuter(), kind, profile,
|
||||||
@@ -86,7 +86,7 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
masterFingerprint ?? default);
|
masterFingerprint ?? default);
|
||||||
|
|
||||||
public BitcoinAddress GetAddress(bool isChange, int index) =>
|
public BitcoinAddress GetAddress(bool isChange, int index) =>
|
||||||
GetPublicKey(isChange, index)!.GetAddress(
|
GetPublicKey(isChange, index).GetAddress(
|
||||||
DerivationPaths.ScriptPubKeyTypeFor(Kind),
|
DerivationPaths.ScriptPubKeyTypeFor(Kind),
|
||||||
PalladiumNetworks.For(Profile.Kind));
|
PalladiumNetworks.For(Profile.Kind));
|
||||||
|
|
||||||
@@ -94,30 +94,23 @@ public sealed class HdAccount : IWalletAccount
|
|||||||
|
|
||||||
public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index);
|
public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index);
|
||||||
|
|
||||||
public PubKey? GetPublicKey(bool isChange, int index) =>
|
public PubKey GetPublicKey(bool isChange, int index) =>
|
||||||
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
||||||
|
|
||||||
/// <summary>Private key for an address; null if watch-only (§17).</summary>
|
|
||||||
public Key? GetPrivateKey(bool isChange, int index) =>
|
|
||||||
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
|
||||||
|
|
||||||
/// <summary>HD accounts use the gap limit: no fixed address list.</summary>
|
|
||||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extended private key for an address. Throws if watch-only: no private key
|
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
||||||
/// can be derived from public keys alone (§17).
|
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ExtKey GetExtPrivateKey(bool isChange, int index) =>
|
public ExtKey GetExtPrivateKey(bool isChange, int index) =>
|
||||||
(_accountXprv ?? throw new InvalidOperationException("Watch-only account: cannot sign."))
|
(_accountXprv ?? throw new InvalidOperationException("Account watch-only: non può firmare."))
|
||||||
.Derive(DerivationPaths.AddressSubPath(isChange, index));
|
.Derive(DerivationPaths.AddressSubPath(isChange, index));
|
||||||
|
|
||||||
/// <summary>Account xpub in SLIP-132 format (xpub/ypub/zpub according to Kind).</summary>
|
/// <summary>Xpub di account in formato SLIP-132 (xpub/ypub/zpub secondo Kind).</summary>
|
||||||
public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
|
public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
|
||||||
|
|
||||||
/// <summary>Account xprv in SLIP-132 format. Throws if watch-only.</summary>
|
/// <summary>Xprv di account in formato SLIP-132. Lancia se watch-only.</summary>
|
||||||
public string ToSlip132Private() =>
|
public string ToSlip132Private() =>
|
||||||
Slip132.Encode(
|
Slip132.Encode(
|
||||||
_accountXprv ?? throw new InvalidOperationException("Watch-only account: no private key."),
|
_accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."),
|
||||||
Kind, Profile);
|
Kind, Profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Crypto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction over all wallet account types (HD from seed, HD from imported xpub/xprv,
|
|
||||||
/// imported WIF keys). Allows WalletSynchronizer and TransactionFactory to operate
|
|
||||||
/// independently of the underlying keystore type (blueprint §4.4–§4.5).
|
|
||||||
/// </summary>
|
|
||||||
public interface IWalletAccount
|
|
||||||
{
|
|
||||||
ScriptKind Kind { get; }
|
|
||||||
ChainProfile Profile { get; }
|
|
||||||
|
|
||||||
/// <summary>True if the account cannot sign (no private keys present).</summary>
|
|
||||||
bool IsWatchOnly { get; }
|
|
||||||
|
|
||||||
BitcoinAddress GetAddress(bool isChange, int index);
|
|
||||||
BitcoinAddress GetReceiveAddress(int index);
|
|
||||||
BitcoinAddress GetChangeAddress(int index);
|
|
||||||
|
|
||||||
/// <summary>Null if the public key cannot be derived from the account (pure watch-only addresses).</summary>
|
|
||||||
PubKey? GetPublicKey(bool isChange, int index);
|
|
||||||
|
|
||||||
/// <summary>Null if the account is watch-only or the index is out of range.</summary>
|
|
||||||
Key? GetPrivateKey(bool isChange, int index);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For accounts with fixed addresses (imported WIF keys) returns the full list
|
|
||||||
/// to scan; null for HD accounts that use the gap limit.
|
|
||||||
/// </summary>
|
|
||||||
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Crypto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Account built from individually imported WIF keys (blueprint §4.4 — "Imported"):
|
|
||||||
/// fixed list of addresses, no HD derivation, no change chain.
|
|
||||||
/// Change always goes back to the first imported address.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ImportedKeyAccount : IWalletAccount
|
|
||||||
{
|
|
||||||
private readonly (BitcoinAddress Address, Key? PrivateKey)[] _entries;
|
|
||||||
|
|
||||||
public ScriptKind Kind { get; }
|
|
||||||
public ChainProfile Profile { get; }
|
|
||||||
public bool IsWatchOnly => _entries.All(e => e.PrivateKey is null);
|
|
||||||
|
|
||||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses =>
|
|
||||||
_entries.Select((e, i) => (e.Address, false, i)).ToList();
|
|
||||||
|
|
||||||
public ImportedKeyAccount(
|
|
||||||
IReadOnlyList<(BitcoinAddress Address, Key? PrivateKey)> entries,
|
|
||||||
ScriptKind kind, ChainProfile profile)
|
|
||||||
{
|
|
||||||
if (entries.Count == 0)
|
|
||||||
throw new ArgumentException("At least one address is required.", nameof(entries));
|
|
||||||
_entries = [.. entries];
|
|
||||||
Kind = kind;
|
|
||||||
Profile = profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BitcoinAddress GetAddress(bool isChange, int index)
|
|
||||||
{
|
|
||||||
if (isChange || index < 0 || index >= _entries.Length)
|
|
||||||
return _entries[0].Address;
|
|
||||||
return _entries[index].Address;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BitcoinAddress GetReceiveAddress(int index) => GetAddress(false, index);
|
|
||||||
|
|
||||||
/// <summary>Change always returns to the first imported address.</summary>
|
|
||||||
public BitcoinAddress GetChangeAddress(int index) => _entries[0].Address;
|
|
||||||
|
|
||||||
public PubKey? GetPublicKey(bool isChange, int index)
|
|
||||||
{
|
|
||||||
if (isChange || index < 0 || index >= _entries.Length)
|
|
||||||
return null;
|
|
||||||
return _entries[index].PrivateKey?.PubKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Key? GetPrivateKey(bool isChange, int index)
|
|
||||||
{
|
|
||||||
if (isChange || index < 0 || index >= _entries.Length)
|
|
||||||
return null;
|
|
||||||
return _entries[index].PrivateKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,8 @@ public static class Slip132
|
|||||||
Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
|
Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recognises the header (→ ScriptKind) and decodes an extended public key:
|
/// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa:
|
||||||
/// this is the watch-only import path (§4.4).
|
/// è la via dell'import watch-only (§4.4).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryDecodePublic(string encoded, ChainProfile profile,
|
public static bool TryDecodePublic(string encoded, ChainProfile profile,
|
||||||
out ExtPubKey? key, out ScriptKind kind)
|
out ExtPubKey? key, out ScriptKind kind)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ using System.Text.Json;
|
|||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// TLS "trust on first use" pinning (blueprint §9): on the first contact the
|
/// Pinning TLS "trust on first use" (blueprint §9): al primo contatto la
|
||||||
/// server certificate's SHA-256 fingerprint is saved; on subsequent ones it is
|
/// fingerprint SHA-256 del certificato del server viene salvata; ai successivi
|
||||||
/// compared. If it changes, the connection must be rejected and the user can
|
/// viene confrontata. Se cambia, la connessione va rifiutata e l'utente può
|
||||||
/// unlock with an explicit reset (typical case: renewed self-signed server).
|
/// sbloccare con un reset esplicito (caso tipico: server self-signed rinnovato).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CertificatePinStore(string filePath)
|
public sealed class CertificatePinStore(string filePath)
|
||||||
{
|
{
|
||||||
@@ -18,8 +18,8 @@ public sealed class CertificatePinStore(string filePath)
|
|||||||
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
|
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// TOFU check: true if the certificate is the one already seen (or if it is the
|
/// Verifica TOFU: true se il certificato è quello già visto (o se è il primo
|
||||||
/// first contact, in which case it is saved). False = certificate changed.
|
/// contatto, nel qual caso viene salvato). False = certificato cambiato.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool VerifyOrPin(string host, int port, X509Certificate certificate)
|
public bool VerifyOrPin(string host, int port, X509Certificate certificate)
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,7 @@ public sealed class CertificatePinStore(string filePath)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reset the saved certificate for a server ("reset SSL certificates", §9).</summary>
|
/// <summary>Reset del certificato salvato per un server ("reset certificati SSL", §9).</summary>
|
||||||
public bool Reset(string host, int port)
|
public bool Reset(string host, int port)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>History entry for a scripthash (blockchain.scripthash.get_history).</summary>
|
/// <summary>Voce di storico di uno scripthash (blockchain.scripthash.get_history).</summary>
|
||||||
public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
|
public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
|
||||||
|
|
||||||
/// <summary>UTXO reported by the server (blockchain.scripthash.listunspent).</summary>
|
/// <summary>UTXO riportato dal server (blockchain.scripthash.listunspent).</summary>
|
||||||
public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
|
public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
|
||||||
|
|
||||||
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
/// <summary>Prova di Merkle (blockchain.transaction.get_merkle).</summary>
|
||||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||||
|
|
||||||
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
|
||||||
public readonly record struct ChainTip(int Height, string HeaderHex);
|
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||||
|
|
||||||
/// <summary>Peer announced by server.peers.subscribe (null ports = not offered).</summary>
|
/// <summary>Peer annunciato da server.peers.subscribe (porte null = non offerte).</summary>
|
||||||
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
|
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Typed helpers over the protocol methods (blueprint §10), on top of the
|
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
|
||||||
/// generic JSON-RPC transport of <see cref="ElectrumClient"/>.
|
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ElectrumApi
|
public static class ElectrumApi
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ public static class ElectrumApi
|
|||||||
return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!);
|
return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Current status of the scripthash (null = never used).</summary>
|
/// <summary>Status corrente dello scripthash (null = mai usato).</summary>
|
||||||
public static async Task<string?> SubscribeScripthashAsync(this ElectrumClient c, string scripthash,
|
public static async Task<string?> SubscribeScripthashAsync(this ElectrumClient c, string scripthash,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ public static class ElectrumApi
|
|||||||
return r.GetString()!;
|
return r.GetString()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Estimated fee in coin/kB for the given target; -1 if the server cannot estimate.</summary>
|
/// <summary>Fee stimata in coin/kB per il target dato; -1 se il server non sa stimare.</summary>
|
||||||
public static async Task<decimal> EstimateFeeAsync(this ElectrumClient c, int targetBlocks,
|
public static async Task<decimal> EstimateFeeAsync(this ElectrumClient c, int targetBlocks,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -112,7 +112,7 @@ public static class ElectrumApi
|
|||||||
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
|
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
|
||||||
c.RequestAsync("server.ping", ct);
|
c.RequestAsync("server.ping", ct);
|
||||||
|
|
||||||
/// <summary>Peers announced by the server (discovery of other servers, §9).</summary>
|
/// <summary>Peer annunciati dal server (scoperta di altri server, §9).</summary>
|
||||||
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
|
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -121,9 +121,9 @@ public static class ElectrumApi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the server.peers.subscribe response: a list of
|
/// Parsa la risposta di server.peers.subscribe: lista di
|
||||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
/// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]];
|
||||||
/// "t"/"s" without a number = network default port (resolved by the caller).
|
/// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
using System.Buffers;
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.IO.Pipelines;
|
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Channels;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client for the indexing server protocol (blueprint §10):
|
/// Client del protocollo del server di indicizzazione (blueprint §10):
|
||||||
/// newline-delimited JSON-RPC 2.0 over TCP, optionally TLS with TOFU pinning.
|
/// JSON-RPC 2.0 newline-delimited su TCP, opzionalmente TLS con pinning TOFU.
|
||||||
/// Notifications (subscriptions) arrive on the <see cref="NotificationReceived"/> event.
|
/// Le notifiche (subscription) arrivano sull'evento <see cref="NotificationReceived"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ElectrumClient : IAsyncDisposable
|
public sealed class ElectrumClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -20,25 +18,10 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
|
|
||||||
private readonly TcpClient _tcp;
|
private readonly TcpClient _tcp;
|
||||||
private readonly Stream _stream;
|
private readonly Stream _stream;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending = new();
|
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending = new();
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private readonly Task _readLoop;
|
private readonly Task _readLoop;
|
||||||
private readonly Task _writeLoop;
|
|
||||||
|
|
||||||
// Single-reader channel: tasks write the payloads without a lock;
|
|
||||||
// the write loop drains everything in a single WriteAsync+FlushAsync —
|
|
||||||
// identical to Electrum's asyncio buffered writer.
|
|
||||||
private readonly Channel<byte[]> _outgoing = Channel.CreateUnbounded<byte[]>(
|
|
||||||
new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false });
|
|
||||||
|
|
||||||
// Cap on in-flight requests (not in the write queue, but awaiting a
|
|
||||||
// response from the server). The write loop already batches the send into a
|
|
||||||
// single segment; this gate avoids flooding the server with thousands of
|
|
||||||
// simultaneous requests on large wallets → no bursts of -101/-102 nor
|
|
||||||
// connection drops. Writes still stay pipelined up to this degree.
|
|
||||||
private const int MaxInFlight = 32;
|
|
||||||
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight);
|
|
||||||
|
|
||||||
private long _nextId;
|
private long _nextId;
|
||||||
|
|
||||||
public string Host { get; }
|
public string Host { get; }
|
||||||
@@ -46,7 +29,10 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
public bool UseSsl { get; }
|
public bool UseSsl { get; }
|
||||||
public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested;
|
public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested;
|
||||||
|
|
||||||
|
/// <summary>(metodo, parametri) per le notifiche di subscription.</summary>
|
||||||
public event Action<string, JsonElement>? NotificationReceived;
|
public event Action<string, JsonElement>? NotificationReceived;
|
||||||
|
|
||||||
|
/// <summary>Scatta quando la connessione cade (errore di lettura o chiusura remota).</summary>
|
||||||
public event Action<Exception?>? Disconnected;
|
public event Action<Exception?>? Disconnected;
|
||||||
|
|
||||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
||||||
@@ -56,14 +42,18 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
Host = host;
|
Host = host;
|
||||||
Port = port;
|
Port = port;
|
||||||
UseSsl = useSsl;
|
UseSsl = useSsl;
|
||||||
_readLoop = Task.Run(ReadLoopAsync);
|
_readLoop = Task.Run(ReadLoopAsync);
|
||||||
_writeLoop = Task.Run(WriteLoopAsync);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connette al server. Con <paramref name="useSsl"/> la validazione del
|
||||||
|
/// certificato è TOFU tramite <paramref name="pins"/> (§9): primo contatto
|
||||||
|
/// salva, contatti successivi confrontano; mismatch ⇒ <see cref="CertificatePinMismatchException"/>.
|
||||||
|
/// </summary>
|
||||||
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
||||||
CertificatePinStore? pins = null, CancellationToken ct = default)
|
CertificatePinStore? pins = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tcp = new TcpClient { NoDelay = true };
|
var tcp = new TcpClient();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await tcp.ConnectAsync(host, port, ct);
|
await tcp.ConnectAsync(host, port, ct);
|
||||||
@@ -74,7 +64,10 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false,
|
var ssl = new SslStream(stream, leaveInnerStreamOpen: false,
|
||||||
(_, cert, _, _) =>
|
(_, cert, _, _) =>
|
||||||
{
|
{
|
||||||
if (cert is null) return false;
|
// I server Electrum sono tipicamente self-signed: la
|
||||||
|
// fiducia è il pin TOFU, non la catena CA (§9).
|
||||||
|
if (cert is null)
|
||||||
|
return false;
|
||||||
pinOk = pins is null || pins.VerifyOrPin(host, port, cert);
|
pinOk = pins is null || pins.VerifyOrPin(host, port, cert);
|
||||||
return pinOk;
|
return pinOk;
|
||||||
});
|
});
|
||||||
@@ -91,6 +84,7 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
||||||
|
// Negoziazione obbligatoria prima di ogni altra richiesta.
|
||||||
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
@@ -104,184 +98,98 @@ public sealed class ElectrumClient : IAsyncDisposable
|
|||||||
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
||||||
params object?[] parameters)
|
params object?[] parameters)
|
||||||
{
|
{
|
||||||
// Gate before going in-flight: beyond MaxInFlight pending requests
|
var id = Interlocked.Increment(ref _nextId);
|
||||||
// we wait for a response to free a slot, instead of flooding the server.
|
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
await _inFlight.WaitAsync(ct);
|
_pending[id] = tcs;
|
||||||
|
|
||||||
|
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
@params = parameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _writeLock.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var id = Interlocked.Increment(ref _nextId);
|
await _stream.WriteAsync(payload, ct);
|
||||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
await _stream.WriteAsync("\n"u8.ToArray(), ct);
|
||||||
_pending[id] = tcs;
|
await _stream.FlushAsync(ct);
|
||||||
|
|
||||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
|
||||||
{
|
|
||||||
jsonrpc = "2.0",
|
|
||||||
id,
|
|
||||||
method,
|
|
||||||
@params = parameters,
|
|
||||||
});
|
|
||||||
|
|
||||||
_outgoing.Writer.TryWrite(payload);
|
|
||||||
|
|
||||||
await using var registration = ct.Register(() =>
|
|
||||||
{
|
|
||||||
_pending.TryRemove(id, out _);
|
|
||||||
tcs.TrySetCanceled(ct);
|
|
||||||
});
|
|
||||||
return await tcs.Task;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_inFlight.Release();
|
_writeLock.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await using var registration = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||||
|
return await tcs.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drain loop: empties the channel into a single buffer → one WriteAsync+FlushAsync
|
|
||||||
/// for all queued messages. When N requests are queued, they are
|
|
||||||
/// transmitted in a single TCP segment instead of N serial flushes.
|
|
||||||
/// </summary>
|
|
||||||
private async Task WriteLoopAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (await _outgoing.Reader.WaitToReadAsync(_cts.Token))
|
|
||||||
{
|
|
||||||
using var ms = new MemoryStream(512);
|
|
||||||
while (_outgoing.Reader.TryRead(out var data))
|
|
||||||
{
|
|
||||||
ms.Write(data);
|
|
||||||
ms.WriteByte((byte)'\n');
|
|
||||||
}
|
|
||||||
await _stream.WriteAsync(ms.GetBuffer().AsMemory(0, (int)ms.Length), _cts.Token);
|
|
||||||
await _stream.FlushAsync(_cts.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { }
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
foreach (var (_, pending) in _pending)
|
|
||||||
pending.TrySetException(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read loop with PipeReader: pooled buffers, zero per-response allocations
|
|
||||||
/// (no intermediate string), JSON parsing directly from a byte span.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ReadLoopAsync()
|
private async Task ReadLoopAsync()
|
||||||
{
|
{
|
||||||
Exception? failure = null;
|
Exception? failure = null;
|
||||||
var pipe = PipeReader.Create(_stream, new StreamPipeReaderOptions(leaveOpen: true));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (true)
|
using var reader = new StreamReader(_stream, Encoding.UTF8, leaveOpen: true);
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
ReadResult result;
|
var line = await reader.ReadLineAsync(_cts.Token);
|
||||||
try { result = await pipe.ReadAsync(_cts.Token); }
|
if (line is null)
|
||||||
catch (OperationCanceledException) { break; }
|
break; // chiusura remota
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
continue;
|
||||||
|
|
||||||
var buffer = result.Buffer;
|
using var doc = JsonDocument.Parse(line);
|
||||||
try
|
var root = doc.RootElement;
|
||||||
{
|
|
||||||
while (TrySliceLine(ref buffer, out var line))
|
|
||||||
if (!line.IsEmpty)
|
|
||||||
DispatchLine(line);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// consumed = everything we have consumed (up to the last \n)
|
|
||||||
// examined = everything we have looked at (up to the end of the buffer)
|
|
||||||
pipe.AdvanceTo(buffer.Start, buffer.End);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.IsCompleted) break;
|
if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
if (!_pending.TryRemove(idEl.GetInt64(), out var tcs))
|
||||||
|
continue;
|
||||||
|
if (root.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null)
|
||||||
|
tcs.TrySetException(new ElectrumServerException(error.ToString()));
|
||||||
|
else
|
||||||
|
tcs.TrySetResult(root.GetProperty("result").Clone());
|
||||||
|
}
|
||||||
|
else if (root.TryGetProperty("method", out var methodEl))
|
||||||
|
{
|
||||||
|
NotificationReceived?.Invoke(
|
||||||
|
methodEl.GetString()!,
|
||||||
|
root.GetProperty("params").Clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
failure = ex;
|
failure = ex;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await pipe.CompleteAsync();
|
|
||||||
foreach (var (_, tcs) in _pending)
|
foreach (var (_, tcs) in _pending)
|
||||||
tcs.TrySetException(failure ?? new IOException("Connection to the server closed."));
|
tcs.TrySetException(failure ?? new IOException("Connessione al server chiusa."));
|
||||||
_pending.Clear();
|
_pending.Clear();
|
||||||
Disconnected?.Invoke(failure);
|
Disconnected?.Invoke(failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TrySliceLine(ref ReadOnlySequence<byte> buffer,
|
|
||||||
out ReadOnlySequence<byte> line)
|
|
||||||
{
|
|
||||||
var pos = buffer.PositionOf((byte)'\n');
|
|
||||||
if (pos is null) { line = default; return false; }
|
|
||||||
line = buffer.Slice(0, pos.Value);
|
|
||||||
buffer = buffer.Slice(buffer.GetPosition(1, pos.Value));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DispatchLine(ReadOnlySequence<byte> line)
|
|
||||||
{
|
|
||||||
if (line.IsSingleSegment)
|
|
||||||
{
|
|
||||||
DispatchSpan(line.FirstSpan);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Multi-segment (very long response): copy to ArrayPool then parse.
|
|
||||||
var len = (int)line.Length;
|
|
||||||
var buf = ArrayPool<byte>.Shared.Rent(len);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
line.CopyTo(buf);
|
|
||||||
DispatchSpan(buf.AsSpan(0, len));
|
|
||||||
}
|
|
||||||
finally { ArrayPool<byte>.Shared.Return(buf); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DispatchSpan(ReadOnlySpan<byte> utf8)
|
|
||||||
{
|
|
||||||
// JsonDocument.Parse via Utf8JsonReader: no intermediate string,
|
|
||||||
// parsing directly from the pooled span.
|
|
||||||
var reader = new Utf8JsonReader(utf8);
|
|
||||||
using var doc = JsonDocument.ParseValue(ref reader);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
|
|
||||||
{
|
|
||||||
if (!_pending.TryRemove(idEl.GetInt64(), out var tcs)) return;
|
|
||||||
if (root.TryGetProperty("error", out var err) && err.ValueKind != JsonValueKind.Null)
|
|
||||||
tcs.TrySetException(new ElectrumServerException(err.ToString()));
|
|
||||||
else
|
|
||||||
tcs.TrySetResult(root.GetProperty("result").Clone());
|
|
||||||
}
|
|
||||||
else if (root.TryGetProperty("method", out var methodEl))
|
|
||||||
{
|
|
||||||
NotificationReceived?.Invoke(
|
|
||||||
methodEl.GetString()!,
|
|
||||||
root.GetProperty("params").Clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _cts.CancelAsync();
|
await _cts.CancelAsync();
|
||||||
_outgoing.Writer.Complete();
|
|
||||||
_tcp.Close();
|
_tcp.Close();
|
||||||
try { await _readLoop; } catch { }
|
try { await _readLoop; } catch { /* in chiusura */ }
|
||||||
try { await _writeLoop; } catch { }
|
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
_inFlight.Dispose();
|
_writeLock.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Error returned by the server ("error" field of the JSON-RPC response).</summary>
|
/// <summary>Errore restituito dal server (campo "error" della risposta JSON-RPC).</summary>
|
||||||
public sealed class ElectrumServerException(string error) : Exception(error);
|
public sealed class ElectrumServerException(string error) : Exception(error);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The server certificate changed with respect to the saved one (TOFU, §9).
|
/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9).
|
||||||
/// It is unlocked with an explicit reset of the certificates.
|
/// Si sblocca con il reset esplicito dei certificati.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
||||||
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using PalladiumWallet.Core.Chain;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Net;
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
/// <summary>Known indexing server (bootstrap or discovered from peers).</summary>
|
/// <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 sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null)
|
||||||
{
|
{
|
||||||
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
|
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
|
||||||
@@ -13,9 +13,9 @@ public sealed record KnownServer(string Host, int TcpPort, int SslPort, string?
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server registry (blueprint §9): starts from the profile bootstrap list (§3),
|
/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3),
|
||||||
/// is enriched with peers announced via server.peers.subscribe, and persists
|
/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste
|
||||||
/// discovered servers to file for subsequent sessions.
|
/// le scoperte su file per le sessioni successive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ServerRegistry
|
public sealed class ServerRegistry
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,7 @@ public sealed class ServerRegistry
|
|||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
// Corrupted file: fall back to bootstrap servers only.
|
// File corrotto: si riparte dai soli bootstrap.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,15 +52,15 @@ public sealed class ServerRegistry
|
|||||||
get { lock (_lock) return [.. _servers]; }
|
get { lock (_lock) return [.. _servers]; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>First known server: default when the user does not specify one.</summary>
|
/// <summary>Primo server noto: default quando l'utente non ne indica uno.</summary>
|
||||||
public KnownServer? Default
|
public KnownServer? Default
|
||||||
{
|
{
|
||||||
get { lock (_lock) return _servers.FirstOrDefault(); }
|
get { lock (_lock) return _servers.FirstOrDefault(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Queries the connected server for its announced peers and adds new ones to the
|
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
|
||||||
/// registry (missing ports fall back to the profile defaults). Returns the count of new entries.
|
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
|
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,7 @@ public sealed class ServerRegistry
|
|||||||
private void Save()
|
private void Save()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
|
||||||
// Only discovered servers are saved; bootstrap servers come from the profile.
|
// Si salvano solo gli scoperti: i bootstrap vengono dal profilo.
|
||||||
var discovered = _servers
|
var discovered = _servers
|
||||||
.Where(s => !_profile.BootstrapServers.Any(b =>
|
.Where(s => !_profile.BootstrapServers.Any(b =>
|
||||||
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
|
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Block header parsed from the canonical 80 bytes (blueprint §7.2):
|
/// Header di blocco parsato dagli 80 byte canonici (blueprint §7.2):
|
||||||
/// version, prev_hash, merkle_root, timestamp, bits, nonce.
|
/// versione, prev_hash, merkle_root, timestamp, bits, nonce.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record BlockHeaderInfo(
|
public sealed record BlockHeaderInfo(
|
||||||
int Version, uint256 PrevHash, uint256 MerkleRoot, uint Timestamp, uint Bits, uint Nonce, uint256 Hash)
|
int Version, uint256 PrevHash, uint256 MerkleRoot, uint Timestamp, uint Bits, uint Nonce, uint256 Hash)
|
||||||
@@ -18,7 +18,7 @@ public sealed record BlockHeaderInfo(
|
|||||||
public static BlockHeaderInfo Parse(byte[] raw)
|
public static BlockHeaderInfo Parse(byte[] raw)
|
||||||
{
|
{
|
||||||
if (raw.Length != Size)
|
if (raw.Length != Size)
|
||||||
throw new ArgumentException($"Header is {raw.Length} bytes (expected {Size}).", nameof(raw));
|
throw new ArgumentException($"Header di {raw.Length} byte (attesi {Size}).", nameof(raw));
|
||||||
|
|
||||||
return new BlockHeaderInfo(
|
return new BlockHeaderInfo(
|
||||||
Version: BitConverter.ToInt32(raw, 0),
|
Version: BitConverter.ToInt32(raw, 0),
|
||||||
@@ -31,10 +31,11 @@ public sealed record BlockHeaderInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SPV header validation (§7.2): linkage to the previous header and,
|
/// Validazione SPV dell'header (§7.2): collegamento al precedente e,
|
||||||
/// if the profile does not mandate skip (LWMA chain), PoW verification
|
/// se il profilo non impone lo skip (catena LWMA), verifica PoW
|
||||||
/// hash <= target from bits. Full bits/retargeting consistency requires
|
/// hash <= target dai bits. Il controllo di coerenza dei bits col
|
||||||
/// history — for this chain it is replaced by checkpoints (§7.3).
|
/// retargeting completo richiede la storia: per la catena di riferimento
|
||||||
|
/// è sostituito dai checkpoint (§7.3).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile)
|
public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile)
|
||||||
{
|
{
|
||||||
@@ -46,7 +47,7 @@ public sealed record BlockHeaderInfo(
|
|||||||
return Hash <= target;
|
return Hash <= target;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Comparison against a hardcoded checkpoint (§7.3).</summary>
|
/// <summary>Confronto con un checkpoint hardcoded (§7.3).</summary>
|
||||||
public bool MatchesCheckpoint(Checkpoint checkpoint) =>
|
public bool MatchesCheckpoint(Checkpoint checkpoint) =>
|
||||||
Hash == uint256.Parse(checkpoint.BlockHash);
|
Hash == uint256.Parse(checkpoint.BlockHash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using NBitcoin;
|
|||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Script hash for the indexing protocol (blueprint §0/§10): SHA-256 of the
|
/// Scripthash del protocollo di indicizzazione (blueprint §0/§10): SHA-256
|
||||||
/// scriptPubKey with bytes reversed, encoded as lowercase hex.
|
/// dello scriptPubKey con i byte in ordine inverso, esadecimale.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Scripthash
|
public static class Scripthash
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Threading;
|
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
@@ -8,14 +6,14 @@ using PalladiumWallet.Core.Storage;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
/// <summary>Address derived and tracked during synchronisation.</summary>
|
/// <summary>Indirizzo derivato e tracciato durante la sincronizzazione.</summary>
|
||||||
public sealed record TrackedAddress(
|
public sealed record TrackedAddress(
|
||||||
BitcoinAddress Address, string ScriptHash, bool IsChange, int Index)
|
BitcoinAddress Address, string ScriptHash, bool IsChange, int Index)
|
||||||
{
|
{
|
||||||
public Script ScriptPubKey => Address.ScriptPubKey;
|
public Script ScriptPubKey => Address.ScriptPubKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Result of a synchronisation pass.</summary>
|
/// <summary>Esito di una passata di sincronizzazione.</summary>
|
||||||
public sealed class SyncResult
|
public sealed class SyncResult
|
||||||
{
|
{
|
||||||
public required int TipHeight { get; init; }
|
public required int TipHeight { get; init; }
|
||||||
@@ -31,175 +29,82 @@ public sealed class SyncResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wallet synchronisation (blueprint §7.4).
|
/// Sincronizzazione del wallet (blueprint §7.4): per ogni indirizzo calcola lo
|
||||||
|
/// scripthash e si sottoscrive; scarica storico e transazioni; verifica ogni tx
|
||||||
|
/// confermata con la prova di Merkle contro l'header del suo blocco (le risposte
|
||||||
|
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
||||||
|
/// estende la scansione fino al gap limit (§5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20)
|
||||||
{
|
{
|
||||||
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
||||||
public event Action<string>? Progress;
|
public event Action<string>? Progress;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
// 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 = [];
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
|
||||||
|
|
||||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
|
||||||
// discovery — already-used addresses are fetched in a single burst instead of
|
|
||||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
|
||||||
private int _knownReceiveIndex;
|
|
||||||
private int _knownChangeIndex;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pre-populates internal caches from data saved on disk.
|
|
||||||
/// Call before SyncOnceAsync to avoid re-downloading already known transactions.
|
|
||||||
/// </summary>
|
|
||||||
public void PreloadCaches(
|
|
||||||
Dictionary<string, string> rawTxHex,
|
|
||||||
Dictionary<string, int> verifiedAt,
|
|
||||||
Dictionary<int, string>? blockHeaders,
|
|
||||||
int knownReceiveIndex,
|
|
||||||
int knownChangeIndex,
|
|
||||||
Network network)
|
|
||||||
{
|
|
||||||
foreach (var (txid, hex) in rawTxHex)
|
|
||||||
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
|
||||||
foreach (var (txid, height) in verifiedAt)
|
|
||||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
|
||||||
_verifiedAtHeight[txid] = height;
|
|
||||||
if (blockHeaders is not null)
|
|
||||||
foreach (var (height, hex) in blockHeaders)
|
|
||||||
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
|
||||||
_knownReceiveIndex = knownReceiveIndex;
|
|
||||||
_knownChangeIndex = knownChangeIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports the current caches in a serialisable form for disk storage.
|
|
||||||
/// Only confirmed transactions (height > 0) are included: unconfirmed ones
|
|
||||||
/// may change (RBF) and must always be re-downloaded.
|
|
||||||
/// </summary>
|
|
||||||
public (Dictionary<string, string> RawTxHex,
|
|
||||||
Dictionary<string, int> VerifiedAt,
|
|
||||||
Dictionary<int, string> BlockHeaders)
|
|
||||||
ExportCaches(Network network)
|
|
||||||
{
|
|
||||||
var rawHex = _verifiedAtHeight.Keys
|
|
||||||
.Where(_txCache.ContainsKey)
|
|
||||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
|
||||||
|
|
||||||
// Only already-completed headers: in-progress Task<string> instances
|
|
||||||
// are not persisted (they will be re-fetched on the next sync if needed).
|
|
||||||
var headers = new Dictionary<int, string>();
|
|
||||||
foreach (var (height, task) in _headerFetches)
|
|
||||||
if (task.IsCompletedSuccessfully)
|
|
||||||
headers[height] = task.Result;
|
|
||||||
|
|
||||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var tip = await client.SubscribeHeadersAsync(ct);
|
var tip = await client.SubscribeHeadersAsync(ct);
|
||||||
Progress?.Invoke($"chain tip: {tip.Height}");
|
Progress?.Invoke($"tip della catena: {tip.Height}");
|
||||||
|
|
||||||
// 1-2. Address scanning.
|
// 1-2. Scansione indirizzi con gap limit, per catena receiving e change.
|
||||||
var tracked = new List<TrackedAddress>();
|
var tracked = new List<TrackedAddress>();
|
||||||
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||||
int nextReceive, nextChange;
|
var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
||||||
|
var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
||||||
|
|
||||||
if (account.FixedAddresses is { } fixedAddresses)
|
// 3. Storico unico (txid → altezza massima riportata).
|
||||||
{
|
|
||||||
foreach (var (addr, isChange, idx) in fixedAddresses)
|
|
||||||
tracked.Add(new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, idx));
|
|
||||||
nextReceive = tracked.Count(t => !t.IsChange);
|
|
||||||
nextChange = 0;
|
|
||||||
|
|
||||||
await Task.WhenAll(tracked.Select(t => RetryOnBusyAsync(async () =>
|
|
||||||
{
|
|
||||||
var h = await client.GetHistoryAsync(t.ScriptHash, ct);
|
|
||||||
if (h.Count > 0) historyByAddress[t.ScriptHash] = h;
|
|
||||||
}, ct)).Concat(tracked.Select(t =>
|
|
||||||
RetryOnBusyAsync(() => client.SubscribeScripthashAsync(t.ScriptHash, ct), ct))));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Receive and change chains in parallel (independent by definition).
|
|
||||||
// ScanChainAsync uses _knownReceiveIndex/_knownChangeIndex for incremental
|
|
||||||
// discovery: already-used addresses are fetched in a single burst.
|
|
||||||
var receiveTask = ScanChainAsync(isChange: false, _knownReceiveIndex, ct);
|
|
||||||
var changeTask = ScanChainAsync(isChange: true, _knownChangeIndex, ct);
|
|
||||||
var rxScan = await receiveTask;
|
|
||||||
var chScan = await changeTask;
|
|
||||||
|
|
||||||
tracked.AddRange(rxScan.Tracked);
|
|
||||||
tracked.AddRange(chScan.Tracked);
|
|
||||||
foreach (var (k, v) in rxScan.History) historyByAddress[k] = v;
|
|
||||||
foreach (var (k, v) in chScan.History) historyByAddress[k] = v;
|
|
||||||
nextReceive = rxScan.NextIndex;
|
|
||||||
nextChange = chScan.NextIndex;
|
|
||||||
|
|
||||||
var gapAddresses = tracked.Where(t =>
|
|
||||||
(!t.IsChange && t.Index >= nextReceive && t.Index < nextReceive + gapLimit) ||
|
|
||||||
( t.IsChange && t.Index >= nextChange && t.Index < nextChange + gapLimit)).ToList();
|
|
||||||
if (gapAddresses.Count > 0)
|
|
||||||
await Task.WhenAll(gapAddresses.Select(t =>
|
|
||||||
RetryOnBusyAsync(() => client.SubscribeScripthashAsync(t.ScriptHash, ct), ct)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Merged history (txid → highest reported height).
|
|
||||||
var txHeights = new Dictionary<string, int>();
|
var txHeights = new Dictionary<string, int>();
|
||||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||||
txHeights[item.TxHash] = item.Height;
|
txHeights[item.TxHash] = item.Height;
|
||||||
|
|
||||||
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
// 4. Scarica in parallelo le sole transazioni nuove.
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{
|
||||||
|
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), in parallelo e
|
||||||
|
// solo per le tx non ancora verificate a quell'altezza.
|
||||||
var toVerify = txHeights
|
var toVerify = txHeights
|
||||||
.Where(kv => kv.Value > 0
|
.Where(kv => kv.Value > 0
|
||||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
if (toVerify.Count > 0)
|
||||||
if (missing.Count > 0 || toVerify.Count > 0)
|
|
||||||
{
|
{
|
||||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
||||||
var dlDone = 0;
|
await Task.WhenAll(toVerify.Select(async kv =>
|
||||||
var merkDone = 0;
|
|
||||||
|
|
||||||
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
|
||||||
{
|
|
||||||
var raw = await client.GetTransactionAsync(txid, ct);
|
|
||||||
_txCache[txid] = Transaction.Parse(raw, network);
|
|
||||||
var n = Interlocked.Increment(ref dlDone);
|
|
||||||
if (n % 50 == 0 || n == missing.Count)
|
|
||||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
|
|
||||||
}, ct));
|
|
||||||
|
|
||||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var (txid, height) = kv;
|
var (txid, height) = kv;
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
var headerTask = _headerFetches.GetOrAdd(height,
|
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
||||||
h => client.GetBlockHeaderAsync(h, ct));
|
var proof = await proofTask;
|
||||||
var proof = await proofTask;
|
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
if (!MerkleProof.Verify(
|
if (!MerkleProof.Verify(
|
||||||
uint256.Parse(txid), proof.Pos,
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||||
var n = Interlocked.Increment(ref merkDone);
|
}));
|
||||||
if (n % 50 == 0 || n == toVerify.Count)
|
|
||||||
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
|
||||||
}, ct));
|
|
||||||
|
|
||||||
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
|
||||||
foreach (var (txid, height) in toVerify)
|
foreach (var (txid, height) in toVerify)
|
||||||
_verifiedAtHeight[txid] = height;
|
_verifiedAtHeight[txid] = height;
|
||||||
}
|
}
|
||||||
|
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||||
|
|
||||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
|
||||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
// script; spesi = outpoint consumati da una qualunque tx del wallet.
|
||||||
|
|
||||||
// 6. Local UTXO reconstruction.
|
|
||||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||||
var spent = transactions.Values
|
var spent = transactions.Values
|
||||||
.SelectMany(tx => tx.Inputs)
|
.SelectMany(tx => tx.Inputs)
|
||||||
@@ -218,18 +123,18 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
continue;
|
continue;
|
||||||
utxos.Add(new CachedUtxo
|
utxos.Add(new CachedUtxo
|
||||||
{
|
{
|
||||||
Txid = txid,
|
Txid = txid,
|
||||||
Vout = vout,
|
Vout = vout,
|
||||||
ValueSats = output.Value.Satoshi,
|
ValueSats = output.Value.Satoshi,
|
||||||
Address = addr.Address.ToString(),
|
Address = addr.Address.ToString(),
|
||||||
IsChange = addr.IsChange,
|
IsChange = addr.IsChange,
|
||||||
AddressIndex = addr.Index,
|
AddressIndex = addr.Index,
|
||||||
Height = txHeights[txid],
|
Height = txHeights[txid],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Delta per history entry.
|
// 7. Delta per voce di storico (entrate - uscite del wallet).
|
||||||
var history = new List<CachedTx>();
|
var history = new List<CachedTx>();
|
||||||
foreach (var (txid, tx) in transactions)
|
foreach (var (txid, tx) in transactions)
|
||||||
{
|
{
|
||||||
@@ -242,19 +147,21 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
.Sum(i => transactions[i.PrevOut.Hash.ToString()].Outputs[i.PrevOut.N].Value.Satoshi);
|
.Sum(i => transactions[i.PrevOut.Hash.ToString()].Outputs[i.PrevOut.N].Value.Satoshi);
|
||||||
history.Add(new CachedTx
|
history.Add(new CachedTx
|
||||||
{
|
{
|
||||||
Txid = txid,
|
Txid = txid,
|
||||||
Height = txHeights[txid],
|
Height = txHeights[txid],
|
||||||
DeltaSats = received - sentSats,
|
DeltaSats = received - sentSats,
|
||||||
Verified = verified[txid],
|
Verified = verified[txid],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
history.Sort((a, b) =>
|
history.Sort((a, b) =>
|
||||||
{
|
{
|
||||||
|
// Non confermate (height<=0) in cima, poi per altezza decrescente.
|
||||||
var ha = a.Height <= 0 ? int.MaxValue : a.Height;
|
var ha = a.Height <= 0 ? int.MaxValue : a.Height;
|
||||||
var hb = b.Height <= 0 ? int.MaxValue : b.Height;
|
var hb = b.Height <= 0 ? int.MaxValue : b.Height;
|
||||||
return hb.CompareTo(ha);
|
return hb.CompareTo(ha);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Saldo e numero di transazioni per singolo indirizzo (vista indirizzi).
|
||||||
var balanceByAddress = utxos
|
var balanceByAddress = utxos
|
||||||
.GroupBy(u => u.Address)
|
.GroupBy(u => u.Address)
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
|
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
|
||||||
@@ -262,87 +169,64 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
.OrderBy(t => t.IsChange).ThenBy(t => t.Index)
|
.OrderBy(t => t.IsChange).ThenBy(t => t.Index)
|
||||||
.Select(t => new CachedAddress
|
.Select(t => new CachedAddress
|
||||||
{
|
{
|
||||||
Address = t.Address.ToString(),
|
Address = t.Address.ToString(),
|
||||||
IsChange = t.IsChange,
|
IsChange = t.IsChange,
|
||||||
Index = t.Index,
|
Index = t.Index,
|
||||||
BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()),
|
BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()),
|
||||||
TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0,
|
TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0,
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new SyncResult
|
return new SyncResult
|
||||||
{
|
{
|
||||||
TipHeight = tip.Height,
|
TipHeight = tip.Height,
|
||||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||||
NextReceiveIndex = nextReceive,
|
NextReceiveIndex = nextReceive,
|
||||||
NextChangeIndex = nextChange,
|
NextChangeIndex = nextChange,
|
||||||
History = history,
|
History = history,
|
||||||
Utxos = utxos,
|
Utxos = utxos,
|
||||||
Addresses = tracked,
|
Addresses = tracked,
|
||||||
AddressRows = addressRows,
|
AddressRows = addressRows,
|
||||||
Transactions = transactions,
|
Transactions = transactions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans one chain (receiving or change).
|
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||||
///
|
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
||||||
/// Phase 1 — known addresses (0..fromIndex-1): all GetHistoryAsync calls are
|
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
||||||
/// fired in a single parallel burst, with no sequential batching. A wallet
|
/// Ritorna il primo indice non usato.
|
||||||
/// with 100 used addresses costs 1 RTT instead of 5 sequential gap-limit rounds.
|
|
||||||
///
|
|
||||||
/// Phase 2 — discovery from fromIndex onwards: gap-limit batching as before,
|
|
||||||
/// required to know when to stop.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(int NextIndex,
|
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
||||||
List<TrackedAddress> Tracked,
|
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
||||||
Dictionary<string, IReadOnlyList<HistoryItem>> History)>
|
|
||||||
ScanChainAsync(bool isChange, int fromIndex, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var tracked = new List<TrackedAddress>();
|
|
||||||
var history = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
|
||||||
|
|
||||||
// Phase 1: single burst for all already-known addresses.
|
|
||||||
if (fromIndex > 0)
|
|
||||||
{
|
|
||||||
var known = Enumerable.Range(0, fromIndex).Select(i =>
|
|
||||||
{
|
|
||||||
var addr = account.GetAddress(isChange, i);
|
|
||||||
return new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, i);
|
|
||||||
}).ToList();
|
|
||||||
tracked.AddRange(known);
|
|
||||||
|
|
||||||
var knownHistories = await Task.WhenAll(
|
|
||||||
known.Select(t => RetryOnBusyAsync(
|
|
||||||
() => client.GetHistoryAsync(t.ScriptHash, ct), ct)));
|
|
||||||
for (var i = 0; i < known.Count; i++)
|
|
||||||
if (knownHistories[i].Count > 0)
|
|
||||||
history[known[i].ScriptHash] = knownHistories[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: gap-limit discovery from fromIndex onwards.
|
|
||||||
var consecutiveEmpty = 0;
|
var consecutiveEmpty = 0;
|
||||||
var index = fromIndex;
|
var index = 0;
|
||||||
var firstUnused = fromIndex;
|
var firstUnused = 0;
|
||||||
|
|
||||||
while (consecutiveEmpty < gapLimit)
|
while (consecutiveEmpty < gapLimit)
|
||||||
{
|
{
|
||||||
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
||||||
{
|
{
|
||||||
var addr = account.GetAddress(isChange, i);
|
var address = account.GetAddress(isChange, i);
|
||||||
return new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, i);
|
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
index += batch.Count;
|
index += batch.Count;
|
||||||
tracked.AddRange(batch);
|
tracked.AddRange(batch);
|
||||||
|
|
||||||
|
// La subscribe registra anche la notifica push per i cambi futuri.
|
||||||
|
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(
|
var histories = await Task.WhenAll(
|
||||||
batch.Select(t => RetryOnBusyAsync(
|
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||||
() => client.GetHistoryAsync(t.ScriptHash, ct), 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++)
|
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||||
{
|
{
|
||||||
if (histories[i].Count == 0)
|
if (statuses[i] is null)
|
||||||
{
|
{
|
||||||
consecutiveEmpty++;
|
consecutiveEmpty++;
|
||||||
}
|
}
|
||||||
@@ -350,50 +234,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
{
|
{
|
||||||
consecutiveEmpty = 0;
|
consecutiveEmpty = 0;
|
||||||
firstUnused = batch[i].Index + 1;
|
firstUnused = batch[i].Index + 1;
|
||||||
history[batch[i].ScriptHash] = histories[i];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return firstUnused;
|
||||||
return (firstUnused, tracked, history);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var delay = 200;
|
|
||||||
for (var attempt = 0; ; attempt++)
|
|
||||||
{
|
|
||||||
try { await op(); return; }
|
|
||||||
catch (ElectrumServerException ex)
|
|
||||||
when (IsBusy(ex) && attempt < 7)
|
|
||||||
{
|
|
||||||
await Task.Delay(delay, ct);
|
|
||||||
delay = Math.Min(delay * 2, 5_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var delay = 200;
|
|
||||||
for (var attempt = 0; ; attempt++)
|
|
||||||
{
|
|
||||||
try { return await op(); }
|
|
||||||
catch (ElectrumServerException ex)
|
|
||||||
when (IsBusy(ex) && attempt < 7)
|
|
||||||
{
|
|
||||||
await Task.Delay(delay, ct);
|
|
||||||
delay = Math.Min(delay * 2, 5_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsBusy(ElectrumServerException ex) =>
|
|
||||||
ex.Message.Contains("-102") ||
|
|
||||||
ex.Message.Contains("-101") ||
|
|
||||||
ex.Message.Contains("server busy", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
ex.Message.Contains("excessive resource usage", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>SPV verification failed: server data contradicts the proofs (§17).</summary>
|
/// <summary>La verifica SPV è fallita: i dati del server contraddicono le prove (§17).</summary>
|
||||||
public sealed class SpvVerificationException(string message) : Exception(message);
|
public sealed class SpvVerificationException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ using System.Text.Json;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global application configuration (blueprint §8), separate from wallet
|
/// Configurazione globale dell'applicazione (blueprint §8), separata dai file
|
||||||
/// files: language, display unit, etc. Persisted in config.json
|
/// wallet: lingua, unità di visualizzazione, ecc. Persistita in config.json
|
||||||
/// in the data root (applies to all networks).
|
/// nella radice dei dati (vale per tutte le reti).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AppConfig
|
public sealed class AppConfig
|
||||||
{
|
{
|
||||||
/// <summary>UI language code.</summary>
|
/// <summary>Codice lingua UI ("it", "en").</summary>
|
||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "it";
|
||||||
|
|
||||||
/// <summary>Display unit for amounts (see <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||||
public string Unit { get; set; } = "PLM";
|
public string Unit { get; set; } = "PLM";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
@@ -28,7 +28,7 @@ public sealed class AppConfig
|
|||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
// Corrupted config: fall back to defaults without blocking startup.
|
// Config corrotta: si riparte dai default senza bloccare l'avvio.
|
||||||
return new AppConfig();
|
return new AppConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,100 +3,27 @@ using PalladiumWallet.Core.Chain;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-platform data paths (blueprint §8). The data root can be:
|
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
|
||||||
/// 1. <b>portable</b>: "palladium-data" folder next to the executable;
|
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
|
||||||
/// 2. <b>custom</b>: chosen by the user at first launch and stored in
|
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
|
||||||
/// a small "pointer" file at a fixed bootstrap location;
|
/// esiste una cartella "palladium-data".
|
||||||
/// 3. <b>legacy</b>: old location (%APPDATA%/PalladiumWallet) if it already holds data;
|
|
||||||
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) or %ProgramFiles%\PalladiumWallet (Windows).
|
|
||||||
/// Under the root there is a per-network subfolder (config, wallet, header, certificates).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AppPaths
|
public static class AppPaths
|
||||||
{
|
{
|
||||||
public const string PortableDirName = "palladium-data";
|
public const string PortableDirName = "palladium-data";
|
||||||
|
|
||||||
/// <summary>Application folder name, used in the various paths.</summary>
|
|
||||||
public const string AppDirName = "PalladiumWallet";
|
|
||||||
|
|
||||||
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
|
|
||||||
public static string? OverrideDataRoot { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Default data root, following each platform's convention:
|
|
||||||
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
|
|
||||||
/// Linux/macOS → ~/.palladium-wallet (lowercase dotfolder, like ~/.bitcoin).
|
|
||||||
/// Per-user and always writable, without administrator privileges.
|
|
||||||
/// </summary>
|
|
||||||
public static string DefaultDataRoot()
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
return Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
AppDirName);
|
|
||||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
return Path.Combine(home, ".palladium-wallet");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Pointer file to the user-chosen data root. Lives at a
|
|
||||||
/// bootstrap location that is always writable and independent of the data root.</summary>
|
|
||||||
private static string LocationPointerPath() =>
|
|
||||||
Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
AppDirName, "data-location");
|
|
||||||
|
|
||||||
private static string PortableRoot() =>
|
|
||||||
Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
|
||||||
|
|
||||||
private static bool HasData(string root) =>
|
|
||||||
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
|
||||||
|
|
||||||
/// <summary>Effective data root, following the precedence order documented on the class.</summary>
|
|
||||||
public static string DataRoot()
|
public static string DataRoot()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(OverrideDataRoot))
|
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
||||||
return OverrideDataRoot;
|
|
||||||
|
|
||||||
var portable = PortableRoot();
|
|
||||||
if (Directory.Exists(portable))
|
if (Directory.Exists(portable))
|
||||||
return portable;
|
return portable;
|
||||||
|
|
||||||
if (ReadPointer() is { } custom)
|
return OperatingSystem.IsWindows()
|
||||||
return custom;
|
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet")
|
||||||
|
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
|
||||||
return DefaultDataRoot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
|
||||||
/// true if the data location is already determined and need not be asked
|
|
||||||
/// of the user: portable mode, override, pointer already written, or
|
|
||||||
/// data already present at the default location.
|
|
||||||
/// </summary>
|
|
||||||
public static bool IsDataLocationConfigured() =>
|
|
||||||
!string.IsNullOrEmpty(OverrideDataRoot)
|
|
||||||
|| Directory.Exists(PortableRoot())
|
|
||||||
|| ReadPointer() is not null
|
|
||||||
|| HasData(DefaultDataRoot());
|
|
||||||
|
|
||||||
/// <summary>Stores the user-chosen data root and creates it on disk.</summary>
|
|
||||||
public static void ConfigureDataLocation(string root)
|
|
||||||
{
|
|
||||||
root = Path.GetFullPath(root.Trim());
|
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var pointer = LocationPointerPath();
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(pointer)!);
|
|
||||||
File.WriteAllText(pointer, root);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadPointer()
|
|
||||||
{
|
|
||||||
var pointer = LocationPointerPath();
|
|
||||||
if (!File.Exists(pointer))
|
|
||||||
return null;
|
|
||||||
var path = File.ReadAllText(pointer).Trim();
|
|
||||||
return string.IsNullOrEmpty(path) ? null : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Network data folder (config, wallet, header, certificates).</summary>
|
|
||||||
public static string ForNetwork(NetKind net)
|
public static string ForNetwork(NetKind net)
|
||||||
{
|
{
|
||||||
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
|
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
|
||||||
@@ -114,15 +41,6 @@ public static class AppPaths
|
|||||||
public static string DefaultWalletPath(NetKind net) =>
|
public static string DefaultWalletPath(NetKind net) =>
|
||||||
Path.Combine(WalletsDir(net), "default.wallet.json");
|
Path.Combine(WalletsDir(net), "default.wallet.json");
|
||||||
|
|
||||||
/// <summary>All wallet files for the network, ordered by name (multi-wallet §8).</summary>
|
|
||||||
public static IReadOnlyList<string> WalletFiles(NetKind net)
|
|
||||||
{
|
|
||||||
var dir = WalletsDir(net);
|
|
||||||
return Directory.EnumerateFiles(dir, "*.wallet.json")
|
|
||||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string CertificatePinsPath(NetKind net) =>
|
public static string CertificatePinsPath(NetKind net) =>
|
||||||
Path.Combine(ForNetwork(net), "server-certs.json");
|
Path.Combine(ForNetwork(net), "server-certs.json");
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using System.Text.Json;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wallet file encryption (blueprint §8/§17): AES-256-GCM with a key derived
|
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
|
||||||
/// from the password via PBKDF2-HMAC-SHA512. The container is self-describing JSON
|
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
|
||||||
/// (kdf, parameters, salt, nonce) to allow future parameter upgrades.
|
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EncryptedFile
|
public static class EncryptedFile
|
||||||
{
|
{
|
||||||
@@ -54,7 +54,7 @@ public static class EncryptedFile
|
|||||||
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Throws <see cref="WrongPasswordException"/> if the password is wrong or the file is tampered with.</summary>
|
/// <summary>Lancia <see cref="WrongPasswordException"/> se la password è errata o il file è manomesso.</summary>
|
||||||
public static string Decrypt(string fileContent, string password)
|
public static string Decrypt(string fileContent, string password)
|
||||||
{
|
{
|
||||||
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
||||||
@@ -74,7 +74,7 @@ public static class EncryptedFile
|
|||||||
}
|
}
|
||||||
catch (AuthenticationTagMismatchException)
|
catch (AuthenticationTagMismatchException)
|
||||||
{
|
{
|
||||||
// The GCM tag authenticates: a wrong password and tampering are indistinguishable.
|
// Il tag GCM autentica: password errata e manomissione sono indistinguibili.
|
||||||
throw new WrongPasswordException();
|
throw new WrongPasswordException();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -88,7 +88,7 @@ public static class EncryptedFile
|
|||||||
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
|
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wrong password (or tampered wallet file: the GCM tag does not distinguish the two cases).</summary>
|
/// <summary>Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).</summary>
|
||||||
public sealed class WrongPasswordException : Exception
|
public sealed class WrongPasswordException : Exception
|
||||||
{
|
{
|
||||||
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
|
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ using System.Text.Json.Serialization;
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wallet file schema (blueprint §8), versioned to allow automatic
|
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
|
||||||
/// migrations on opening. Encryption (when there is a password) wraps
|
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
|
||||||
/// the entire document via <see cref="EncryptedFile"/>.
|
/// l'intero documento via <see cref="EncryptedFile"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WalletDocument
|
public sealed class WalletDocument
|
||||||
{
|
{
|
||||||
@@ -14,48 +14,36 @@ public sealed class WalletDocument
|
|||||||
|
|
||||||
public int Version { get; set; } = CurrentVersion;
|
public int Version { get; set; } = CurrentVersion;
|
||||||
|
|
||||||
/// <summary>Network name (mainnet/testnet/regtest), as ChainProfile.NetName.</summary>
|
/// <summary>Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.</summary>
|
||||||
public required string Network { get; set; }
|
public required string Network { get; set; }
|
||||||
|
|
||||||
/// <summary>Script type (name of the ScriptKind enum).</summary>
|
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
|
||||||
public required string ScriptKind { get; set; }
|
public required string ScriptKind { get; set; }
|
||||||
|
|
||||||
/// <summary>BIP39 mnemonic in plaintext in the document (the document must be encrypted!); null if watch-only.</summary>
|
/// <summary>Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.</summary>
|
||||||
public string? Mnemonic { get; set; }
|
public string? Mnemonic { get; set; }
|
||||||
|
|
||||||
/// <summary>BIP39 extension word (§4.1); null if absent.</summary>
|
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
||||||
public string? Passphrase { get; set; }
|
public string? Passphrase { get; set; }
|
||||||
|
|
||||||
/// <summary>Account path (e.g. "84'/746'/0'"); null for imported WIF.</summary>
|
/// <summary>Path di account (es. "84'/746'/0'").</summary>
|
||||||
public string? AccountPath { get; set; }
|
public required string AccountPath { get; set; }
|
||||||
|
|
||||||
/// <summary>Account xpub in SLIP-132 for HD wallets; null for imported WIF.</summary>
|
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
|
||||||
public string? AccountXpub { get; set; }
|
public required string AccountXpub { get; set; }
|
||||||
|
|
||||||
/// <summary>Account xprv in SLIP-132; present only for import from xprv without seed.</summary>
|
|
||||||
public string? AccountXprv { get; set; }
|
|
||||||
|
|
||||||
public string? MasterFingerprint { get; set; }
|
public string? MasterFingerprint { get; set; }
|
||||||
|
|
||||||
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
||||||
public List<string>? WifKeys { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
|
||||||
public int GapLimit { get; set; } = 20;
|
public int GapLimit { get; set; } = 20;
|
||||||
|
|
||||||
/// <summary>Labels by address/txid (§12).</summary>
|
/// <summary>Etichette per indirizzo/txid (§12).</summary>
|
||||||
public Dictionary<string, string> Labels { get; set; } = [];
|
public Dictionary<string, string> Labels { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Contact address book (name + blockchain address).</summary>
|
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
||||||
public List<StoredContact> Contacts { get; set; } = [];
|
|
||||||
|
|
||||||
/// <summary>Cache of the last synced state (balance/history viewable offline).</summary>
|
|
||||||
public SyncCache? Cache { get; set; }
|
public SyncCache? Cache { get; set; }
|
||||||
|
|
||||||
public bool IsWatchOnly =>
|
public bool IsWatchOnly => Mnemonic is null;
|
||||||
Mnemonic is null &&
|
|
||||||
AccountXprv is null &&
|
|
||||||
(WifKeys is null || WifKeys.Count == 0);
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -69,7 +57,6 @@ public sealed class WalletDocument
|
|||||||
{
|
{
|
||||||
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
|
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
|
||||||
?? throw new InvalidDataException("File wallet non valido.");
|
?? throw new InvalidDataException("File wallet non valido.");
|
||||||
// string literal above intentionally left untranslated
|
|
||||||
return Migrate(doc);
|
return Migrate(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,13 +69,6 @@ public sealed class WalletDocument
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Contatto in rubrica: nome leggibile + indirizzo blockchain.</summary>
|
|
||||||
public sealed class StoredContact
|
|
||||||
{
|
|
||||||
public required string Name { get; set; }
|
|
||||||
public required string Address { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
|
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
|
||||||
public sealed class SyncCache
|
public sealed class SyncCache
|
||||||
{
|
{
|
||||||
@@ -100,30 +80,9 @@ public sealed class SyncCache
|
|||||||
public List<CachedTx> History { get; set; } = [];
|
public List<CachedTx> History { get; set; } = [];
|
||||||
public List<CachedUtxo> Utxos { get; set; } = [];
|
public List<CachedUtxo> Utxos { get; set; } = [];
|
||||||
public List<CachedAddress> Addresses { get; set; } = [];
|
public List<CachedAddress> Addresses { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raw transaction cache (txid → hex). Avoids re-downloading confirmed
|
|
||||||
/// transactions on every launch: confirmed txs are immutable by definition.
|
|
||||||
/// May be partially populated after an interrupted sync (e.g. -101 error);
|
|
||||||
/// the synchronizer resumes from where it left off.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, string>? RawTxHex { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Already-verified Merkle proofs (txid → block height). Avoids re-verifying
|
|
||||||
/// the same proofs on every launch: confirmations are immutable.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, int>? VerifiedAt { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raw headers by height (height → hex). Immutable: never re-fetched once saved.
|
|
||||||
/// Eliminates GetBlockHeader calls for Merkle proofs already verified in
|
|
||||||
/// subsequent syncs.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<int, string>? BlockHeaders { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
||||||
public sealed class CachedAddress
|
public sealed class CachedAddress
|
||||||
{
|
{
|
||||||
public required string Address { get; set; }
|
public required string Address { get; set; }
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
namespace PalladiumWallet.Core.Storage;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exclusive lock on a wallet file held for the lifetime of the session.
|
|
||||||
/// The real lock is the open FileStream with FileShare.None — the .lock file
|
|
||||||
/// is just its vessel. The OS releases it automatically on process exit or crash.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class WalletLock : IDisposable
|
|
||||||
{
|
|
||||||
private readonly string _lockPath;
|
|
||||||
private FileStream? _stream;
|
|
||||||
|
|
||||||
private WalletLock(string lockPath, FileStream stream)
|
|
||||||
{
|
|
||||||
_lockPath = lockPath;
|
|
||||||
_stream = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tries to acquire an exclusive lock for <paramref name="walletPath"/>.
|
|
||||||
/// Returns null if another process already holds the lock (IOException).
|
|
||||||
/// Lets UnauthorizedAccessException propagate so callers can show a distinct message.
|
|
||||||
/// </summary>
|
|
||||||
public static WalletLock? TryAcquire(string walletPath)
|
|
||||||
{
|
|
||||||
var lockPath = walletPath + ".lock";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var stream = new FileStream(
|
|
||||||
lockPath,
|
|
||||||
FileMode.OpenOrCreate,
|
|
||||||
FileAccess.ReadWrite,
|
|
||||||
FileShare.None);
|
|
||||||
return new WalletLock(lockPath, stream);
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_stream?.Dispose();
|
|
||||||
_stream = null;
|
|
||||||
try { File.Delete(_lockPath); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,8 +25,6 @@ public static class WalletStore
|
|||||||
return WalletDocument.FromJson(content);
|
return WalletDocument.FromJson(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="password">Null saves in plaintext. Only omit when the user has
|
|
||||||
/// explicitly opted out of encryption (UI must show a clear warning).</param>
|
|
||||||
public static void Save(WalletDocument doc, string path, string? password = null)
|
public static void Save(WalletDocument doc, string path, string? password = null)
|
||||||
{
|
{
|
||||||
var content = doc.ToJson();
|
var content = doc.ToJson();
|
||||||
|
|||||||
@@ -3,31 +3,30 @@ using System.Globalization;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Satoshi ↔ coin unit conversion (8 decimal places) for display and input.
|
/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input.
|
||||||
/// All internal computation uses satoshis; the formatted string is presentation only.
|
/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CoinAmount
|
public static class CoinAmount
|
||||||
{
|
{
|
||||||
public const long SatsPerCoin = 100_000_000;
|
public const long SatsPerCoin = 100_000_000;
|
||||||
|
|
||||||
/// <summary>Selectable display units (config §8).</summary>
|
/// <summary>Unità di visualizzazione selezionabili (config §8).</summary>
|
||||||
public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
|
public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
|
||||||
|
|
||||||
/// <summary>(satoshis per unit, displayed decimals) for each unit.</summary>
|
/// <summary>(satoshi per unità, decimali mostrati) di ciascuna unità.</summary>
|
||||||
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
||||||
{
|
{
|
||||||
"PLM" => (SatsPerCoin, 8),
|
|
||||||
"mPLM" => (100_000, 5),
|
"mPLM" => (100_000, 5),
|
||||||
"µPLM" => (100, 2),
|
"µPLM" => (100, 2),
|
||||||
"sat" => (1, 0),
|
"sat" => (1, 0),
|
||||||
_ => throw new ArgumentException($"Unknown coin unit: {unit}", nameof(unit)),
|
_ => (SatsPerCoin, 8), // PLM
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string Format(long sats, string unit = "") =>
|
public static string Format(long sats, string unit = "") =>
|
||||||
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
||||||
+ (unit.Length > 0 ? " " + unit : "");
|
+ (unit.Length > 0 ? " " + unit : "");
|
||||||
|
|
||||||
/// <summary>Formats in the chosen unit (e.g. 150000 sat → "1.50000 mPLM").</summary>
|
/// <summary>Formatta nell'unità scelta (es. 150000 sat → "1.50000 mPLM").</summary>
|
||||||
public static string FormatIn(long sats, string unit, bool withLabel = true)
|
public static string FormatIn(long sats, string unit, bool withLabel = true)
|
||||||
{
|
{
|
||||||
var (factor, decimals) = Of(unit);
|
var (factor, decimals) = Of(unit);
|
||||||
@@ -36,7 +35,7 @@ public static class CoinAmount
|
|||||||
return withLabel ? $"{value} {unit}" : value;
|
return withLabel ? $"{value} {unit}" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parses an amount expressed in the chosen unit into satoshis.</summary>
|
/// <summary>Parsa un importo espresso nell'unità scelta in satoshi.</summary>
|
||||||
public static bool TryParseIn(string text, string unit, out long sats)
|
public static bool TryParseIn(string text, string unit, out long sats)
|
||||||
{
|
{
|
||||||
sats = 0;
|
sats = 0;
|
||||||
@@ -47,10 +46,7 @@ public static class CoinAmount
|
|||||||
return false;
|
return false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var satsDecimal = value * factor;
|
sats = (long)(value * factor);
|
||||||
if (satsDecimal % 1 != 0)
|
|
||||||
return false;
|
|
||||||
sats = (long)satsDecimal;
|
|
||||||
}
|
}
|
||||||
catch (OverflowException)
|
catch (OverflowException)
|
||||||
{
|
{
|
||||||
@@ -59,7 +55,7 @@ public static class CoinAmount
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parses a coin amount (decimal point or comma) into satoshis.</summary>
|
/// <summary>Parsa un importo in coin (punto o virgola decimale) in satoshi.</summary>
|
||||||
public static bool TryParseCoins(string text, out long sats)
|
public static bool TryParseCoins(string text, out long sats)
|
||||||
{
|
{
|
||||||
sats = 0;
|
sats = 0;
|
||||||
@@ -69,10 +65,7 @@ public static class CoinAmount
|
|||||||
return false;
|
return false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var satsDecimal = coins * SatsPerCoin;
|
sats = (long)(coins * SatsPerCoin);
|
||||||
if (satsDecimal % 1 != 0)
|
|
||||||
return false;
|
|
||||||
sats = (long)satsDecimal;
|
|
||||||
}
|
}
|
||||||
catch (OverflowException)
|
catch (OverflowException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using PalladiumWallet.Core.Storage;
|
|||||||
|
|
||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>Result of a transaction build operation.</summary>
|
/// <summary>Esito della costruzione di una transazione.</summary>
|
||||||
public sealed class BuiltTransaction
|
public sealed class BuiltTransaction
|
||||||
{
|
{
|
||||||
public required Transaction Transaction { get; init; }
|
public required Transaction Transaction { get; init; }
|
||||||
@@ -14,7 +14,7 @@ public sealed class BuiltTransaction
|
|||||||
public required FeeRate FeeRate { get; init; }
|
public required FeeRate FeeRate { get; init; }
|
||||||
public required bool Signed { get; init; }
|
public required bool Signed { get; init; }
|
||||||
|
|
||||||
/// <summary>PSBT for watch-only/air-gapped/multisig flows (§6.5).</summary>
|
/// <summary>PSBT per i flussi watch-only/air-gapped/multisig (§6.5).</summary>
|
||||||
public required PSBT Psbt { get; init; }
|
public required PSBT Psbt { get; init; }
|
||||||
|
|
||||||
public string ToHex() => Transaction.ToHex();
|
public string ToHex() => Transaction.ToHex();
|
||||||
@@ -22,25 +22,25 @@ public sealed class BuiltTransaction
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Transaction construction and signing (blueprint §6) on top of NBitcoin primitives:
|
/// Costruzione e firma delle transazioni (blueprint §6) sopra le primitive
|
||||||
/// coin selection (manual or automatic), fixed fee rate, send-all with fee subtracted,
|
/// NBitcoin: selezione monete (manuale o automatica), fee a rate fisso,
|
||||||
/// change on the internal chain, RBF on by default.
|
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
|
||||||
/// With a watch-only account produces an unsigned PSBT (§6.5).
|
/// Con un account watch-only produce la PSBT non firmata (§6.5).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransactionFactory(IWalletAccount account)
|
public sealed class TransactionFactory(HdAccount account)
|
||||||
{
|
{
|
||||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds (and signs if possible) a transaction.
|
/// Costruisce (e se possibile firma) una transazione.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="utxos">Selected UTXOs (coin control §6.2) or all spendable ones.</param>
|
/// <param name="utxos">UTXO selezionati (coin control §6.2) o tutti quelli spendibili.</param>
|
||||||
/// <param name="transactions">Source transactions for the UTXOs (txid → tx), from sync.</param>
|
/// <param name="transactions">Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione.</param>
|
||||||
/// <param name="destination">Recipient address.</param>
|
/// <param name="destination">Indirizzo destinatario.</param>
|
||||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
/// <param name="amountSats">Importo; ignorato se <paramref name="sendAll"/>.</param>
|
||||||
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
/// <param name="feeRateSatPerVByte">Fee rate fisso in sat/vByte (§6.4).</param>
|
||||||
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
/// <param name="changeIndex">Indice del prossimo indirizzo di change (catena interna).</param>
|
||||||
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
/// <param name="sendAll">Invia tutto: fee sottratta dall'importo (§6.1).</param>
|
||||||
public BuiltTransaction Build(
|
public BuiltTransaction Build(
|
||||||
IReadOnlyList<CachedUtxo> utxos,
|
IReadOnlyList<CachedUtxo> utxos,
|
||||||
IReadOnlyDictionary<string, Transaction> transactions,
|
IReadOnlyDictionary<string, Transaction> transactions,
|
||||||
@@ -50,15 +50,15 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
int changeIndex,
|
int changeIndex,
|
||||||
bool sendAll = false)
|
bool sendAll = false)
|
||||||
{
|
{
|
||||||
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
// Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
|
||||||
// balance but are not spendable until confirmed.
|
// saldo "in attesa" ma non sono spendibili finché non confermano.
|
||||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
||||||
if (spendable.Count == 0)
|
if (spendable.Count == 0)
|
||||||
{
|
{
|
||||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
||||||
throw new WalletSpendException(pending > 0
|
throw new WalletSpendException(pending > 0
|
||||||
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
|
||||||
: "No spendable UTXOs selected.");
|
: "Nessun UTXO spendibile selezionato.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var coins = spendable.Select(u => new Coin(
|
var coins = spendable.Select(u => new Coin(
|
||||||
@@ -68,7 +68,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||||
var builder = Network.CreateTransactionBuilder();
|
var builder = Network.CreateTransactionBuilder();
|
||||||
builder.SetVersion(2);
|
builder.SetVersion(2);
|
||||||
// RBF sequence to allow fee bumping (§6.6).
|
// Sequence RBF per consentire il bump della fee (§6.6).
|
||||||
builder.OptInRBF = true;
|
builder.OptInRBF = true;
|
||||||
builder.AddCoins(coins);
|
builder.AddCoins(coins);
|
||||||
builder.SetChange(account.GetChangeAddress(changeIndex));
|
builder.SetChange(account.GetChangeAddress(changeIndex));
|
||||||
@@ -82,8 +82,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
builder.AddKeys(spendable
|
builder.AddKeys(spendable
|
||||||
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
.Select(u => account.GetExtPrivateKey(u.IsChange, u.AddressIndex))
|
||||||
.OfType<Key>()
|
|
||||||
.ToArray());
|
.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,14 +93,14 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
}
|
}
|
||||||
catch (NotEnoughFundsException ex)
|
catch (NotEnoughFundsException ex)
|
||||||
{
|
{
|
||||||
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
|
throw new WalletSpendException($"Fondi insufficienti: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
||||||
throw new WalletSpendException(
|
throw new WalletSpendException(
|
||||||
"Invalid transaction: " + string.Join("; ", errors.Select(e => e.ToString())));
|
"Transazione non valida: " + string.Join("; ", errors.Select(e => e.ToString())));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BuiltTransaction
|
return new BuiltTransaction
|
||||||
@@ -123,5 +122,5 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
|
/// <summary>Errore di costruzione/firma della spesa (fondi, policy, parametri).</summary>
|
||||||
public sealed class WalletSpendException(string message) : Exception(message);
|
public sealed class WalletSpendException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Net;
|
|
||||||
using PalladiumWallet.Core.Spv;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
|
||||||
public sealed record TxInputInfo(
|
|
||||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
|
|
||||||
|
|
||||||
/// <summary>An output of a transaction.</summary>
|
|
||||||
public sealed record TxOutputInfo(
|
|
||||||
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Complete transaction data assembled by querying the server: the raw transaction
|
|
||||||
/// plus the outputs spent by each input (to derive amounts, addresses, and fees)
|
|
||||||
/// and the block header (for the timestamp). Everything the ElectrumX-like protocol
|
|
||||||
/// (§10) can tell us about a transaction.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TransactionDetails
|
|
||||||
{
|
|
||||||
public required string Txid { get; init; }
|
|
||||||
/// <summary>Block height; ≤0 = still in mempool.</summary>
|
|
||||||
public required int Height { get; init; }
|
|
||||||
public required int Confirmations { get; init; }
|
|
||||||
/// <summary>Net effect on the wallet balance (delta computed during sync).</summary>
|
|
||||||
public required long NetSats { get; init; }
|
|
||||||
/// <summary>Transaction fee; null if any input amount cannot be resolved (e.g. coinbase).</summary>
|
|
||||||
public required long? FeeSats { get; init; }
|
|
||||||
public required int TotalSize { get; init; }
|
|
||||||
public required int VirtualSize { get; init; }
|
|
||||||
public required uint Version { get; init; }
|
|
||||||
public required uint LockTime { get; init; }
|
|
||||||
public required bool RbfSignaled { get; init; }
|
|
||||||
/// <summary>Merkle proof verified during sync (§7.4).</summary>
|
|
||||||
public required bool Verified { get; init; }
|
|
||||||
public required DateTimeOffset? BlockTime { get; init; }
|
|
||||||
public required long TotalOutSats { get; init; }
|
|
||||||
public required long? TotalInSats { get; init; }
|
|
||||||
public required IReadOnlyList<TxInputInfo> Inputs { get; init; }
|
|
||||||
public required IReadOnlyList<TxOutputInfo> Outputs { get; init; }
|
|
||||||
|
|
||||||
public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
|
|
||||||
public bool IsIncoming => NetSats >= 0;
|
|
||||||
/// <summary>Amount sent to external recipients (outputs not ours): the "sent" amount.</summary>
|
|
||||||
public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
|
|
||||||
public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
|
|
||||||
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
|
|
||||||
/// <summary>
|
|
||||||
/// Counterparty addresses: external recipients for a send (outputs not ours),
|
|
||||||
/// external senders for a receive (inputs not ours).
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
|
|
||||||
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
|
|
||||||
: [.. Outputs.Where(o => !o.IsMine && o.Address is not null).Select(o => o.Address!).Distinct()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches all data for a single transaction from the server (blueprint §10):
|
|
||||||
/// the raw transaction and the outputs spent by its inputs, to reconstruct
|
|
||||||
/// amounts, fees, and addresses that the server does not summarise.
|
|
||||||
/// </summary>
|
|
||||||
public static class TransactionInspector
|
|
||||||
{
|
|
||||||
public static async Task<TransactionDetails> FetchAsync(
|
|
||||||
ElectrumClient client, Network network, string txid, int tipHeight, int height,
|
|
||||||
IReadOnlySet<string> ownedAddresses, long netSats, bool verified,
|
|
||||||
IReadOnlyDictionary<string, Transaction>? cache = null,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
async Task<Transaction> GetTx(string id)
|
|
||||||
{
|
|
||||||
if (cache is not null && cache.TryGetValue(id, out var hit))
|
|
||||||
return hit;
|
|
||||||
return Transaction.Parse(await client.GetTransactionAsync(id, ct), network);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<Transaction?> GetTxOrNull(string id)
|
|
||||||
{
|
|
||||||
try { return await GetTx(id); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task<DateTimeOffset?> GetBlockTimeOrNull()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
|
|
||||||
return DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
|
|
||||||
}
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
string? AddrOf(Script s)
|
|
||||||
{
|
|
||||||
try { return s.GetDestinationAddress(network)?.ToString(); }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
var tx = await GetTx(txid);
|
|
||||||
|
|
||||||
var outputs = new List<TxOutputInfo>(tx.Outputs.Count);
|
|
||||||
for (var i = 0; i < tx.Outputs.Count; i++)
|
|
||||||
{
|
|
||||||
var o = tx.Outputs[i];
|
|
||||||
var addr = AddrOf(o.ScriptPubKey);
|
|
||||||
outputs.Add(new TxOutputInfo(
|
|
||||||
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
|
|
||||||
addr is not null && ownedAddresses.Contains(addr)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
|
|
||||||
|
|
||||||
// Input transactions are needed for amounts/addresses/fees. Fetched in
|
|
||||||
// parallel (unique ids, concurrent requests supported by ElectrumClient):
|
|
||||||
// sequential fetching costs one round-trip per input. The block header
|
|
||||||
// is also fetched in parallel.
|
|
||||||
var prevTxids = tx.IsCoinBase
|
|
||||||
? []
|
|
||||||
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
|
|
||||||
|
|
||||||
var prevFetch = prevTxids.ToDictionary(id => id, id => GetTxOrNull(id));
|
|
||||||
var headerTask = height > 0 ? GetBlockTimeOrNull() : Task.FromResult<DateTimeOffset?>(null);
|
|
||||||
await Task.WhenAll(prevFetch.Values.Cast<Task>().Append(headerTask));
|
|
||||||
|
|
||||||
var prevTxs = prevFetch.ToDictionary(kv => kv.Key, kv => kv.Value.Result);
|
|
||||||
var blockTime = await headerTask;
|
|
||||||
|
|
||||||
var inputs = new List<TxInputInfo>(tx.Inputs.Count);
|
|
||||||
var feeKnown = !tx.IsCoinBase;
|
|
||||||
long inSum = 0;
|
|
||||||
foreach (var inp in tx.Inputs)
|
|
||||||
{
|
|
||||||
if (tx.IsCoinBase)
|
|
||||||
{
|
|
||||||
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
long? amt = null;
|
|
||||||
string? addr = null;
|
|
||||||
var prev = prevTxs.GetValueOrDefault(inp.PrevOut.Hash.ToString());
|
|
||||||
if (prev is not null && inp.PrevOut.N < prev.Outputs.Count)
|
|
||||||
{
|
|
||||||
var po = prev.Outputs[(int)inp.PrevOut.N];
|
|
||||||
amt = po.Value.Satoshi;
|
|
||||||
addr = AddrOf(po.ScriptPubKey);
|
|
||||||
inSum += po.Value.Satoshi;
|
|
||||||
}
|
|
||||||
else feeKnown = false;
|
|
||||||
|
|
||||||
inputs.Add(new TxInputInfo(
|
|
||||||
inp.PrevOut.Hash.ToString(), inp.PrevOut.N, amt, addr,
|
|
||||||
addr is not null && ownedAddresses.Contains(addr), false));
|
|
||||||
}
|
|
||||||
|
|
||||||
var outSum = tx.Outputs.Sum(o => o.Value.Satoshi);
|
|
||||||
|
|
||||||
return new TransactionDetails
|
|
||||||
{
|
|
||||||
Txid = txid,
|
|
||||||
Height = height,
|
|
||||||
Confirmations = height > 0 && tipHeight >= height ? tipHeight - height + 1 : 0,
|
|
||||||
NetSats = netSats,
|
|
||||||
FeeSats = feeKnown ? inSum - outSum : null,
|
|
||||||
TotalSize = tx.ToBytes().Length,
|
|
||||||
VirtualSize = tx.GetVirtualSize(),
|
|
||||||
Version = tx.Version,
|
|
||||||
LockTime = tx.LockTime.Value,
|
|
||||||
RbfSignaled = rbf,
|
|
||||||
Verified = verified,
|
|
||||||
BlockTime = blockTime,
|
|
||||||
TotalOutSats = outSum,
|
|
||||||
TotalInSats = feeKnown ? inSum : null,
|
|
||||||
Inputs = inputs,
|
|
||||||
Outputs = outputs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ScriptType(Script script)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var t = StandardScripts.GetTemplateFromScriptPubKey(script);
|
|
||||||
return t is null
|
|
||||||
? "nonstandard"
|
|
||||||
: t.GetType().Name.Replace("PayTo", "").Replace("Template", "");
|
|
||||||
}
|
|
||||||
catch { return "—"; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,66 +6,39 @@ using PalladiumWallet.Core.Storage;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wallet type factory (blueprint §4.5): reconstructs the correct account type
|
/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce
|
||||||
/// from a document (HD from seed, HD from imported xprv, imported WIF, watch-only).
|
/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della
|
||||||
|
/// factory dei tipi di wallet; crescerà con multisig e importati.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WalletLoader
|
public static class WalletLoader
|
||||||
{
|
{
|
||||||
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
||||||
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
||||||
|
|
||||||
public static IWalletAccount ToAccount(WalletDocument doc)
|
public static HdAccount ToAccount(WalletDocument doc)
|
||||||
{
|
{
|
||||||
var profile = ProfileOf(doc);
|
var profile = ProfileOf(doc);
|
||||||
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
||||||
var network = PalladiumNetworks.For(profile.Kind);
|
var path = KeyPath.Parse(doc.AccountPath);
|
||||||
|
|
||||||
// 1. HD from seed (most common case)
|
|
||||||
if (doc.Mnemonic is { } words)
|
if (doc.Mnemonic is { } words)
|
||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out var mnemonic))
|
if (!Bip39.TryParse(words, out var mnemonic))
|
||||||
throw new InvalidDataException("Invalid mnemonic in wallet file.");
|
throw new InvalidDataException("Mnemonica del file wallet non valida.");
|
||||||
var path = KeyPath.Parse(doc.AccountPath ?? DerivationPaths.AccountPath(kind, profile).ToString());
|
|
||||||
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. HD from imported xprv (spendable, no seed)
|
|
||||||
if (doc.AccountXprv is { } xprvStr)
|
|
||||||
{
|
|
||||||
if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _))
|
|
||||||
throw new InvalidDataException("Xprv in wallet file is invalid for this network.");
|
|
||||||
var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null;
|
|
||||||
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Imported WIF keys
|
|
||||||
if (doc.WifKeys is { Count: > 0 } wifKeys)
|
|
||||||
{
|
|
||||||
var entries = wifKeys.Select(wif =>
|
|
||||||
{
|
|
||||||
var secret = new BitcoinSecret(wif, network);
|
|
||||||
var addr = secret.PrivateKey.PubKey
|
|
||||||
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
|
||||||
return (addr, (Key?)secret.PrivateKey);
|
|
||||||
}).ToList();
|
|
||||||
return new ImportedKeyAccount(entries, kind, profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Watch-only from xpub
|
|
||||||
if (doc.AccountXpub is null)
|
|
||||||
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
|
||||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||||
throw new InvalidDataException("Xpub in wallet file is invalid for this network.");
|
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
|
||||||
var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null;
|
return HdAccount.FromAccountXpub(xpub!, kind, profile, path);
|
||||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates the document for a new seed wallet.</summary>
|
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
||||||
public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic(
|
public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic(
|
||||||
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
|
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
|
||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out var mnemonic))
|
if (!Bip39.TryParse(words, out var mnemonic))
|
||||||
throw new InvalidDataException("Invalid mnemonic (wrong words or checksum).");
|
throw new InvalidDataException("Mnemonica non valida (parole o checksum errati).");
|
||||||
|
|
||||||
var account = customPath is null
|
var account = customPath is null
|
||||||
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
|
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
|
||||||
@@ -83,91 +56,4 @@ public static class WalletLoader
|
|||||||
};
|
};
|
||||||
return (doc, account);
|
return (doc, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the document from an imported SLIP-132 xpub (watch-only).
|
|
||||||
/// Detects the ScriptKind from the SLIP-132 header; <paramref name="kindOverride"/> overrides
|
|
||||||
/// it for ambiguous prefixes (xpub can be Legacy or Taproot).
|
|
||||||
/// </summary>
|
|
||||||
public static (WalletDocument Doc, HdAccount Account) NewFromXpub(
|
|
||||||
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
|
||||||
{
|
|
||||||
if (!Slip132.TryDecodePublic(slip132.Trim(), profile, out var xpub, out var detectedKind))
|
|
||||||
throw new InvalidDataException("Extended public key is invalid or unrecognised for this network.");
|
|
||||||
var kind = kindOverride ?? detectedKind;
|
|
||||||
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
|
||||||
|
|
||||||
var doc = new WalletDocument
|
|
||||||
{
|
|
||||||
Network = profile.NetName,
|
|
||||||
ScriptKind = kind.ToString(),
|
|
||||||
AccountPath = account.AccountPath.ToString(),
|
|
||||||
AccountXpub = slip132.Trim(),
|
|
||||||
};
|
|
||||||
return (doc, account);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the document from an imported SLIP-132 xprv (spendable without seed).
|
|
||||||
/// The document contains the xprv in plaintext — it must be encrypted.
|
|
||||||
/// </summary>
|
|
||||||
public static (WalletDocument Doc, HdAccount Account) NewFromXprv(
|
|
||||||
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
|
||||||
{
|
|
||||||
if (!Slip132.TryDecodePrivate(slip132.Trim(), profile, out var xprv, out var detectedKind))
|
|
||||||
throw new InvalidDataException("Extended private key is invalid or unrecognised for this network.");
|
|
||||||
var kind = kindOverride ?? detectedKind;
|
|
||||||
var account = HdAccount.FromAccountXprv(xprv!, kind, profile);
|
|
||||||
|
|
||||||
var doc = new WalletDocument
|
|
||||||
{
|
|
||||||
Network = profile.NetName,
|
|
||||||
ScriptKind = kind.ToString(),
|
|
||||||
AccountPath = account.AccountPath.ToString(),
|
|
||||||
AccountXpub = account.ToSlip132(),
|
|
||||||
AccountXprv = slip132.Trim(),
|
|
||||||
};
|
|
||||||
return (doc, account);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates the document from one or more imported WIF keys.
|
|
||||||
/// The document contains the WIF keys in plaintext — it must be encrypted.
|
|
||||||
/// </summary>
|
|
||||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
|
||||||
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
|
|
||||||
{
|
|
||||||
if (wifKeys.Count == 0)
|
|
||||||
throw new InvalidDataException("At least one WIF key is required.");
|
|
||||||
|
|
||||||
var network = PalladiumNetworks.For(profile.Kind);
|
|
||||||
var entries = new List<(BitcoinAddress, Key?)>();
|
|
||||||
var wifStrings = new List<string>();
|
|
||||||
|
|
||||||
foreach (var raw in wifKeys)
|
|
||||||
{
|
|
||||||
BitcoinSecret secret;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
secret = new BitcoinSecret(raw.Trim(), network);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException($"Invalid WIF key: {ex.Message}");
|
|
||||||
}
|
|
||||||
var addr = secret.PrivateKey.PubKey
|
|
||||||
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
|
||||||
entries.Add((addr, secret.PrivateKey));
|
|
||||||
wifStrings.Add(raw.Trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
var account = new ImportedKeyAccount(entries, kind, profile);
|
|
||||||
var doc = new WalletDocument
|
|
||||||
{
|
|
||||||
Network = profile.NetName,
|
|
||||||
ScriptKind = kind.ToString(),
|
|
||||||
WifKeys = wifStrings,
|
|
||||||
};
|
|
||||||
return (doc, account);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class ChainProfileTests
|
|||||||
Assert.Equal(50002, p.DefaultSslPort);
|
Assert.Equal(50002, p.DefaultSslPort);
|
||||||
Assert.Equal(746, p.Bip44CoinType);
|
Assert.Equal(746, p.Bip44CoinType);
|
||||||
Assert.Equal("palladium", p.UriScheme);
|
Assert.Equal("palladium", p.UriScheme);
|
||||||
Assert.True(p.SkipPowValidation); // mandatory: the chain uses LWMA (§3)
|
Assert.True(p.SkipPowValidation); // obbligatorio: la catena usa LWMA (§3)
|
||||||
Assert.Equal(120, p.BlockTimeSeconds);
|
Assert.Equal(120, p.BlockTimeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,44 +69,8 @@ public class ChainProfileTests
|
|||||||
Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
|
Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
// Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check:
|
||||||
public void Rete_sconosciuta_lancia_ArgumentException()
|
// il prefisso testuale risultante dipende solo dall'header.
|
||||||
{
|
|
||||||
Assert.ThrowsAny<ArgumentException>(() => ChainProfiles.For((NetKind)99));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void I_tre_profili_sono_istanze_distinte()
|
|
||||||
{
|
|
||||||
Assert.NotSame(ChainProfiles.Mainnet, ChainProfiles.Testnet);
|
|
||||||
Assert.NotSame(ChainProfiles.Mainnet, ChainProfiles.Regtest);
|
|
||||||
Assert.NotSame(ChainProfiles.Testnet, ChainProfiles.Regtest);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Tutti_i_profili_hanno_gli_stessi_porti_tcp_ssl()
|
|
||||||
{
|
|
||||||
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
|
|
||||||
{
|
|
||||||
Assert.Equal(50001, profile.DefaultTcpPort);
|
|
||||||
Assert.Equal(50002, profile.DefaultSslPort);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Coin_type_mainnet_e_746()
|
|
||||||
{
|
|
||||||
Assert.Equal(746, ChainProfiles.Mainnet.Bip44CoinType);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Coin_type_testnet_e_1()
|
|
||||||
{
|
|
||||||
Assert.Equal(1, ChainProfiles.Testnet.Bip44CoinType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize header (4-byte BE) + 74-byte BIP32 payload and encode Base58Check:
|
|
||||||
// the resulting textual prefix depends only on the header.
|
|
||||||
private static string EncodeWithHeader(uint header)
|
private static string EncodeWithHeader(uint header)
|
||||||
{
|
{
|
||||||
var data = new byte[78];
|
var data = new byte[78];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace PalladiumWallet.Tests.Chain;
|
|||||||
|
|
||||||
public class PalladiumNetworksTests
|
public class PalladiumNetworksTests
|
||||||
{
|
{
|
||||||
// Fixed private key for deterministic tests (test-only, never use it for real).
|
// Chiave privata fissa per test deterministici (solo test, mai usarla davvero).
|
||||||
private static Key TestKey => new(Convert.FromHexString(
|
private static Key TestKey => new(Convert.FromHexString(
|
||||||
"0000000000000000000000000000000000000000000000000000000000000001"));
|
"0000000000000000000000000000000000000000000000000000000000000001"));
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class AddressDerivationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Il_vettore_bip44_produce_lo_stesso_hash_su_bitcoin_e_plm()
|
public void Il_vettore_bip44_produce_lo_stesso_hash_su_bitcoin_e_plm()
|
||||||
{
|
{
|
||||||
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
// Indirizzo noto di abandon-about su m/44'/0'/0'/0/0 (riferimento pubblico).
|
||||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
||||||
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
||||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
var pubKey = account.GetPublicKey(isChange: false, 0);
|
||||||
@@ -27,8 +27,8 @@ public class AddressDerivationTests
|
|||||||
|
|
||||||
var plmAddr = account.GetReceiveAddress(0);
|
var plmAddr = account.GetReceiveAddress(0);
|
||||||
Assert.StartsWith("P", plmAddr.ToString());
|
Assert.StartsWith("P", plmAddr.ToString());
|
||||||
// Same hash160 under the two prefixes: the derivation is identical,
|
// Stesso hash160 sotto i due prefissi: la derivazione è identica,
|
||||||
// only the network dress changes.
|
// cambia solo la veste di rete.
|
||||||
Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
|
Assert.Equal(pubKey.Hash, ((BitcoinPubKeyAddress)plmAddr).Hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using PalladiumWallet.Core.Crypto;
|
|||||||
namespace PalladiumWallet.Tests.Crypto;
|
namespace PalladiumWallet.Tests.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BIP32 specification test vector 1 (seed 000102...0e0f). The Legacy headers
|
/// Vettore di test 1 della specifica BIP32 (seed 000102...0e0f). Gli header
|
||||||
/// of the PLM mainnet coincide with the Bitcoin ones, so the comparison
|
/// Legacy della mainnet PLM coincidono con quelli Bitcoin, quindi il confronto
|
||||||
/// with the specification strings is direct on the PLM network.
|
/// con le stringhe della specifica è diretto sulla rete PLM.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Bip32Tests
|
public class Bip32Tests
|
||||||
{
|
{
|
||||||
@@ -40,7 +40,7 @@ public class Bip32Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void I_path_hardened_non_sono_derivabili_da_una_xpub()
|
public void I_path_hardened_non_sono_derivabili_da_una_xpub()
|
||||||
{
|
{
|
||||||
// §17 guarantee: from public keys alone, no hardened derivation.
|
// Garanzia §17: da sole chiavi pubbliche niente derivazione hardened.
|
||||||
Assert.ThrowsAny<InvalidOperationException>(() =>
|
Assert.ThrowsAny<InvalidOperationException>(() =>
|
||||||
Root.Neuter().Derive(new KeyPath("0'")));
|
Root.Neuter().Derive(new KeyPath("0'")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace PalladiumWallet.Tests.Crypto;
|
|||||||
|
|
||||||
public class Bip39Tests
|
public class Bip39Tests
|
||||||
{
|
{
|
||||||
// Official Trezor vectors (python-mnemonic/vectors.json), passphrase "TREZOR".
|
// Vettori ufficiali Trezor (python-mnemonic/vectors.json), passphrase "TREZOR".
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(
|
[InlineData(
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
@@ -30,8 +30,8 @@ public class Bip39Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
|
public void Il_vettore_giapponese_copre_la_normalizzazione_nfkd()
|
||||||
{
|
{
|
||||||
// Official bip32JP vector (test_JP_BIP39.json[0]): mnemonic with U+3000
|
// Vettore ufficiale bip32JP (test_JP_BIP39.json[0]): mnemonica con spazi
|
||||||
// ideographic spaces and a passphrase with characters to normalize NFKD.
|
// ideografici U+3000 e passphrase con caratteri da normalizzare NFKD.
|
||||||
const string words =
|
const string words =
|
||||||
"あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
|
"あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あいこくしん あおぞら";
|
||||||
const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
|
const string passphrase = "㍍ガバヴァぱばぐゞちぢ十人十色";
|
||||||
@@ -55,8 +55,8 @@ public class Bip39Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Checksum_invalido_viene_rifiutato()
|
public void Checksum_invalido_viene_rifiutato()
|
||||||
{
|
{
|
||||||
// 12 × "abandon": valid words but wrong checksum — the case that the
|
// 12 × "abandon": parole valide ma checksum errato — il caso che il
|
||||||
// NBitcoin constructor does not check on its own.
|
// costruttore NBitcoin non controlla da solo.
|
||||||
var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
|
var words = string.Join(' ', Enumerable.Repeat("abandon", 12));
|
||||||
Assert.False(Bip39.TryParse(words, out _));
|
Assert.False(Bip39.TryParse(words, out _));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ using PalladiumWallet.Core.Crypto;
|
|||||||
namespace PalladiumWallet.Tests.Crypto;
|
namespace PalladiumWallet.Tests.Crypto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BIP84 specification test vector (abandon-about mnemonic, without
|
/// Vettore di test della specifica BIP84 (mnemonica abandon-about, senza
|
||||||
/// passphrase). The SLIP-132 headers of the PLM mainnet coincide with the
|
/// passphrase). Gli header SLIP-132 della mainnet PLM coincidono con quelli
|
||||||
/// Bitcoin ones, so zprv/zpub are compared directly; addresses are
|
/// Bitcoin, quindi zprv/zpub si confrontano direttamente; gli indirizzi si
|
||||||
/// compared on the witness program (chain-independent) + PLM prefix.
|
/// confrontano sul witness program (chain-independent) + prefisso PLM.
|
||||||
/// The path m/84'/0'/0' is deliberately "customized" (coin type 0, not 746):
|
/// Il path m/84'/0'/0' è volutamente "personalizzato" (coin type 0, non 746):
|
||||||
/// it also exercises the import with a custom path (§4.2).
|
/// esercita anche l'import con path custom (§4.2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Bip84Slip132Tests
|
public class Bip84Slip132Tests
|
||||||
{
|
{
|
||||||
@@ -64,12 +64,12 @@ public class Bip84Slip132Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
|
public void Una_xkey_con_header_sconosciuto_viene_rifiutata()
|
||||||
{
|
{
|
||||||
// Valid Bitcoin xpub but with Legacy header: it is not a zpub.
|
// xpub Bitcoin valida ma con header Legacy: non è una zpub.
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var asXpub = account.AccountXpub.ToString(Network.Main);
|
var asXpub = account.AccountXpub.ToString(Network.Main);
|
||||||
|
|
||||||
Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
|
Assert.True(Slip132.TryDecodePublic(asXpub, ChainProfiles.Mainnet, out _, out var kind));
|
||||||
Assert.Equal(ScriptKind.Legacy, kind); // xpub header → recognized as Legacy
|
Assert.Equal(ScriptKind.Legacy, kind); // header xpub → riconosciuto come Legacy
|
||||||
Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
|
Assert.False(Slip132.TryDecodePublic("non-base58!!!", ChainProfiles.Mainnet, out _, out _));
|
||||||
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,8 @@ public class Bip84Slip132Tests
|
|||||||
if (expectedPubKeyHex is not null)
|
if (expectedPubKeyHex is not null)
|
||||||
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
||||||
|
|
||||||
// The witness program (hash160 of the pubkey) is chain-independent: it must
|
// Il witness program (hash160 della pubkey) è chain-independent: deve
|
||||||
// coincide with that of the bc1 address from the official vector.
|
// coincidere con quello dell'indirizzo bc1 del vettore ufficiale.
|
||||||
var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
var bitcoinExpected = (BitcoinWitPubKeyAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
||||||
Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
|
Assert.Equal(bitcoinExpected.Hash, pubKey.WitHash);
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Crypto;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests.Crypto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// BIP86 test vectors (abandon-about mnemonic, no passphrase).
|
|
||||||
/// The tweaked public key (output key, 32-byte x-only) is chain-independent:
|
|
||||||
/// verified against the official Bitcoin vectors, then checked that the PLM
|
|
||||||
/// address starts with plm1p (witness v1, bech32m).
|
|
||||||
/// The path m/86'/0'/0' uses coin_type=0 (not 746) to match the BIP86 vectors.
|
|
||||||
/// </summary>
|
|
||||||
public class Bip86TaprootTests
|
|
||||||
{
|
|
||||||
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.Taproot,
|
|
||||||
ChainProfiles.Mainnet,
|
|
||||||
new KeyPath("86'/0'/0'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Il_derivation_path_di_un_account_taproot_usa_purpose_86()
|
|
||||||
{
|
|
||||||
var path = DerivationPaths.AccountPath(ScriptKind.Taproot, ChainProfiles.Mainnet, 0);
|
|
||||||
Assert.Equal($"86'/{ChainProfiles.Mainnet.Bip44CoinType}'/0'", path.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Gli_indirizzi_plm_taproot_iniziano_con_plm1p()
|
|
||||||
{
|
|
||||||
var account = Account();
|
|
||||||
var addr0 = account.GetReceiveAddress(0).ToString();
|
|
||||||
var addr1 = account.GetReceiveAddress(1).ToString();
|
|
||||||
var change0 = account.GetChangeAddress(0).ToString();
|
|
||||||
|
|
||||||
Assert.StartsWith("plm1p", addr0);
|
|
||||||
Assert.StartsWith("plm1p", addr1);
|
|
||||||
Assert.StartsWith("plm1p", change0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The output key (tweaked x-only pubkey, 32 bytes) matches the BIP86 vector
|
|
||||||
/// regardless of network: the network changes only HRP and checksum, not the program.
|
|
||||||
/// Bitcoin addresses from https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki
|
|
||||||
/// </summary>
|
|
||||||
[Theory]
|
|
||||||
[InlineData(false, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr")]
|
|
||||||
[InlineData(false, 1, "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh")]
|
|
||||||
[InlineData(true, 0, "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7")]
|
|
||||||
public void L_output_key_coincide_col_vettore_bip86(
|
|
||||||
bool isChange, int index, string bitcoinAddress)
|
|
||||||
{
|
|
||||||
var account = Account();
|
|
||||||
var plmAddr = account.GetAddress(isChange, index);
|
|
||||||
|
|
||||||
// The witness program (tweaked output key) is chain-independent.
|
|
||||||
var btcAddr = (TaprootAddress)BitcoinAddress.Create(bitcoinAddress, Network.Main);
|
|
||||||
var plmTaproot = (TaprootAddress)plmAddr;
|
|
||||||
Assert.Equal(btcAddr.PubKey, plmTaproot.PubKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Il_wallet_taproot_e_watch_only_se_creato_da_xpub()
|
|
||||||
{
|
|
||||||
var full = Account();
|
|
||||||
var watchOnly = HdAccount.FromAccountXpub(
|
|
||||||
full.AccountXpub, ScriptKind.Taproot, ChainProfiles.Mainnet);
|
|
||||||
Assert.True(watchOnly.IsWatchOnly);
|
|
||||||
Assert.Equal(
|
|
||||||
full.GetReceiveAddress(0).ToString(),
|
|
||||||
watchOnly.GetReceiveAddress(0).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void I_tipi_multisig_lanciano_not_supported()
|
|
||||||
{
|
|
||||||
Assert.Throws<NotSupportedException>(
|
|
||||||
() => DerivationPaths.ScriptPubKeyTypeFor(ScriptKind.WrappedSegwitMultisig));
|
|
||||||
Assert.Throws<NotSupportedException>(
|
|
||||||
() => DerivationPaths.ScriptPubKeyTypeFor(ScriptKind.NativeSegwitMultisig));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ public class PeerParsingTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx()
|
public void La_risposta_peers_subscribe_si_parsa_nel_formato_electrumx()
|
||||||
{
|
{
|
||||||
// Real format: [ip, hostname, ["v...", "pN", "tPORT", "sPORT"]].
|
// Formato reale: [ip, hostname, ["v...", "pN", "tPORTA", "sPORTA"]].
|
||||||
const string json = """
|
const string json = """
|
||||||
[
|
[
|
||||||
["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]],
|
["173.212.224.67", "173.212.224.67", ["v1.4.2", "p10000", "t50001", "s50002"]],
|
||||||
@@ -20,10 +20,10 @@ public class PeerParsingTests
|
|||||||
""";
|
""";
|
||||||
var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement);
|
var peers = ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement);
|
||||||
|
|
||||||
Assert.Equal(3, peers.Count); // last entry has no ports → discarded
|
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]);
|
Assert.Equal(new PeerInfo("173.212.224.67", 50001, 50002, "1.4.2"), peers[0]);
|
||||||
// "t" without a number = default port (0 signals "resolve from profile").
|
// "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("nodo.esempio.org", 0, null, "1.4"), peers[1]);
|
||||||
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
||||||
}
|
}
|
||||||
@@ -55,8 +55,8 @@ public class ServerRegistryTests
|
|||||||
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||||
var bootstrapCount = registry.All.Count;
|
var bootstrapCount = registry.All.Count;
|
||||||
|
|
||||||
// Direct merge via DiscoverAsync requires a client: test persistence
|
// Merge diretto via DiscoverAsync richiede un client: si testa la
|
||||||
// by simulating the discovered servers file.
|
// persistenza simulando il file degli scoperti.
|
||||||
var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") };
|
var discovered = new[] { new KnownServer("nuovo.esempio.org", 50001, 50002, "1.4.2") };
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(discovered));
|
File.WriteAllText(path, JsonSerializer.Serialize(discovered));
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ public class ServerRegistryTests
|
|||||||
Assert.Equal(bootstrapCount + 1, reloaded.All.Count);
|
Assert.Equal(bootstrapCount + 1, reloaded.All.Count);
|
||||||
Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org");
|
Assert.Contains(reloaded.All, s => s.Host == "nuovo.esempio.org");
|
||||||
|
|
||||||
// A bootstrap duplicate in the file must not double-count.
|
// Un bootstrap duplicato nel file non raddoppia.
|
||||||
File.WriteAllText(path, JsonSerializer.Serialize(new[]
|
File.WriteAllText(path, JsonSerializer.Serialize(new[]
|
||||||
{
|
{
|
||||||
new KnownServer("173.212.224.67", 50001, 50002),
|
new KnownServer("173.212.224.67", 50001, 50002),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
<PackageReference Include="CsCheck" Version="4.7.0" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using CsCheck;
|
|
||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Spv;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Property-based tests (CsCheck). Each test generates hundreds of random inputs and
|
|
||||||
/// verifies that invariant properties hold — crashes, unexpected exceptions, or
|
|
||||||
/// roundtrip violations are failures.
|
|
||||||
/// </summary>
|
|
||||||
public class PropertyTests
|
|
||||||
{
|
|
||||||
// ── reusable generators ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static readonly Gen<string> GenUnit = Gen.OneOf(
|
|
||||||
Gen.Const("PLM"), Gen.Const("mPLM"), Gen.Const("µPLM"), Gen.Const("sat"));
|
|
||||||
|
|
||||||
// random uint256 built from 4 ulongs
|
|
||||||
private static readonly Gen<uint256> GenTxid =
|
|
||||||
Gen.Select(Gen.ULong, Gen.ULong, Gen.ULong, Gen.ULong, (a, b, c, d) =>
|
|
||||||
{
|
|
||||||
var bytes = new byte[32];
|
|
||||||
BitConverter.TryWriteBytes(bytes.AsSpan(0, 8), a);
|
|
||||||
BitConverter.TryWriteBytes(bytes.AsSpan(8, 8), b);
|
|
||||||
BitConverter.TryWriteBytes(bytes.AsSpan(16, 8), c);
|
|
||||||
BitConverter.TryWriteBytes(bytes.AsSpan(24, 8), d);
|
|
||||||
return new uint256(bytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── CoinAmount ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// TryParseIn must never throw on arbitrary input with valid units.
|
|
||||||
[Fact]
|
|
||||||
public void CoinAmount_TryParseIn_non_lancia_mai_su_input_arbitrario()
|
|
||||||
{
|
|
||||||
Gen.Select(GenUnit, Gen.String).Sample((unit, text) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CoinAmount.TryParseIn(text, unit, out _);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
// unknown unit: impossible here because we only use known units
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Assert.Fail($"TryParseIn threw {ex.GetType().Name} for unit={unit}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FormatIn → TryParseIn: any satoshi value in [0, MaxSupply] must roundtrip exactly.
|
|
||||||
[Fact]
|
|
||||||
public void CoinAmount_roundtrip_FormatIn_TryParseIn_per_ogni_unita()
|
|
||||||
{
|
|
||||||
const long MaxSupply = 21_000_000L * 100_000_000L;
|
|
||||||
Gen.Select(Gen.Long[0, MaxSupply], GenUnit).Sample((sats, unit) =>
|
|
||||||
{
|
|
||||||
var formatted = CoinAmount.FormatIn(sats, unit, withLabel: false);
|
|
||||||
Assert.True(
|
|
||||||
CoinAmount.TryParseIn(formatted, unit, out var parsed),
|
|
||||||
$"FormatIn={formatted} unit={unit} failed to re-parse");
|
|
||||||
Assert.Equal(sats, parsed);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TryParseCoins must never throw on arbitrary input.
|
|
||||||
[Fact]
|
|
||||||
public void CoinAmount_TryParseCoins_non_lancia_mai_su_input_arbitrario()
|
|
||||||
{
|
|
||||||
Gen.String.Sample(text =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CoinAmount.TryParseCoins(text, out _);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Assert.Fail($"TryParseCoins threw {ex.GetType().Name}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Any value accepted by TryParseCoins must be ≥ 0.
|
|
||||||
[Fact]
|
|
||||||
public void CoinAmount_TryParseCoins_accetta_solo_valori_non_negativi()
|
|
||||||
{
|
|
||||||
Gen.String.Sample(text =>
|
|
||||||
{
|
|
||||||
if (CoinAmount.TryParseCoins(text, out var sats))
|
|
||||||
Assert.True(sats >= 0, $"TryParseCoins returned {sats} for '{text}'");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── EncryptedFile ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Encrypt → Decrypt with the same password must return the original plaintext.
|
|
||||||
[Fact]
|
|
||||||
public void EncryptedFile_roundtrip_su_contenuto_e_password_arbitrari()
|
|
||||||
{
|
|
||||||
Gen.Select(Gen.String, Gen.String[1, 64]).Sample((plaintext, password) =>
|
|
||||||
{
|
|
||||||
var cipher = EncryptedFile.Encrypt(plaintext, password);
|
|
||||||
var recovered = EncryptedFile.Decrypt(cipher, password);
|
|
||||||
Assert.Equal(plaintext, recovered);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt with the wrong password must throw WrongPasswordException, never anything else.
|
|
||||||
[Fact]
|
|
||||||
public void EncryptedFile_password_sbagliata_lancia_solo_WrongPasswordException()
|
|
||||||
{
|
|
||||||
Gen.Select(Gen.String, Gen.String[1, 32], Gen.String[1, 32]).Sample((plaintext, pwd1, pwd2) =>
|
|
||||||
{
|
|
||||||
if (pwd1 == pwd2) return; // same password: valid roundtrip, skip
|
|
||||||
|
|
||||||
var cipher = EncryptedFile.Encrypt(plaintext, pwd1);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EncryptedFile.Decrypt(cipher, pwd2);
|
|
||||||
Assert.Fail("Decrypt with wrong password did not throw");
|
|
||||||
}
|
|
||||||
catch (WrongPasswordException) { /* expected */ }
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Assert.Fail($"Decrypt threw {ex.GetType().Name} instead of WrongPasswordException");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// IsEncrypted must never throw on arbitrary input.
|
|
||||||
[Fact]
|
|
||||||
public void EncryptedFile_IsEncrypted_non_lancia_mai()
|
|
||||||
{
|
|
||||||
Gen.String.Sample(s =>
|
|
||||||
{
|
|
||||||
try { EncryptedFile.IsEncrypted(s); }
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Assert.Fail($"IsEncrypted threw {ex.GetType().Name}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MerkleProof ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Every leaf of a randomly generated Merkle tree must verify against the root.
|
|
||||||
[Fact]
|
|
||||||
public void MerkleProof_ogni_foglia_verifica_contro_la_sua_radice()
|
|
||||||
{
|
|
||||||
GenTxid.Array[1, 16].Sample(txids =>
|
|
||||||
{
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
|
||||||
for (var pos = 0; pos < txids.Length; pos++)
|
|
||||||
{
|
|
||||||
var branch = BuildBranch(txids, pos);
|
|
||||||
Assert.True(
|
|
||||||
MerkleProof.Verify(txids[pos], pos, branch, root),
|
|
||||||
$"Verify failed for pos={pos} over {txids.Length} leaves");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A txid not present in the leaves must not verify (and must not crash).
|
|
||||||
[Fact]
|
|
||||||
public void MerkleProof_txid_estraneo_non_verifica_e_non_crasha()
|
|
||||||
{
|
|
||||||
Gen.Select(GenTxid.Array[2, 8], GenTxid).Sample((txids, extra) =>
|
|
||||||
{
|
|
||||||
if (txids.Contains(extra)) return; // random collision: skip
|
|
||||||
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
|
||||||
var branch = BuildBranch(txids, 0);
|
|
||||||
Assert.False(MerkleProof.Verify(extra, 0, branch, root));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper: builds the branch for the given position
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Spv;
|
using PalladiumWallet.Core.Spv;
|
||||||
@@ -9,7 +6,7 @@ namespace PalladiumWallet.Tests.Spv;
|
|||||||
|
|
||||||
public class ScripthashTests
|
public class ScripthashTests
|
||||||
{
|
{
|
||||||
// Vectors computed independently with Python (hashlib), not with NBitcoin.
|
// Vettori calcolati indipendentemente con Python (hashlib), non con NBitcoin.
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
|
[InlineData("76a9140102030405060708090a0b0c0d0e0f101112131488ac",
|
||||||
"5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
|
"5546fc69d399ef99854c132abb060381cc159dbec67c496a6f0e0dbf12e83ae8")]
|
||||||
@@ -20,26 +17,12 @@ public class ScripthashTests
|
|||||||
var script = Script.FromHex(scriptHex);
|
var script = Script.FromHex(scriptHex);
|
||||||
Assert.Equal(expected, Scripthash.FromScript(script));
|
Assert.Equal(expected, Scripthash.FromScript(script));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Script_identici_producono_scripthash_identico()
|
|
||||||
{
|
|
||||||
var script = Script.FromHex("76a9140102030405060708090a0b0c0d0e0f101112131488ac");
|
|
||||||
Assert.Equal(Scripthash.FromScript(script), Scripthash.FromScript(script));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Script_diversi_producono_scripthash_diversi()
|
|
||||||
{
|
|
||||||
var s1 = Script.FromHex("76a9140102030405060708090a0b0c0d0e0f101112131488ac");
|
|
||||||
var s2 = Script.FromHex("00140102030405060708090a0b0c0d0e0f1011121314");
|
|
||||||
Assert.NotEqual(Scripthash.FromScript(s1), Scripthash.FromScript(s2));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MerkleProofTests
|
public class MerkleProofTests
|
||||||
{
|
{
|
||||||
// Bitcoin block 100000: 4 transactions (even), known merkle root.
|
// Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna
|
||||||
|
// per la convenzione di hashing/ordinamento.
|
||||||
private static readonly uint256[] Block100000Txids =
|
private static readonly uint256[] Block100000Txids =
|
||||||
[
|
[
|
||||||
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
|
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
|
||||||
@@ -51,8 +34,6 @@ public class MerkleProofTests
|
|||||||
private static readonly uint256 Block100000Root =
|
private static readonly uint256 Block100000Root =
|
||||||
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
|
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
|
||||||
|
|
||||||
// ---- even number of transactions (4 tx) ----
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
|
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
|
||||||
{
|
{
|
||||||
@@ -70,70 +51,6 @@ public class MerkleProofTests
|
|||||||
Assert.True(MerkleProof.Verify(Block100000Txids[position], position, branch, Block100000Root));
|
Assert.True(MerkleProof.Verify(Block100000Txids[position], position, branch, Block100000Root));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- singola transazione ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Radice_con_singola_tx_e_la_tx_stessa()
|
|
||||||
{
|
|
||||||
var txid = uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87");
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves([txid]);
|
|
||||||
Assert.Equal(txid, root);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Verify_con_branch_vuoto_e_posizione_0_e_la_tx_stessa()
|
|
||||||
{
|
|
||||||
var txid = uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87");
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves([txid]);
|
|
||||||
Assert.True(MerkleProof.Verify(txid, 0, [], root));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- odd number of transactions (last one duplicated) ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Tre_tx_dispari_la_terza_viene_duplicata()
|
|
||||||
{
|
|
||||||
// With 3 tx: level 1 = [SHA256d(tx0||tx1), SHA256d(tx2||tx2)]
|
|
||||||
// Verify the root is deterministic and correct.
|
|
||||||
var txids = Block100000Txids.Take(3).ToArray();
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
|
||||||
var branch2 = BuildBranch(txids, 2);
|
|
||||||
Assert.True(MerkleProof.Verify(txids[2], 2, branch2, root));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Cinque_tx_dispari_verifica_tutte_le_posizioni()
|
|
||||||
{
|
|
||||||
// 5 tx → even level (4) → even level (2) → root
|
|
||||||
var txids = new uint256[5];
|
|
||||||
for (var i = 0; i < 5; i++)
|
|
||||||
txids[i] = new uint256(new byte[32].Select((_, j) => (byte)(i * 17 + j)).ToArray());
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
|
||||||
|
|
||||||
for (var pos = 0; pos < 5; pos++)
|
|
||||||
{
|
|
||||||
var branch = BuildBranch(txids, pos);
|
|
||||||
Assert.True(MerkleProof.Verify(txids[pos], pos, branch, root),
|
|
||||||
$"posizione {pos} non verifica");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- two transactions (minimum even) ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Due_tx_verifica_entrambe_le_posizioni()
|
|
||||||
{
|
|
||||||
var txids = Block100000Txids.Take(2).ToArray();
|
|
||||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
|
||||||
for (var pos = 0; pos < 2; pos++)
|
|
||||||
{
|
|
||||||
var branch = BuildBranch(txids, pos);
|
|
||||||
Assert.True(MerkleProof.Verify(txids[pos], pos, branch, root));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- invalid proofs ----
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Una_prova_per_la_posizione_sbagliata_fallisce()
|
public void Una_prova_per_la_posizione_sbagliata_fallisce()
|
||||||
{
|
{
|
||||||
@@ -148,30 +65,7 @@ public class MerkleProofTests
|
|||||||
Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root));
|
Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
/// <summary>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary>
|
||||||
public void Branch_alterato_non_verifica()
|
|
||||||
{
|
|
||||||
var branch = BuildBranch(Block100000Txids, 0);
|
|
||||||
branch[0] = uint256.One; // corrupt the first branch element
|
|
||||||
Assert.False(MerkleProof.Verify(Block100000Txids[0], 0, branch, Block100000Root));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Radice_alterata_non_verifica()
|
|
||||||
{
|
|
||||||
var branch = BuildBranch(Block100000Txids, 0);
|
|
||||||
Assert.False(MerkleProof.Verify(Block100000Txids[0], 0, branch, uint256.One));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- lista vuota lancia eccezione ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Lista_vuota_lancia_ArgumentException()
|
|
||||||
{
|
|
||||||
Assert.Throws<ArgumentException>(() => MerkleProof.ComputeRootFromLeaves([]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Builds the Merkle branch for a leaf by reconstructing the tree levels.</summary>
|
|
||||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||||
{
|
{
|
||||||
var branch = new List<uint256>();
|
var branch = new List<uint256>();
|
||||||
@@ -195,6 +89,7 @@ public class MerkleProofTests
|
|||||||
|
|
||||||
public class BlockHeaderInfoTests
|
public class BlockHeaderInfoTests
|
||||||
{
|
{
|
||||||
|
// Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3).
|
||||||
private const string GenesisHeaderHex =
|
private const string GenesisHeaderHex =
|
||||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
|
||||||
|
|
||||||
@@ -223,20 +118,10 @@ public class BlockHeaderInfoTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Con_skip_pow_la_validazione_non_controlla_il_target()
|
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);
|
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
|
||||||
Assert.True(ChainProfiles.Mainnet.SkipPowValidation);
|
Assert.True(ChainProfiles.Mainnet.SkipPowValidation);
|
||||||
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
|
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Header_troncato_lancia_eccezione()
|
|
||||||
{
|
|
||||||
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("0100000000"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Header_hex_non_valido_lancia_eccezione()
|
|
||||||
{
|
|
||||||
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests.Storage;
|
namespace PalladiumWallet.Tests.Storage;
|
||||||
@@ -17,11 +14,6 @@ public class StorageTests
|
|||||||
Labels = { ["txid123"] = "caffè" },
|
Labels = { ["txid123"] = "caffè" },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string TempPath() =>
|
|
||||||
Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
|
|
||||||
|
|
||||||
// ---- AES-GCM encryption ----
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
|
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
|
||||||
{
|
{
|
||||||
@@ -42,43 +34,14 @@ public class StorageTests
|
|||||||
public void Un_file_manomesso_viene_rifiutato()
|
public void Un_file_manomesso_viene_rifiutato()
|
||||||
{
|
{
|
||||||
var cipher = EncryptedFile.Encrypt("contenuto", "pass");
|
var cipher = EncryptedFile.Encrypt("contenuto", "pass");
|
||||||
var node = JsonNode.Parse(cipher)!;
|
// 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>());
|
var data = Convert.FromBase64String(node["Data"]!.GetValue<string>());
|
||||||
data[0] ^= 0xff;
|
data[0] ^= 0xff;
|
||||||
node["Data"] = Convert.ToBase64String(data);
|
node["Data"] = Convert.ToBase64String(data);
|
||||||
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
|
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Ogni_encrypt_produce_nonce_diverso()
|
|
||||||
{
|
|
||||||
var c1 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Nonce"]!.GetValue<string>();
|
|
||||||
var c2 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Nonce"]!.GetValue<string>();
|
|
||||||
Assert.NotEqual(c1, c2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Ogni_encrypt_produce_salt_diverso()
|
|
||||||
{
|
|
||||||
var s1 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Salt"]!.GetValue<string>();
|
|
||||||
var s2 = JsonNode.Parse(EncryptedFile.Encrypt("x", "p"))!["Salt"]!.GetValue<string>();
|
|
||||||
Assert.NotEqual(s1, s2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void IsEncrypted_restituisce_false_per_json_non_cifrato()
|
|
||||||
{
|
|
||||||
Assert.False(EncryptedFile.IsEncrypted("{\"Version\": 1}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void IsEncrypted_restituisce_false_per_testo_non_json()
|
|
||||||
{
|
|
||||||
Assert.False(EncryptedFile.IsEncrypted("non è json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WalletDocument JSON ----
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Il_documento_wallet_fa_roundtrip_json()
|
public void Il_documento_wallet_fa_roundtrip_json()
|
||||||
{
|
{
|
||||||
@@ -95,36 +58,15 @@ public class StorageTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Una_versione_futura_del_file_viene_rifiutata()
|
public void Una_versione_futura_del_file_viene_rifiutata()
|
||||||
{
|
{
|
||||||
var json = SampleDoc().ToJson().Replace("\"Version\": 1", "\"Version\": 99");
|
var doc = SampleDoc();
|
||||||
|
var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99");
|
||||||
Assert.Throws<InvalidDataException>(() => WalletDocument.FromJson(json));
|
Assert.Throws<InvalidDataException>(() => WalletDocument.FromJson(json));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Il_documento_senza_mnemonica_e_watch_only()
|
|
||||||
{
|
|
||||||
var doc = SampleDoc();
|
|
||||||
doc.Mnemonic = null;
|
|
||||||
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Json_corrotto_lancia_eccezione()
|
|
||||||
{
|
|
||||||
Assert.ThrowsAny<Exception>(() => WalletDocument.FromJson("{non è json valido}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Json_con_campi_mancanti_lancia_eccezione()
|
|
||||||
{
|
|
||||||
Assert.ThrowsAny<Exception>(() => WalletDocument.FromJson("{}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WalletStore ----
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Il_wallet_store_salva_e_riapre_con_e_senza_password()
|
public void Il_wallet_store_salva_e_riapre_con_e_senza_password()
|
||||||
{
|
{
|
||||||
var path = TempPath();
|
var path = Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
WalletStore.Save(SampleDoc(), path);
|
WalletStore.Save(SampleDoc(), path);
|
||||||
@@ -144,132 +86,10 @@ public class StorageTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Scrittura_atomica_non_lascia_file_tmp()
|
public void Il_documento_senza_mnemonica_e_watch_only()
|
||||||
{
|
{
|
||||||
var path = TempPath();
|
var doc = SampleDoc();
|
||||||
try
|
doc.Mnemonic = null;
|
||||||
{
|
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
|
||||||
WalletStore.Save(SampleDoc(), path);
|
|
||||||
Assert.True(File.Exists(path));
|
|
||||||
Assert.False(File.Exists(path + ".tmp"));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_da_path_inesistente_lancia_eccezione()
|
|
||||||
{
|
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"plm-noexist-{Guid.NewGuid()}.wallet.json");
|
|
||||||
Assert.Throws<FileNotFoundException>(() => WalletStore.Load(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Exists_restituisce_false_per_path_inesistente()
|
|
||||||
{
|
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"plm-noexist-{Guid.NewGuid()}.wallet.json");
|
|
||||||
Assert.False(WalletStore.Exists(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Due_save_successivi_producono_nonce_diversi()
|
|
||||||
{
|
|
||||||
var path = TempPath();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WalletStore.Save(SampleDoc(), path, "password");
|
|
||||||
var n1 = JsonNode.Parse(File.ReadAllText(path))!["Nonce"]!.GetValue<string>();
|
|
||||||
WalletStore.Save(SampleDoc(), path, "password");
|
|
||||||
var n2 = JsonNode.Parse(File.ReadAllText(path))!["Nonce"]!.GetValue<string>();
|
|
||||||
Assert.NotEqual(n1, n2);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WalletLock ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WalletLock_acquisisce_e_rilascia()
|
|
||||||
{
|
|
||||||
var path = TempPath();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var lock1 = WalletLock.TryAcquire(path);
|
|
||||||
Assert.NotNull(lock1);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(path + ".lock");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WalletLock_seconda_istanza_restituisce_null()
|
|
||||||
{
|
|
||||||
var path = TempPath();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var lock1 = WalletLock.TryAcquire(path);
|
|
||||||
Assert.NotNull(lock1);
|
|
||||||
Assert.Null(WalletLock.TryAcquire(path));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(path + ".lock");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WalletLock_riacquisibile_dopo_rilascio()
|
|
||||||
{
|
|
||||||
var path = TempPath();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lock1 = WalletLock.TryAcquire(path);
|
|
||||||
Assert.NotNull(lock1);
|
|
||||||
lock1!.Dispose();
|
|
||||||
|
|
||||||
using var lock2 = WalletLock.TryAcquire(path);
|
|
||||||
Assert.NotNull(lock2);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(path + ".lock");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WalletLock_dispose_rimuove_il_file_lock()
|
|
||||||
{
|
|
||||||
var path = TempPath();
|
|
||||||
var lockPath = path + ".lock";
|
|
||||||
var lock1 = WalletLock.TryAcquire(path);
|
|
||||||
Assert.NotNull(lock1);
|
|
||||||
lock1!.Dispose();
|
|
||||||
Assert.False(File.Exists(lockPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WalletLock_file_lock_preesistente_ma_non_bloccato_viene_acquisito()
|
|
||||||
{
|
|
||||||
// A stale .lock left from a previous crash (file exists but nobody holds it)
|
|
||||||
// must not block wallet opening.
|
|
||||||
var path = TempPath();
|
|
||||||
var lockPath = path + ".lock";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.WriteAllText(lockPath, "stale");
|
|
||||||
using var lock1 = WalletLock.TryAcquire(path);
|
|
||||||
Assert.NotNull(lock1);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(lockPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
using System;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests.Wallet;
|
|
||||||
|
|
||||||
public class CoinAmountTests
|
|
||||||
{
|
|
||||||
// ---- valid amounts: all units ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("1", "sat", 1)]
|
|
||||||
[InlineData("0", "sat", 0)]
|
|
||||||
[InlineData("0", "PLM", 0)]
|
|
||||||
[InlineData("0", "mPLM", 0)]
|
|
||||||
[InlineData("0", "µPLM", 0)]
|
|
||||||
[InlineData("1.5", "PLM", 150_000_000)]
|
|
||||||
[InlineData("0.00000001", "PLM", 1)]
|
|
||||||
[InlineData("1", "PLM", 100_000_000)]
|
|
||||||
[InlineData("1.00000", "mPLM", 100_000)]
|
|
||||||
[InlineData("0.00001", "mPLM", 1)]
|
|
||||||
[InlineData("1.00", "µPLM", 100)]
|
|
||||||
[InlineData("0.01", "µPLM", 1)]
|
|
||||||
[InlineData("100000000", "sat", 100_000_000)]
|
|
||||||
public void Importo_valido_viene_accettato(string input, string unit, long expectedSats)
|
|
||||||
{
|
|
||||||
Assert.True(CoinAmount.TryParseIn(input, unit, out var sats));
|
|
||||||
Assert.Equal(expectedSats, sats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- decimale con virgola (locale italiano) ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("1,5", "PLM", 150_000_000)]
|
|
||||||
[InlineData("1,00000", "mPLM", 100_000)]
|
|
||||||
[InlineData("0,00000001", "PLM", 1)]
|
|
||||||
public void Virgola_italiana_viene_accettata(string input, string unit, long expectedSats)
|
|
||||||
{
|
|
||||||
Assert.True(CoinAmount.TryParseIn(input, unit, out var sats));
|
|
||||||
Assert.Equal(expectedSats, sats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- spazi iniziali/finali ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(" 1 ", "sat", 1)]
|
|
||||||
[InlineData(" 1.5 ", "PLM", 150_000_000)]
|
|
||||||
public void Spazi_iniziali_e_finali_vengono_ignorati(string input, string unit, long expectedSats)
|
|
||||||
{
|
|
||||||
Assert.True(CoinAmount.TryParseIn(input, unit, out var sats));
|
|
||||||
Assert.Equal(expectedSats, sats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- importi con troppi decimali ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("1.9", "sat")]
|
|
||||||
[InlineData("1.1", "sat")]
|
|
||||||
[InlineData("0.001", "µPLM")]
|
|
||||||
[InlineData("1.500000001", "PLM")]
|
|
||||||
[InlineData("0.000000001", "PLM")]
|
|
||||||
[InlineData("0.000001", "mPLM")]
|
|
||||||
public void Importo_con_troppi_decimali_viene_rifiutato(string input, string unit)
|
|
||||||
{
|
|
||||||
Assert.False(CoinAmount.TryParseIn(input, unit, out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- negativi ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("-1", "sat")]
|
|
||||||
[InlineData("-0.1", "PLM")]
|
|
||||||
[InlineData("-1", "PLM")]
|
|
||||||
public void Importo_negativo_viene_rifiutato(string input, string unit)
|
|
||||||
{
|
|
||||||
Assert.False(CoinAmount.TryParseIn(input, unit, out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- stringa vuota e non numerica ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData("abc")]
|
|
||||||
[InlineData("1e5")]
|
|
||||||
[InlineData("∞")]
|
|
||||||
public void Stringa_non_numerica_viene_rifiutata(string input)
|
|
||||||
{
|
|
||||||
Assert.False(CoinAmount.TryParseIn(input, "PLM", out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- overflow ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Overflow_viene_rifiutato()
|
|
||||||
{
|
|
||||||
// 92233720368.54775807 PLM supera long.MaxValue in satoshi
|
|
||||||
Assert.False(CoinAmount.TryParseIn("99999999999", "PLM", out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- unknown unit throws ArgumentException ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("banana")]
|
|
||||||
[InlineData("BTC")]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData("plm")] // case-sensitive
|
|
||||||
public void Unita_sconosciuta_lancia_ArgumentException(string unit)
|
|
||||||
{
|
|
||||||
Assert.Throws<ArgumentException>(() => CoinAmount.TryParseIn("1", unit, out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FormatIn_unita_sconosciuta_lancia_ArgumentException()
|
|
||||||
{
|
|
||||||
Assert.Throws<ArgumentException>(() => CoinAmount.FormatIn(1, "banana"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- roundtrip FormatIn → TryParseIn ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0, "PLM")]
|
|
||||||
[InlineData(1, "sat")]
|
|
||||||
[InlineData(1, "PLM")]
|
|
||||||
[InlineData(150_000_000, "PLM")]
|
|
||||||
[InlineData(100_000, "mPLM")]
|
|
||||||
[InlineData(100, "µPLM")]
|
|
||||||
[InlineData(99_999_999, "PLM")]
|
|
||||||
public void Roundtrip_format_parse_conserva_i_satoshi(long sats, string unit)
|
|
||||||
{
|
|
||||||
var formatted = CoinAmount.FormatIn(sats, unit, withLabel: false);
|
|
||||||
Assert.True(CoinAmount.TryParseIn(formatted, unit, out var parsed));
|
|
||||||
Assert.Equal(sats, parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- TryParseCoins ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TryParseCoins_accetta_precisione_massima()
|
|
||||||
{
|
|
||||||
Assert.True(CoinAmount.TryParseCoins("0.00000001", out var sats));
|
|
||||||
Assert.Equal(1L, sats);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TryParseCoins_virgola_italiana()
|
|
||||||
{
|
|
||||||
Assert.True(CoinAmount.TryParseCoins("1,5", out var sats));
|
|
||||||
Assert.Equal(150_000_000L, sats);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TryParseCoins_rifiuta_sotto_al_satoshi()
|
|
||||||
{
|
|
||||||
Assert.False(CoinAmount.TryParseCoins("0.000000001", out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TryParseCoins_rifiuta_importo_negativo()
|
|
||||||
{
|
|
||||||
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TryParseCoins_rifiuta_overflow()
|
|
||||||
{
|
|
||||||
Assert.False(CoinAmount.TryParseCoins("99999999999", out _));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Format (PLM con 8 decimali) ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(100_000_000, "1.00000000")]
|
|
||||||
[InlineData(1, "0.00000001")]
|
|
||||||
[InlineData(0, "0.00000000")]
|
|
||||||
public void Format_produce_stringa_con_8_decimali(long sats, string expected)
|
|
||||||
{
|
|
||||||
Assert.Equal(expected, CoinAmount.Format(sats));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
using NBitcoin;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Crypto;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests.Wallet;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tests for ImportedKeyAccount and the new factory paths in WalletLoader
|
|
||||||
/// (blueprint §4.4 — imported WIF, xpub, xprv).
|
|
||||||
/// </summary>
|
|
||||||
public class ImportedKeyAccountTests
|
|
||||||
{
|
|
||||||
private static ChainProfile Profile => ChainProfiles.Mainnet;
|
|
||||||
private static Network Network => PalladiumNetworks.For(Profile.Kind);
|
|
||||||
|
|
||||||
// Valid WIF key for PLM mainnet (prefix 0x80 = Compressed WIF "K"/"L")
|
|
||||||
private static Key GenerateKey() => new Key();
|
|
||||||
|
|
||||||
private static string ToWif(Key key) => key.GetWif(Network).ToString();
|
|
||||||
|
|
||||||
// ---- ImportedKeyAccount ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Account_importato_non_e_hd()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
|
||||||
var account = new ImportedKeyAccount(
|
|
||||||
[(addr, key)], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
Assert.False(account.IsWatchOnly);
|
|
||||||
Assert.Equal(ScriptKind.NativeSegwit, account.Kind);
|
|
||||||
Assert.NotNull(account.FixedAddresses);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetAddress_restituisce_indirizzo_corretto()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
|
||||||
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
Assert.Equal(addr.ToString(), account.GetAddress(false, 0).ToString());
|
|
||||||
Assert.Equal(addr.ToString(), account.GetReceiveAddress(0).ToString());
|
|
||||||
// Change → first address
|
|
||||||
Assert.Equal(addr.ToString(), account.GetChangeAddress(0).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetPrivateKey_restituisce_chiave_corretta()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
|
||||||
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
var retrieved = account.GetPrivateKey(false, 0);
|
|
||||||
Assert.NotNull(retrieved);
|
|
||||||
Assert.Equal(key.ToBytes(), retrieved!.ToBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetPrivateKey_fuori_range_restituisce_null()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
|
||||||
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
Assert.Null(account.GetPrivateKey(false, 99));
|
|
||||||
Assert.Null(account.GetPrivateKey(true, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Account_watch_only_se_nessuna_chiave_privata()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
|
||||||
var account = new ImportedKeyAccount(
|
|
||||||
[(addr, (Key?)null)], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
Assert.True(account.IsWatchOnly);
|
|
||||||
Assert.Null(account.GetPrivateKey(false, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FixedAddresses_copre_tutti_gli_indirizzi()
|
|
||||||
{
|
|
||||||
var keys = Enumerable.Range(0, 3).Select(_ => GenerateKey()).ToList();
|
|
||||||
var entries = keys
|
|
||||||
.Select(k => (k.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network), (Key?)k))
|
|
||||||
.ToList();
|
|
||||||
var account = new ImportedKeyAccount(entries, ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
var fixed_ = account.FixedAddresses!;
|
|
||||||
Assert.Equal(3, fixed_.Count);
|
|
||||||
for (var i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
Assert.Equal(entries[i].Item1.ToString(), fixed_[i].Address.ToString());
|
|
||||||
Assert.False(fixed_[i].IsChange);
|
|
||||||
Assert.Equal(i, fixed_[i].Index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WalletLoader.NewFromWif ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromWif_crea_account_con_indirizzi_corretti()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var wif = ToWif(key);
|
|
||||||
var (doc, account) = WalletLoader.NewFromWif([wif], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
Assert.Null(doc.Mnemonic);
|
|
||||||
Assert.NotNull(doc.WifKeys);
|
|
||||||
Assert.Single(doc.WifKeys!);
|
|
||||||
Assert.False(doc.IsWatchOnly);
|
|
||||||
Assert.Equal(ScriptKind.NativeSegwit.ToString(), doc.ScriptKind);
|
|
||||||
|
|
||||||
var expected = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network).ToString();
|
|
||||||
Assert.Equal(expected, account.GetReceiveAddress(0).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromWif_roundtrip_persiste_e_ricarica()
|
|
||||||
{
|
|
||||||
var key = GenerateKey();
|
|
||||||
var wif = ToWif(key);
|
|
||||||
var (doc, original) = WalletLoader.NewFromWif([wif], ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
var reloaded = WalletLoader.ToAccount(doc);
|
|
||||||
Assert.IsType<ImportedKeyAccount>(reloaded);
|
|
||||||
Assert.Equal(
|
|
||||||
original.GetReceiveAddress(0).ToString(),
|
|
||||||
reloaded.GetReceiveAddress(0).ToString());
|
|
||||||
Assert.False(reloaded.IsWatchOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromWif_wif_invalido_lancia_eccezione()
|
|
||||||
{
|
|
||||||
Assert.Throws<InvalidDataException>(
|
|
||||||
() => WalletLoader.NewFromWif(["not-a-wif"], ScriptKind.NativeSegwit, Profile));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WalletLoader.NewFromXpub ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromXpub_produce_account_watch_only()
|
|
||||||
{
|
|
||||||
// Create an HD account, export the zpub, re-import as watch-only xpub.
|
|
||||||
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
||||||
null, ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
var zpub = hdFull.ToSlip132();
|
|
||||||
var (doc, woAccount) = WalletLoader.NewFromXpub(zpub, Profile);
|
|
||||||
|
|
||||||
Assert.True(woAccount.IsWatchOnly);
|
|
||||||
Assert.Null(doc.Mnemonic);
|
|
||||||
Assert.Equal(
|
|
||||||
hdFull.GetReceiveAddress(0).ToString(),
|
|
||||||
woAccount.GetReceiveAddress(0).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- WalletLoader.NewFromXprv ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromXprv_produce_account_spendibile()
|
|
||||||
{
|
|
||||||
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
||||||
null, ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
var zprv = hdFull.ToSlip132Private();
|
|
||||||
var (doc, xprvAccount) = WalletLoader.NewFromXprv(zprv, Profile);
|
|
||||||
|
|
||||||
Assert.False(xprvAccount.IsWatchOnly);
|
|
||||||
Assert.NotNull(doc.AccountXprv);
|
|
||||||
Assert.Null(doc.Mnemonic);
|
|
||||||
Assert.Equal(
|
|
||||||
hdFull.GetReceiveAddress(0).ToString(),
|
|
||||||
xprvAccount.GetReceiveAddress(0).ToString());
|
|
||||||
Assert.NotNull(xprvAccount.GetPrivateKey(false, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromXprv_roundtrip_persiste_e_ricarica()
|
|
||||||
{
|
|
||||||
var (_, hdFull) = WalletLoader.NewFromMnemonic(
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
||||||
null, ScriptKind.NativeSegwit, Profile);
|
|
||||||
|
|
||||||
var zprv = hdFull.ToSlip132Private();
|
|
||||||
var (doc, _) = WalletLoader.NewFromXprv(zprv, Profile);
|
|
||||||
|
|
||||||
var reloaded = WalletLoader.ToAccount(doc);
|
|
||||||
Assert.IsType<HdAccount>(reloaded);
|
|
||||||
Assert.False(reloaded.IsWatchOnly);
|
|
||||||
Assert.Equal(
|
|
||||||
hdFull.GetReceiveAddress(0).ToString(),
|
|
||||||
reloaded.GetReceiveAddress(0).ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@ public class TransactionFactoryTests
|
|||||||
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fake transaction that credits <paramref name="sats"/> to the receiving/0 address.</summary>
|
/// <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)
|
private static (List<CachedUtxo>, Dictionary<string, Transaction>) Fund(HdAccount account, long sats)
|
||||||
{
|
{
|
||||||
var funding = Net.CreateTransaction();
|
var funding = Net.CreateTransaction();
|
||||||
@@ -51,17 +51,17 @@ public class TransactionFactoryTests
|
|||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||||
|
|
||||||
Assert.True(built.Signed);
|
Assert.True(built.Signed);
|
||||||
// Output: recipient + change.
|
// Output: destinatario + change.
|
||||||
Assert.Equal(2, built.Transaction.Outputs.Count);
|
Assert.Equal(2, built.Transaction.Outputs.Count);
|
||||||
Assert.Contains(built.Transaction.Outputs,
|
Assert.Contains(built.Transaction.Outputs,
|
||||||
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
||||||
|
|
||||||
// Fee consistent with the requested rate (±20% for estimation rounding).
|
// Fee coerente col rate richiesto (±20% per gli arrotondamenti di stima).
|
||||||
var vsize = built.Transaction.GetVirtualSize();
|
var vsize = built.Transaction.GetVirtualSize();
|
||||||
var expected = vsize * 2;
|
var expected = vsize * 2;
|
||||||
Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
|
Assert.InRange(built.Fee.Satoshi, expected * 0.8, expected * 1.5);
|
||||||
|
|
||||||
// RBF enabled (§6.6).
|
// RBF abilitato (§6.6).
|
||||||
Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
|
Assert.All(built.Transaction.Inputs, i => Assert.True(i.Sequence < Sequence.Final));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +96,12 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var (utxos, txs) = Fund(account, 1_000_000);
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
utxos[0].Height = 0; // in mempool: visible in pending balance, not spendable
|
utxos[0].Height = 0; // in mempool: visibile nel saldo pending, non spendibile
|
||||||
|
|
||||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||||
Assert.Contains("pending confirmation", ex.Message);
|
Assert.Contains("in attesa di conferma", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -109,7 +109,7 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var (utxos, txs) = Fund(account, 1_000_000);
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
utxos[0].Frozen = true; // frozen (§6.2)
|
utxos[0].Frozen = true; // freeze (§6.2)
|
||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
@@ -130,8 +130,8 @@ public class TransactionFactoryTests
|
|||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||||
|
|
||||||
Assert.False(built.Signed);
|
Assert.False(built.Signed);
|
||||||
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
// Il flusso air-gapped (§6.5): la PSBT della macchina online si firma
|
||||||
// offline with the keys and then finalised.
|
// offline con le chiavi e si finalizza.
|
||||||
var psbt = built.Psbt;
|
var psbt = built.Psbt;
|
||||||
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
|
psbt.SignWithKeys(full.GetExtPrivateKey(false, 0));
|
||||||
psbt.Finalize();
|
psbt.Finalize();
|
||||||
@@ -157,7 +157,7 @@ public class TransactionFactoryTests
|
|||||||
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
Assert.False(CoinAmount.TryParseCoins("-1", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 PLM = 150_000_000 sat, expressed in each unit (§8).
|
// 1.5 PLM = 150_000_000 sat, espressi in ciascuna unità (§8).
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("PLM", "1.50000000 PLM", "1.5")]
|
[InlineData("PLM", "1.50000000 PLM", "1.5")]
|
||||||
[InlineData("mPLM", "1500.00000 mPLM", "1500")]
|
[InlineData("mPLM", "1500.00000 mPLM", "1500")]
|
||||||
@@ -197,7 +197,7 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
File.WriteAllText(path, "{ rotto ");
|
File.WriteAllText(path, "{ rotto ");
|
||||||
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
|
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
|
||||||
Assert.Equal("en", loaded.Language);
|
Assert.Equal("it", loaded.Language);
|
||||||
Assert.Equal("PLM", loaded.Unit);
|
Assert.Equal("PLM", loaded.Unit);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
using System;
|
|
||||||
using PalladiumWallet.Core.Chain;
|
|
||||||
using PalladiumWallet.Core.Crypto;
|
|
||||||
using PalladiumWallet.Core.Storage;
|
|
||||||
using PalladiumWallet.Core.Wallet;
|
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests.Wallet;
|
|
||||||
|
|
||||||
public class WalletLoaderTests
|
|
||||||
{
|
|
||||||
private const string ValidMnemonic =
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
||||||
|
|
||||||
private const string ValidMnemonic24 =
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " +
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
|
|
||||||
|
|
||||||
// ---- NewFromMnemonic ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_crea_documento_con_rete_e_tipo_corretti()
|
|
||||||
{
|
|
||||||
var profile = ChainProfiles.Mainnet;
|
|
||||||
var (doc, account) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic, passphrase: null, ScriptKind.NativeSegwit, profile);
|
|
||||||
|
|
||||||
Assert.Equal("mainnet", doc.Network);
|
|
||||||
Assert.Equal("NativeSegwit", doc.ScriptKind);
|
|
||||||
Assert.Equal(ValidMnemonic, doc.Mnemonic);
|
|
||||||
Assert.Null(doc.Passphrase);
|
|
||||||
Assert.NotEmpty(doc.AccountXpub);
|
|
||||||
Assert.NotEmpty(doc.MasterFingerprint);
|
|
||||||
Assert.False(doc.IsWatchOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_stessa_mnemonica_produce_stesso_xpub()
|
|
||||||
{
|
|
||||||
var profile = ChainProfiles.Mainnet;
|
|
||||||
var (doc1, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile);
|
|
||||||
var (doc2, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile);
|
|
||||||
|
|
||||||
Assert.Equal(doc1.AccountXpub, doc2.AccountXpub);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_passphrase_diversa_produce_xpub_diverso()
|
|
||||||
{
|
|
||||||
var profile = ChainProfiles.Mainnet;
|
|
||||||
var (doc1, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile);
|
|
||||||
var (doc2, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, "passphrase", ScriptKind.NativeSegwit, profile);
|
|
||||||
|
|
||||||
Assert.NotEqual(doc1.AccountXpub, doc2.AccountXpub);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_mnemonica_invalida_lancia_eccezione()
|
|
||||||
{
|
|
||||||
Assert.Throws<InvalidDataException>(() =>
|
|
||||||
WalletLoader.NewFromMnemonic("parole non valide foo bar", null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_mnemonica_24_parole_funziona()
|
|
||||||
{
|
|
||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
Assert.Equal("mainnet", doc.Network);
|
|
||||||
Assert.NotEmpty(doc.AccountXpub);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_tipi_script_producono_xpub_diversi()
|
|
||||||
{
|
|
||||||
var profile = ChainProfiles.Mainnet;
|
|
||||||
var (docNative, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, profile);
|
|
||||||
var (docWrapped, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.WrappedSegwit, profile);
|
|
||||||
var (docLegacy, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.Legacy, profile);
|
|
||||||
|
|
||||||
Assert.NotEqual(docNative.AccountXpub, docWrapped.AccountXpub);
|
|
||||||
Assert.NotEqual(docNative.AccountXpub, docLegacy.AccountXpub);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void NewFromMnemonic_reti_diverse_producono_xpub_diverse()
|
|
||||||
{
|
|
||||||
var (docMain, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
var (docTest, _) = WalletLoader.NewFromMnemonic(ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Testnet);
|
|
||||||
|
|
||||||
Assert.NotEqual(docMain.AccountXpub, docTest.AccountXpub);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- ToAccount ----
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToAccount_da_mnemonica_deriva_gli_stessi_indirizzi_ad_ogni_caricamento()
|
|
||||||
{
|
|
||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
var account1 = WalletLoader.ToAccount(doc);
|
|
||||||
var account2 = WalletLoader.ToAccount(doc);
|
|
||||||
|
|
||||||
Assert.Equal(
|
|
||||||
account1.GetReceiveAddress(0).ToString(),
|
|
||||||
account2.GetReceiveAddress(0).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToAccount_da_mnemonica_non_e_watch_only()
|
|
||||||
{
|
|
||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
var account = WalletLoader.ToAccount(doc);
|
|
||||||
Assert.False(account.IsWatchOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToAccount_da_xpub_e_watch_only_e_produce_stessi_indirizzi()
|
|
||||||
{
|
|
||||||
var (doc, accountSeed) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
|
|
||||||
// Build a watch-only document by removing the mnemonic.
|
|
||||||
var docWo = new WalletDocument
|
|
||||||
{
|
|
||||||
Network = doc.Network,
|
|
||||||
ScriptKind = doc.ScriptKind,
|
|
||||||
AccountPath = doc.AccountPath,
|
|
||||||
AccountXpub = doc.AccountXpub,
|
|
||||||
};
|
|
||||||
|
|
||||||
var accountWo = WalletLoader.ToAccount(docWo);
|
|
||||||
|
|
||||||
Assert.True(accountWo.IsWatchOnly);
|
|
||||||
Assert.Equal(
|
|
||||||
accountSeed.GetReceiveAddress(0).ToString(),
|
|
||||||
accountWo.GetReceiveAddress(0).ToString());
|
|
||||||
Assert.Equal(
|
|
||||||
accountSeed.GetReceiveAddress(9).ToString(),
|
|
||||||
accountWo.GetReceiveAddress(9).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToAccount_watch_only_non_espone_chiavi_private()
|
|
||||||
{
|
|
||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
var docWo = new WalletDocument
|
|
||||||
{
|
|
||||||
Network = doc.Network,
|
|
||||||
ScriptKind = doc.ScriptKind,
|
|
||||||
AccountPath = doc.AccountPath,
|
|
||||||
AccountXpub = doc.AccountXpub,
|
|
||||||
};
|
|
||||||
var account = WalletLoader.ToAccount(docWo);
|
|
||||||
Assert.True(account.IsWatchOnly);
|
|
||||||
Assert.Null(account.GetPrivateKey(false, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToAccount_rete_sconosciuta_lancia_eccezione()
|
|
||||||
{
|
|
||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
|
||||||
ValidMnemonic, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
|
||||||
var docBad = new WalletDocument
|
|
||||||
{
|
|
||||||
Network = "fantanet",
|
|
||||||
ScriptKind = doc.ScriptKind,
|
|
||||||
AccountPath = doc.AccountPath,
|
|
||||||
AccountXpub = doc.AccountXpub,
|
|
||||||
Mnemonic = doc.Mnemonic,
|
|
||||||
};
|
|
||||||
Assert.ThrowsAny<Exception>(() => WalletLoader.ToAccount(docBad));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- ProfileOf ----
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("mainnet", NetKind.Mainnet)]
|
|
||||||
[InlineData("testnet", NetKind.Testnet)]
|
|
||||||
[InlineData("regtest", NetKind.Regtest)]
|
|
||||||
[InlineData("Mainnet", NetKind.Mainnet)]
|
|
||||||
public void ProfileOf_riconosce_le_reti_note(string network, NetKind expected)
|
|
||||||
{
|
|
||||||
var doc = new WalletDocument { Network = network, ScriptKind = "NativeSegwit",
|
|
||||||
AccountPath = "84'/0'/0'", AccountXpub = "xpub" };
|
|
||||||
Assert.Equal(expected, WalletLoader.ProfileOf(doc).Kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||