Compare commits
49 Commits
data-dev
...
14ae39c5aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ae39c5aa | |||
| 3054a9baaa | |||
| 4dc37f1b42 | |||
| 47b6064964 | |||
| 002c854497 | |||
| 8b960458ee | |||
| 1b784a6c73 | |||
| 200c12651b | |||
| 5f983ca84e | |||
| 53ecd701c0 | |||
| bfee0dde03 | |||
| 8476eeb247 | |||
| 1914d9462b | |||
| 4c0edde4f7 | |||
| cfc48ff86f | |||
| bb9819ec96 | |||
| 8cdbd70966 | |||
| ee0b73dd52 | |||
| a8d48bedad | |||
| 5d061c6f21 | |||
| 2ddc0f920c | |||
| 58645bd7a7 | |||
| 008e9c395a | |||
| 70cce640aa | |||
| 0f8a764a44 | |||
| d66490b6be | |||
| b00c5821f2 | |||
| 658fcdbced | |||
| e94eaf7700 | |||
| a9ded6497a | |||
| 46aca513b8 | |||
| 6c01d7e6bd | |||
| 3cffba6e98 | |||
| 865daa137d | |||
| 4735490759 | |||
| 4c7e8696cb | |||
| 7727dbfddc | |||
| 58b86ad1af | |||
| 7f2759b2fc | |||
| a8a97f09b7 | |||
| f3bf4cf94a | |||
| 87e1c82610 | |||
| 51c87a7dc9 | |||
| 28cb4ce6ae | |||
| cf6e2d7654 | |||
| fe320584eb | |||
| 6fe31964e1 | |||
| 8ab8bbd8b3 | |||
| 71a604c8a5 |
@@ -91,6 +91,3 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
CLAUDE.md
|
||||
|
||||
blueprint.md
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Role
|
||||
|
||||
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.
|
||||
|
||||
## How to assist
|
||||
|
||||
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)
|
||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
```
|
||||
|
||||
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
||||
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
|
||||
by `MainWindow` on desktop and as the single-view root on Android.
|
||||
|
||||
**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.
|
||||
|
||||
## Commands
|
||||
|
||||
.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`
|
||||
- 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
|
||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
||||
|
||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
||||
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
||||
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
||||
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
||||
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
||||
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
||||
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
||||
|
||||
**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).
|
||||
|
||||
## Architecture (points that require reading multiple files)
|
||||
|
||||
- **Layers (§2):** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile (§3):** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW (§3, §7):** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints** (§7.3). Custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom (§19.2):** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like, §10); SPV sync with Merkle verification (§7.4); header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric (§6.5):** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Implementation state (§16 steps 1–7 + GUI)
|
||||
|
||||
`Core/Chain` network profiles; `Core/Crypto` BIP39/32/SLIP-132/`HdAccount`; `Core/Storage` JSON wallet v1 + AES-GCM (PBKDF2-SHA512) + data paths; `Core/Net` `ElectrumClient` (newline JSON-RPC over TCP/TLS, TOFU in `server-certs.json`, concurrent requests) + `ElectrumApi`; `Core/Spv` scripthash, mandatory Merkle verification on every confirmed tx, sync with gap limit; `Core/Wallet` `TransactionFactory` (RBF on, send-all, watch-only PSBT), `TransactionInspector` (tx detail from the server), `WalletLoader`. GUI: setup wizard, dashboard (history/send/receive with QR+copy/addresses/contacts), transaction detail, settings/server/help, multi-wallet. Runs on desktop and Android from one shared UI (debug apk builds end-to-end).
|
||||
|
||||
**TODO (§16 steps 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.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Davide Grilli
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -40,11 +44,21 @@ Global
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {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}
|
||||
{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
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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,37 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 l'AppBuilder Android si configura nella sottoclasse Application
|
||||
// (AvaloniaAndroidApplication<TApp>), non più nell'Activity. allowBackup=false:
|
||||
// il file wallet cifrato/seed non deve finire nei backup cloud automatici.
|
||||
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
||||
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;
|
||||
|
||||
// Registra lo scanner QR: apre ScannerActivity e ne attende il risultato.
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- DEVE derivare da Theme.AppCompat: AvaloniaActivity estende AppCompatActivity,
|
||||
che con un tema non-AppCompat lancia IllegalStateException all'avvio.
|
||||
Avalonia disegna comunque l'intera UI (NoActionBar). -->
|
||||
<style name="MyTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,218 @@
|
||||
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>
|
||||
/// Activity full-screen per la scansione QR via Camera2 + ZXing.Net.
|
||||
/// Torna a MainActivity via SetResult con l'extra "qr" = testo del codice.
|
||||
/// </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[] per 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!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Head Desktop (Windows/Linux): entry-point e pacchetti specifici del
|
||||
desktop. La UI vera vive nella libreria condivisa PalladiumWallet.App. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\App\Assets\logo.ico</ApplicationIcon>
|
||||
<AssemblyName>PalladiumWallet</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,31 +1,34 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.App.Views;
|
||||
|
||||
namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.App.Views;
|
||||
|
||||
namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
// Desktop (Windows/Linux): finestra classica. Mobile (Android): vista
|
||||
// singola. Stessa UI condivisa (MainView) e stesso ViewModel.
|
||||
switch (ApplicationLifetime)
|
||||
{
|
||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
||||
desktop.MainWindow = new MainWindow { DataContext = vm };
|
||||
break;
|
||||
case ISingleViewApplicationLifetime singleView:
|
||||
singleView.MainView = new MainView { DataContext = vm };
|
||||
break;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,166 +1,373 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace PalladiumWallet.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con
|
||||
/// Localizzazione UI: dizionario chiave → traduzioni per lingua, con
|
||||
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
|
||||
/// notifica "Item[]" e tutte le binding si aggiornano.
|
||||
/// il ViewModel sostituisce l'istanza così Avalonia rivaluta tutte le binding.
|
||||
/// </summary>
|
||||
public sealed class Loc : INotifyPropertyChanged
|
||||
public sealed class Loc
|
||||
{
|
||||
public static Loc Instance { get; } = new();
|
||||
public static Loc Instance { get; private set; } = new();
|
||||
|
||||
public static readonly string[] Languages = ["it", "en"];
|
||||
public static readonly string[] LanguageNames = ["Italiano", "English"];
|
||||
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
|
||||
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
|
||||
|
||||
public string Language { get; private set; } = "it";
|
||||
public string Language { get; private set; } = "en";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private Loc() { }
|
||||
private Loc(string language) { Language = language; }
|
||||
|
||||
public void SetLanguage(string language)
|
||||
/// <summary>
|
||||
/// Crea una nuova istanza con la lingua specificata e aggiorna il singleton
|
||||
/// usato da <see cref="Tr"/>. Il ViewModel assegna questa istanza alla
|
||||
/// propria property Loc così Avalonia vede un riferimento diverso e
|
||||
/// rivaluta tutte le binding {Binding Loc[chiave]}.
|
||||
/// </summary>
|
||||
internal static Loc SwitchTo(string language)
|
||||
{
|
||||
if (Language == language || System.Array.IndexOf(Languages, language) < 0)
|
||||
return;
|
||||
Language = language;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
|
||||
if (System.Array.IndexOf(Languages, language) < 0) language = "en";
|
||||
var loc = new Loc(language);
|
||||
Instance = loc;
|
||||
return loc;
|
||||
}
|
||||
|
||||
public string this[string key] =>
|
||||
Strings.TryGetValue(key, out var values)
|
||||
? values[Language == "en" ? 1 : 0]
|
||||
? values[System.Math.Max(0, System.Array.IndexOf(Languages, Language))]
|
||||
: key;
|
||||
|
||||
public static string Tr(string key) => Instance[key];
|
||||
|
||||
private static readonly Dictionary<string, string[]> Strings = new()
|
||||
{
|
||||
// Menu
|
||||
["menu.file"] = ["_File", "_File"],
|
||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"],
|
||||
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet"],
|
||||
["menu.net"] = ["_Rete", "_Network"],
|
||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"],
|
||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"],
|
||||
["menu.settings"] = ["_Impostazioni", "_Settings"],
|
||||
["settings.unit.short"] = ["Unità", "Unit"],
|
||||
// Menu it en es fr pt de
|
||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
|
||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
|
||||
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…", "Abrir wallet desde archivo…", "Ouvrir le wallet depuis un fichier…", "Abrir carteira de arquivo…", "Wallet aus Datei öffnen…"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
|
||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
|
||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
|
||||
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
|
||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
||||
["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)."],
|
||||
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
|
||||
|
||||
// Wizard
|
||||
["wiz.net"] = ["Rete:", "Network:"],
|
||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"],
|
||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"],
|
||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"],
|
||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet"],
|
||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"],
|
||||
["wiz.open.ok"] = ["Apri", "Open"],
|
||||
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"],
|
||||
["wiz.seed.warning"] = [
|
||||
["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.data.info"] = [
|
||||
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
|
||||
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
|
||||
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
|
||||
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
|
||||
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
|
||||
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
|
||||
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
|
||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
||||
["wiz.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.",
|
||||
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable."],
|
||||
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next"],
|
||||
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"],
|
||||
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"],
|
||||
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"],
|
||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"],
|
||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"],
|
||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"],
|
||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password"],
|
||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"],
|
||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet"],
|
||||
["wiz.back"] = ["Indietro", "Back"],
|
||||
["wiz.next"] = ["Avanti", "Next"],
|
||||
"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.",
|
||||
"É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.",
|
||||
"Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
|
||||
"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.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.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"],
|
||||
["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.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
||||
["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.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
||||
["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.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
|
||||
["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)"],
|
||||
|
||||
// Messaggi di errore import
|
||||
["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)."],
|
||||
["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."],
|
||||
["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."],
|
||||
["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."],
|
||||
|
||||
// Pannello wallet
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet"],
|
||||
["wallet.server"] = ["Server:", "Server:"],
|
||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"],
|
||||
["wallet.discover"] = ["Cerca altri server", "Discover servers"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs"],
|
||||
["tab.receive"] = ["Ricevi", "Receive"],
|
||||
["tab.history"] = ["Storico", "History"],
|
||||
["tab.addresses"] = ["Indirizzi", "Addresses"],
|
||||
["tab.send"] = ["Invia", "Send"],
|
||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"],
|
||||
["receive.hint"] = [
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
||||
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
||||
["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.",
|
||||
"Payments received here will appear in the history at the next synchronization."],
|
||||
["addr.type"] = ["Tipo", "Type"],
|
||||
["addr.index"] = ["Indice", "Index"],
|
||||
["addr.address"] = ["Indirizzo", "Address"],
|
||||
["addr.balance"] = ["Saldo", "Balance"],
|
||||
["addr.receive"] = ["ricezione", "receive"],
|
||||
["addr.change"] = ["change", "change"],
|
||||
["send.to"] = ["Indirizzo destinatario", "Recipient address"],
|
||||
["send.amount"] = ["Importo", "Amount"],
|
||||
["send.all"] = ["Invia tutto", "Send all"],
|
||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"],
|
||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction"],
|
||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"],
|
||||
"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.",
|
||||
"Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
|
||||
"Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
|
||||
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."],
|
||||
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"],
|
||||
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
|
||||
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
||||
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
|
||||
["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"],
|
||||
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
|
||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
|
||||
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
|
||||
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
|
||||
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||
|
||||
// Storico → dettaglio transazione
|
||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"],
|
||||
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"],
|
||||
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"],
|
||||
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
|
||||
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"],
|
||||
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"],
|
||||
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
|
||||
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
|
||||
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
|
||||
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
|
||||
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"],
|
||||
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"],
|
||||
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"],
|
||||
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"],
|
||||
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
|
||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
|
||||
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
|
||||
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
|
||||
["tx.verified"] = ["Verifica SPV", "SPV verification", "Verificación SPV", "Vérification SPV", "Verificação SPV", "SPV-Prüfung"],
|
||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
||||
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
|
||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
||||
["send.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"],
|
||||
|
||||
// Stato connessione
|
||||
["conn.none"] = ["non connesso", "not connected"],
|
||||
["conn.disconnected"] = ["disconnesso", "disconnected"],
|
||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…"],
|
||||
["conn.error"] = ["errore di connessione", "connection error"],
|
||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed"],
|
||||
["conn.connectedto"] = ["connesso a", "connected to"],
|
||||
["conn.connectingto"] = ["connessione a", "connecting to"],
|
||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
||||
["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"],
|
||||
|
||||
// Messaggi di stato principali
|
||||
["msg.welcome.existing"] = [
|
||||
["msg.welcome.existing"] = [
|
||||
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
||||
"Found an existing wallet on this network: open it, or create another one."],
|
||||
["msg.welcome.new"] = [
|
||||
"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.",
|
||||
"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.",
|
||||
"Welcome: create a new wallet or restore from seed."],
|
||||
["msg.open.password"] = [
|
||||
"Welcome: create a new wallet or restore from seed.",
|
||||
"Bienvenido: crea un nuevo wallet o restaura desde semilla.",
|
||||
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
|
||||
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
|
||||
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."],
|
||||
["msg.open.password"] = [
|
||||
"Inserisci la password del file (lascia vuoto se non impostata).",
|
||||
"Enter the file password (leave empty if not set)."],
|
||||
["msg.seed.write"] = [
|
||||
"Enter the file password (leave empty if not set).",
|
||||
"Ingresa la contraseña del archivo (deja vacío si no establecida).",
|
||||
"Entrez le mot de passe du fichier (laisser vide si non défini).",
|
||||
"Digite a senha do arquivo (deixe vazio se não definida).",
|
||||
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."],
|
||||
["msg.seed.write"] = [
|
||||
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
||||
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet."],
|
||||
["msg.seed.retype"] = [
|
||||
"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.",
|
||||
"É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.",
|
||||
"Re-enter the 12 words to confirm you wrote them down."],
|
||||
["msg.seed.mismatch"] = [
|
||||
"Re-enter the 12 words to confirm you wrote them down.",
|
||||
"Reingresa las 12 palabras para confirmar que las has anotado.",
|
||||
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
|
||||
"Reinsira as 12 palavras para confirmar que as anotou.",
|
||||
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."],
|
||||
["msg.seed.mismatch"] = [
|
||||
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
||||
"The words do not match: check what you wrote on paper."],
|
||||
["msg.words.enter"] = [
|
||||
"The words do not match: check what you wrote on paper.",
|
||||
"Las palabras no coinciden: revisa lo que escribiste en papel.",
|
||||
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
|
||||
"As palavras não correspondem: verifique o que escreveu no papel.",
|
||||
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."],
|
||||
["msg.words.enter"] = [
|
||||
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
||||
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces)."],
|
||||
["msg.words.invalid"] = [
|
||||
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
|
||||
"Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
|
||||
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
|
||||
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
|
||||
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."],
|
||||
["msg.words.invalid"] = [
|
||||
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
||||
"Invalid mnemonic (wrong words or checksum): check again."],
|
||||
["msg.passphrase.info"] = [
|
||||
"Invalid mnemonic (wrong words or checksum): check again.",
|
||||
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
|
||||
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
|
||||
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
|
||||
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."],
|
||||
["msg.passphrase.info"] = [
|
||||
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
|
||||
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip."],
|
||||
["msg.password.info"] = [
|
||||
"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.",
|
||||
"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.",
|
||||
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file."],
|
||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password."],
|
||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…"],
|
||||
["msg.synced"] = ["Sincronizzato", "Synchronized"],
|
||||
["msg.synced.detail"] = [
|
||||
"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.",
|
||||
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
|
||||
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
|
||||
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
|
||||
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
|
||||
["msg.password.required"] = [
|
||||
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
|
||||
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
|
||||
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
|
||||
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
|
||||
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
|
||||
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
|
||||
["msg.password.mismatch"] = [
|
||||
"Le due password non coincidono.",
|
||||
"The two passwords do not match.",
|
||||
"Las dos contraseñas no coinciden.",
|
||||
"Les deux mots de passe ne correspondent pas.",
|
||||
"As duas senhas não coincidem.",
|
||||
"Die beiden Passwörter stimmen nicht überein."],
|
||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
|
||||
["msg.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.",
|
||||
"SPV-verified transactions. Real-time updates active."],
|
||||
["msg.height"] = ["altezza", "height"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."],
|
||||
["msg.certreset"] = [
|
||||
"SPV-verified transactions. Real-time updates active.",
|
||||
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
|
||||
"transactions vérifiées SPV. Mises à jour en temps réel actives.",
|
||||
"transações verificadas SPV. Atualizações em tempo real ativas.",
|
||||
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
|
||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||
["msg.certreset"] = [
|
||||
"Certificati SSL azzerati: riprova la connessione.",
|
||||
"SSL certificates cleared: retry the connection."],
|
||||
["msg.error"] = ["Errore", "Error"],
|
||||
"SSL certificates cleared: retry the connection.",
|
||||
"Certificados SSL restablecidos: reintenta la conexión.",
|
||||
"Certificats SSL réinitialisés : réessayez la connexion.",
|
||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
||||
|
||||
// Contatti
|
||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
|
||||
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
|
||||
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
|
||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
||||
|
||||
// Finestra impostazioni
|
||||
["settings.title"] = ["Impostazioni", "Settings"],
|
||||
["settings.language"] = ["Lingua", "Language"],
|
||||
["settings.unit"] = ["Unità degli importi", "Amount unit"],
|
||||
["settings.ok"] = ["Salva", "Save"],
|
||||
["settings.cancel"] = ["Annulla", "Cancel"],
|
||||
["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…"],
|
||||
|
||||
// Finestra server
|
||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
||||
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
|
||||
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Libreria UI condivisa: il codice Avalonia (App, Views, ViewModels, Loc,
|
||||
Assets) usato sia dall'head Desktop sia dall'head Android. Gli head
|
||||
portano l'entry-point e i pacchetti specifici di piattaforma. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>0.9.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PalladiumWallet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Seam per servizi specifici della piattaforma (Android/desktop).
|
||||
/// Il head Android imposta i delegate in OnCreate; desktop li lascia null.
|
||||
/// </summary>
|
||||
public static class PlatformServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Apre lo scanner QR nativo e restituisce il testo raw del codice,
|
||||
/// oppure null se l'utente annulla o lo scanner non è disponibile.
|
||||
/// </summary>
|
||||
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// true (mobile) → Dock.Bottom — tab strip in basso, standard Android.
|
||||
/// false (desktop) → Dock.Top — comportamento predefinito Avalonia.
|
||||
/// </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();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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>Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo.</summary>
|
||||
[ObservableProperty]
|
||||
private ContactEntry? sendToContact;
|
||||
|
||||
partial void OnSendToContactChanged(ContactEntry? value)
|
||||
{
|
||||
if (value is not null)
|
||||
SendTo = value.Address;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContactName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContactAddress = "";
|
||||
|
||||
[RelayCommand]
|
||||
private void AddContact()
|
||||
{
|
||||
var name = NewContactName.Trim();
|
||||
var addr = NewContactAddress.Trim();
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return;
|
||||
Contacts.Add(new ContactEntry(name, addr));
|
||||
NewContactName = NewContactAddress = "";
|
||||
PersistContacts();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSelectedContact()
|
||||
{
|
||||
if (SelectedContactInList is { } c)
|
||||
{
|
||||
Contacts.Remove(c);
|
||||
SelectedContactInList = null;
|
||||
PersistContacts();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistContacts()
|
||||
{
|
||||
if (_doc is null || _walletPath is null) return;
|
||||
_doc.Contacts = Contacts
|
||||
.Select(c => new 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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
|
||||
{
|
||||
// ---- saldo e info wallet ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string balanceText = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string unconfirmedText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
// ---- indirizzo di ricezione e 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tab indirizzi ----
|
||||
|
||||
[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;
|
||||
|
||||
// ---- overlay dettaglio transazione ----
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- aggiorna display dal risultato della sync ----
|
||||
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
// Gestisce URI tipo "palladium:ADDRESS?amount=X" estraendo solo l'indirizzo
|
||||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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 e connessione ----
|
||||
|
||||
[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>
|
||||
/// Restituisce i server da provare in ordine: prima quello selezionato nella UI
|
||||
/// (o digitato manualmente), poi gli altri in KnownServers, evitando duplicati.
|
||||
/// </summary>
|
||||
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// 1. Server corrente (selezionato o digitato manualmente).
|
||||
if (!string.IsNullOrWhiteSpace(selectedHost))
|
||||
{
|
||||
var key = $"{selectedHost}:{selectedPort}";
|
||||
if (seen.Add(key))
|
||||
yield return (selectedHost, selectedPort);
|
||||
}
|
||||
// 2. Altri server noti, in ordine di lista.
|
||||
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)
|
||||
{
|
||||
// Salva le cache prima di distruggere il synchronizer: il nuovo
|
||||
// synchronizer userà un client diverso e deve essere ricreato,
|
||||
// ma i dati già scaricati vengono preservati via _doc.Cache.
|
||||
PersistPartialTxCache();
|
||||
_synchronizer = null;
|
||||
|
||||
// Prova tutti i server noti in ordine; parte da quello selezionato
|
||||
// e scorre la lista fino al primo che risponde.
|
||||
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");
|
||||
// Aggiorna la UI per riflettere il server effettivamente connesso.
|
||||
_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);
|
||||
// Ricarica dalla cache su disco: evita di riscaricale le tx già note
|
||||
// (fondamentale per wallet con migliaia di tx storiche — previene -101).
|
||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||
_synchronizer.PreloadCaches(
|
||||
_doc.Cache?.RawTxHex ?? [],
|
||||
_doc.Cache?.VerifiedAt ?? [],
|
||||
net);
|
||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
_resyncRequested = false;
|
||||
var result = await _synchronizer.SyncOnceAsync();
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
var (rawHex, verifiedAt) = _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,
|
||||
};
|
||||
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;
|
||||
// Salva le tx già scaricate: al retry il synchronizer riparte
|
||||
// da qui invece di ricominciare da zero (es. dopo -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>
|
||||
/// Salva nel SyncCache le tx e le prove di Merkle già accumulate dal synchronizer,
|
||||
/// anche se la sync non è ancora completa. Consente al retry successivo
|
||||
/// (o al riavvio dell'app) di riprendere senza riscaricale da zero.
|
||||
/// </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) = _synchronizer.ExportCaches(net);
|
||||
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||
return;
|
||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||
_doc.Cache.VerifiedAt = verifiedAt;
|
||||
WalletStore.Save(_doc, _walletPath, _password);
|
||||
}
|
||||
catch { /* non fatale: il prossimo salvataggio completo recupererà */ }
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (_client is { } client)
|
||||
{
|
||||
_client = null;
|
||||
_synchronizer = null;
|
||||
try { await client.DisposeAsync(); } catch { }
|
||||
}
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
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
|
||||
{
|
||||
// ---- wizard di setup
|
||||
|
||||
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 = "";
|
||||
|
||||
// Tipo di script rilevato durante la decodifica dell'xkey (per mostrarlo all'utente)
|
||||
[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;
|
||||
}
|
||||
// Valida e rileva il tipo: prova prima come xpub, poi come 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;
|
||||
}
|
||||
// Valida il WIF con un parsing anticipato.
|
||||
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>
|
||||
/// Costruisce il percorso file dal nome inserito dall'utente.
|
||||
/// Se il nome è vuoto genera un nome automatico (default, wallet-2, …).
|
||||
/// Rimuove i caratteri non validi per il 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))
|
||||
{
|
||||
// Nessun nome → nome automatico
|
||||
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 riprova la connessione anche se il primo tentativo fallisce
|
||||
_syncFailed = true; // forza la prima sync automatica
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -21,96 +19,87 @@ namespace PalladiumWallet.App.ViewModels;
|
||||
/// <summary>Riga dello storico transazioni per la vista.</summary>
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
||||
|
||||
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary>
|
||||
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx);
|
||||
/// <summary>Riga della vista indirizzi con chiavi e derivation path pre-calcolati.</summary>
|
||||
public sealed record AddressRow(
|
||||
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
|
||||
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
|
||||
{
|
||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||
}
|
||||
|
||||
/// <summary>Dati completi di un indirizzo passati alla finestra di dettaglio.</summary>
|
||||
public sealed record AddressInfo(
|
||||
Loc Loc,
|
||||
string Address, string DerivPath, string PubKey, string PrivKey)
|
||||
{
|
||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||
}
|
||||
|
||||
/// <summary>Contatto in rubrica: nome + indirizzo blockchain.</summary>
|
||||
public sealed record ContactEntry(string Name, string Address);
|
||||
|
||||
/// <summary>Voce della lista di scelta wallet: nome file + percorso completo.</summary>
|
||||
public sealed record WalletFileEntry(string Name, string Path);
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
|
||||
/// pannello di setup (crea/ripristina/apri) e pannello wallet
|
||||
/// (saldo, ricevi, storico, invia, server).
|
||||
/// ViewModel unico dell'applicazione (wizard + dashboard). Suddiviso in file
|
||||
/// partial per area: Wizard, Settings, Sync, Send, Contacts, Receive.
|
||||
/// </summary>
|
||||
public partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
// ---- stato sessione wallet ----
|
||||
private WalletDocument? _doc;
|
||||
private HdAccount? _account;
|
||||
private IWalletAccount? _account;
|
||||
private string? _walletPath;
|
||||
private string? _password;
|
||||
private WalletLock? _walletLock;
|
||||
|
||||
// ---- rete ----
|
||||
private ElectrumClient? _client;
|
||||
private WalletSynchronizer? _synchronizer;
|
||||
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
||||
private BuiltTransaction? _pendingSend;
|
||||
|
||||
/// <summary>Notifica arrivata durante una sync: si risincronizza appena finita.</summary>
|
||||
private bool _resyncRequested;
|
||||
|
||||
/// <summary>Configurazione globale (§8): lingua e unità.</summary>
|
||||
// ---- invio ----
|
||||
private BuiltTransaction? _pendingSend;
|
||||
|
||||
// ---- dettaglio transazione ----
|
||||
private CancellationTokenSource? _txDetailsCts;
|
||||
|
||||
// ---- configurazione e localizzazione ----
|
||||
private AppConfig _config = AppConfig.Load();
|
||||
private Loc _loc = Loc.Instance;
|
||||
public Loc Loc => _loc;
|
||||
|
||||
/// <summary>Stringhe localizzate, bindabili da XAML come Loc[chiave].</summary>
|
||||
public Loc Loc => Loc.Instance;
|
||||
|
||||
/// <summary>Unità corrente per il campo importo del pannello Invia.</summary>
|
||||
public string UnitLabel => _config.Unit;
|
||||
|
||||
public AppConfig CurrentConfig => _config;
|
||||
|
||||
// Spunte del menu Impostazioni (ToggleType Radio).
|
||||
public bool IsLangIt => _config.Language == "it";
|
||||
public bool IsLangEn => _config.Language == "en";
|
||||
public bool IsUnitPlm => _config.Unit == "PLM";
|
||||
public bool IsUnitMilli => _config.Unit == "mPLM";
|
||||
public bool IsUnitMicro => _config.Unit == "µPLM";
|
||||
public bool IsUnitSat => _config.Unit == "sat";
|
||||
|
||||
[RelayCommand]
|
||||
private void SetLanguage(string language)
|
||||
{
|
||||
_config.Language = language;
|
||||
ApplySettings(_config);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SetUnit(string unit)
|
||||
{
|
||||
_config.Unit = unit;
|
||||
ApplySettings(_config);
|
||||
}
|
||||
|
||||
/// <summary>Applica e persiste le impostazioni (§8).</summary>
|
||||
public void ApplySettings(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_config.Save();
|
||||
Loc.SetLanguage(config.Language);
|
||||
OnPropertyChanged(nameof(UnitLabel));
|
||||
OnPropertyChanged(nameof(IsLangIt));
|
||||
OnPropertyChanged(nameof(IsLangEn));
|
||||
OnPropertyChanged(nameof(IsUnitPlm));
|
||||
OnPropertyChanged(nameof(IsUnitMilli));
|
||||
OnPropertyChanged(nameof(IsUnitMicro));
|
||||
OnPropertyChanged(nameof(IsUnitSat));
|
||||
ApplyCache(_doc?.Cache);
|
||||
StatusMessage = Loc.Tr("msg.settings.saved");
|
||||
}
|
||||
|
||||
/// <summary>Formatta un importo nell'unità scelta nelle impostazioni.</summary>
|
||||
private string Fmt(long sats, bool withLabel = true) =>
|
||||
CoinAmount.FormatIn(sats, _config.Unit, withLabel);
|
||||
|
||||
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
|
||||
// ---- wizard ----
|
||||
private string? _pendingOpenPath;
|
||||
|
||||
/// <summary>Dopo la prima connessione riuscita il timer riconnette da solo.</summary>
|
||||
// ---- keep-alive ----
|
||||
private bool _autoReconnect;
|
||||
|
||||
private bool _syncFailed;
|
||||
private readonly DispatcherTimer _keepAliveTimer;
|
||||
|
||||
// ---- selezione rete e stato pannelli ----
|
||||
// ---- server UI sync ----
|
||||
private bool _syncingServerFields;
|
||||
|
||||
public string[] Networks { get; } = ["mainnet", "testnet", "regtest"];
|
||||
// ---- proprietà di app ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string selectedNetwork = "mainnet";
|
||||
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 su desktop; false su Android/iOS — nasconde le funzioni legate al filesystem libero.</summary>
|
||||
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
|
||||
|
||||
public bool IsMobile => !IsDesktop;
|
||||
|
||||
public string UnitLabel => _config.Unit;
|
||||
public AppConfig CurrentConfig => _config;
|
||||
|
||||
// ---- stato pannelli ----
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
||||
@@ -118,138 +107,68 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
public bool IsSetupVisible => !IsWalletOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool walletFileExists;
|
||||
|
||||
[ObservableProperty]
|
||||
private string statusMessage = "";
|
||||
|
||||
// ---- wizard di setup (§15): un passo alla volta ----
|
||||
|
||||
public const string StepStart = "start";
|
||||
public const string StepOpen = "open";
|
||||
public const string StepShowSeed = "show-seed";
|
||||
public const string StepConfirmSeed = "confirm-seed";
|
||||
public const string StepWords = "words";
|
||||
public const string StepPassphrase = "passphrase";
|
||||
public const string StepPassword = "password";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepStart))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
||||
private string setupStep = StepStart;
|
||||
private int selectedTabIndex;
|
||||
|
||||
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;
|
||||
[RelayCommand]
|
||||
private void SelectTab(string index) => SelectedTabIndex = int.Parse(index);
|
||||
|
||||
/// <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;
|
||||
// ---- collections per la dashboard ----
|
||||
|
||||
public ObservableCollection<HistoryRow> History { get; } = [];
|
||||
|
||||
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
||||
|
||||
// ---- pannello invia ----
|
||||
// ---- helpers ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendTo = "";
|
||||
private NetKind Net => NetKind.Mainnet;
|
||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendAmount = "";
|
||||
private string Fmt(long sats, bool withLabel = true) =>
|
||||
CoinAmount.FormatIn(sats, _config.Unit, withLabel);
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendFeeRate = "1";
|
||||
private string KeyWif(bool isChange, int index)
|
||||
{
|
||||
if (_account is null or { IsWatchOnly: true }) return "";
|
||||
try
|
||||
{
|
||||
return _account.GetPrivateKey(isChange, index)
|
||||
?.GetWif(PalladiumNetworks.For(Net)).ToString() ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool sendAll;
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendPreview = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasPendingSend;
|
||||
// ---- costruttore ----
|
||||
|
||||
public MainWindowViewModel()
|
||||
{
|
||||
RefreshSetupState();
|
||||
// Aggiornamenti continui (§9): ping periodico per tenere viva la
|
||||
// connessione e accorgersi subito della caduta; se cade, riconnette
|
||||
// e risincronizza da solo. Le notifiche push restano la via principale.
|
||||
_keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) };
|
||||
_loc = Loc.SwitchTo(_config.Language);
|
||||
if (!AppPaths.IsDataLocationConfigured())
|
||||
SetupStep = StepDataLocation;
|
||||
else
|
||||
RefreshSetupState();
|
||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||
_keepAliveTimer.Start();
|
||||
}
|
||||
|
||||
private async Task KeepAliveTickAsync()
|
||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||
{
|
||||
if (!IsWalletOpen || IsSyncing)
|
||||
if (IsSyncing)
|
||||
return;
|
||||
if (_client is { IsConnected: true })
|
||||
{
|
||||
try
|
||||
// Se il wallet è aperto e l'ultima sync è fallita, riprova automaticamente.
|
||||
if (_syncFailed && _account is not null)
|
||||
{
|
||||
await _client.PingAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// La caduta viene gestita dall'evento Disconnected.
|
||||
await ConnectAndSync();
|
||||
return;
|
||||
}
|
||||
try { await _client.PingAsync(); }
|
||||
catch { }
|
||||
}
|
||||
else if (_autoReconnect)
|
||||
{
|
||||
@@ -258,506 +177,32 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
// ---- ciclo di vita wallet ----
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseWallet()
|
||||
{
|
||||
_walletLock?.Dispose();
|
||||
_walletLock = null;
|
||||
_ = _client?.DisposeAsync().AsTask();
|
||||
_client = null;
|
||||
_synchronizer = null;
|
||||
_autoReconnect = false;
|
||||
_resyncRequested = false;
|
||||
_syncFailed = false;
|
||||
_doc = null;
|
||||
_account = null;
|
||||
_lastTransactions = null;
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
History.Clear();
|
||||
Contacts.Clear();
|
||||
SelectedContactInList = null;
|
||||
SendToContact = null;
|
||||
SelectedAddressRow = null;
|
||||
IsWalletOpen = false;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
RefreshSetupState();
|
||||
}
|
||||
|
||||
private (string Host, int Port) ParseServer()
|
||||
{
|
||||
var parts = ServerInput.Trim().Split(':');
|
||||
var host = parts[0];
|
||||
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
|
||||
? p
|
||||
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
|
||||
return (host, port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// bool → pennello: gli indirizzi del wallet (input/output "nostri") sono
|
||||
/// evidenziati in verde, gli altri usano il colore di testo predefinito.
|
||||
/// </summary>
|
||||
public sealed class MineColorConverter : IValueConverter
|
||||
{
|
||||
public static readonly MineColorConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true ? Brushes.MediumSeaGreen : AvaloniaProperty.UnsetValue;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>Riga input/output per le tabelle della finestra di dettaglio.</summary>
|
||||
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel della finestra di dettaglio transazione: prende un
|
||||
/// <see cref="TransactionDetails"/> (assemblato dal server) e ne ricava tutte
|
||||
/// le stringhe già formattate e localizzate per la vista. È di sola lettura.
|
||||
/// </summary>
|
||||
public sealed class TransactionDetailsViewModel
|
||||
{
|
||||
private readonly string _unit;
|
||||
|
||||
public Loc Loc { get; }
|
||||
|
||||
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
|
||||
{
|
||||
Loc = loc;
|
||||
_unit = unit;
|
||||
|
||||
Txid = d.Txid;
|
||||
StatusText = BuildStatus(d, loc);
|
||||
DateText = d.BlockTime is { } t
|
||||
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
||||
: loc["tx.mempool"];
|
||||
|
||||
var counterparties = d.CounterpartyAddresses;
|
||||
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
|
||||
CounterpartyText = counterparties.Count > 0
|
||||
? string.Join(Environment.NewLine, counterparties)
|
||||
: "—";
|
||||
|
||||
// Debito (uscita verso terzi) o Credito (entrata netta sui nostri output).
|
||||
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
|
||||
AmountText = d.IsIncoming
|
||||
? Signed(d.ReceivedSats)
|
||||
: Signed(-d.SentToOthersSats);
|
||||
|
||||
FeeText = d.FeeSats is { } fee
|
||||
? (d.IsIncoming ? Abs(fee) : Signed(-fee))
|
||||
: "—";
|
||||
|
||||
NetText = Signed(d.NetSats);
|
||||
|
||||
TotalSizeText = $"{d.TotalSize} byte";
|
||||
VirtualSizeText = $"{d.VirtualSize} byte";
|
||||
FeeRateText = d.FeeRateSatPerVb is { } r
|
||||
? r.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " sat/vB"
|
||||
: "—";
|
||||
VersionText = d.Version.ToString();
|
||||
LockTimeText = d.LockTime.ToString();
|
||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||
VerifiedText = d.Verified ? "✓ SPV" : "—";
|
||||
|
||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||
i.IsCoinbase ? "coinbase" : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
|
||||
i.Address ?? "—",
|
||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||
i.IsMine)));
|
||||
|
||||
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
||||
$"#{o.Index}",
|
||||
o.Address ?? $"({o.ScriptType})",
|
||||
CoinAmount.FormatIn(o.AmountSats, unit),
|
||||
o.IsMine)));
|
||||
}
|
||||
|
||||
public string Txid { get; }
|
||||
public string StatusText { get; }
|
||||
public string DateText { get; }
|
||||
public string CounterpartyHeader { get; }
|
||||
public string CounterpartyText { get; }
|
||||
public string AmountHeader { get; }
|
||||
public string AmountText { get; }
|
||||
public string FeeText { get; }
|
||||
public string NetText { get; }
|
||||
public string TotalSizeText { get; }
|
||||
public string VirtualSizeText { get; }
|
||||
public string FeeRateText { get; }
|
||||
public string VersionText { get; }
|
||||
public string LockTimeText { get; }
|
||||
public string RbfText { get; }
|
||||
public string VerifiedText { get; }
|
||||
public ObservableCollection<TxIoRow> Inputs { get; }
|
||||
public ObservableCollection<TxIoRow> Outputs { get; }
|
||||
|
||||
private static string BuildStatus(TransactionDetails d, Loc loc)
|
||||
{
|
||||
if (d.Confirmations <= 0)
|
||||
return loc["tx.status.mempool"];
|
||||
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||
}
|
||||
|
||||
private string Signed(long sats)
|
||||
{
|
||||
var sign = sats > 0 ? "+" : sats < 0 ? "-" : "";
|
||||
return sign + CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
||||
}
|
||||
|
||||
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
||||
|
||||
private static string Shorten(string txid) =>
|
||||
txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
|
||||
namespace PalladiumWallet.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Vista radice dell'app, condivisa tra desktop (ospitata in <see cref="MainWindow"/>)
|
||||
/// e mobile (root single-view). Tutti gli overlay sono in-app, quindi non servono
|
||||
/// finestre separate. Le API legate al top-level (file picker, clipboard) si
|
||||
/// raggiungono via <see cref="TopLevel.GetTopLevel"/> perché un UserControl non le espone.
|
||||
/// </summary>
|
||||
public partial class MainView : UserControl
|
||||
{
|
||||
public MainView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
|
||||
|
||||
var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Apri file wallet",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
||||
vm.OpenFromPath(path);
|
||||
}
|
||||
|
||||
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
||||
if (lb.SelectedItem is not HistoryRow row) return;
|
||||
|
||||
// Overlay in-app: appare subito con lo spinner, i dati arrivano dal
|
||||
// server in background. Niente top-level window (lenta da aprire/chiudere).
|
||||
_ = vm.ShowTransactionDetailsAsync(row.Txid);
|
||||
}
|
||||
|
||||
private void OnTxDetailsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.CloseTransactionDetailsCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void OnAddressListTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
|
||||
return;
|
||||
vm.ShowAddressInfo(row);
|
||||
}
|
||||
|
||||
private void OnAddressListPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(null).Properties.IsRightButtonPressed) return;
|
||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
||||
|
||||
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>();
|
||||
if (item is not { DataContext: AddressRow row }) return;
|
||||
|
||||
lb.SelectedItem = row;
|
||||
vm.ShowAddressInfo(row);
|
||||
}
|
||||
|
||||
// Chiusura dell'overlay dettaglio indirizzo: click sullo sfondo scuro
|
||||
// (solo sullo sfondo, non sulla scheda) o tasto Esc.
|
||||
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.AddressInfo = null;
|
||||
}
|
||||
|
||||
private void OnServerOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsServerSettingsOpen = false;
|
||||
}
|
||||
|
||||
private async void OnChooseDataFolderClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
|
||||
|
||||
var folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Cartella dati Palladium Wallet",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
||||
vm.ApplyDataLocation(path);
|
||||
}
|
||||
|
||||
private async void OnCopyReceiveAddressClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.ReceiveAddress))
|
||||
return;
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||
{
|
||||
await clipboard.SetTextAsync(vm.ReceiveAddress);
|
||||
vm.NotifyAddressCopied();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsServerSettingsOpen = true;
|
||||
}
|
||||
|
||||
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsSettingsOpen = false;
|
||||
}
|
||||
|
||||
private void OnHelpOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsHelpOpen = false;
|
||||
}
|
||||
|
||||
// Esc (desktop) o tasto Back (Android) chiudono l'overlay in primo piano.
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
|
||||
{
|
||||
if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.AddressInfo is not null) { vm.AddressInfo = null; e.Handled = true; return; }
|
||||
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
}
|
||||
@@ -1,302 +1,19 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PalladiumWallet.App.ViewModels"
|
||||
xmlns:views="using:PalladiumWallet.App.Views"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
|
||||
x:Class="PalladiumWallet.App.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Icon="/Assets/logo.ico"
|
||||
Width="900" Height="620"
|
||||
Title="Palladium Wallet">
|
||||
Title="{Binding WindowTitle}">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ MENU (stile Electrum) ============ -->
|
||||
<Menu Grid.Row="0">
|
||||
<MenuItem Header="{Binding Loc[menu.file]}">
|
||||
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
|
||||
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
|
||||
IsEnabled="{Binding IsWalletOpen}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{Binding Loc[menu.net]}">
|
||||
<MenuItem Header="{Binding Loc[menu.net.discover]}" Command="{Binding DiscoverServersCommand}"/>
|
||||
<MenuItem Header="{Binding Loc[menu.net.resetcerts]}" Command="{Binding ResetCertificatesCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{Binding Loc[menu.settings]}">
|
||||
<MenuItem Header="{Binding Loc[settings.language]}">
|
||||
<MenuItem Header="Italiano" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangIt, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
|
||||
<MenuItem Header="English" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangEn, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{Binding Loc[settings.unit.short]}">
|
||||
<MenuItem Header="PLM" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
|
||||
<MenuItem Header="mPLM" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
|
||||
<MenuItem Header="µPLM" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
|
||||
<MenuItem Header="sat" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
<!-- Passo 1: scelta iniziale -->
|
||||
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding Loc[wiz.net]}" VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding Networks}"
|
||||
SelectedItem="{Binding SelectedNetwork}" MinWidth="140"/>
|
||||
</StackPanel>
|
||||
<Button Content="{Binding Loc[wiz.open.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
IsVisible="{Binding WalletFileExists}"
|
||||
Command="{Binding WizardStartOpenCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.new.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding WizardStartNewCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.restore.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding WizardStartRestoreCommand}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: password del wallet esistente -->
|
||||
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.open.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.open.placeholder]}"
|
||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.open.ok]}" Classes="accent"
|
||||
Command="{Binding OpenExistingCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: mostra il nuovo seed -->
|
||||
<StackPanel IsVisible="{Binding IsStepShowSeed}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.seed.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<Border BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Text="{Binding MnemonicInput}"
|
||||
FontFamily="monospace" FontSize="16" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
<TextBlock Foreground="Orange" TextWrapping="Wrap"
|
||||
Text="{Binding Loc[wiz.seed.warning]}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.seed.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromShowSeedCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: conferma del seed -->
|
||||
<StackPanel IsVisible="{Binding IsStepConfirmSeed}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.confirm.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.confirm.placeholder]}"
|
||||
AcceptsReturn="False" Text="{Binding ConfirmMnemonicInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromConfirmSeedCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: inserimento seed (ripristino) -->
|
||||
<StackPanel IsVisible="{Binding IsStepWords}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.words.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.words.placeholder]}"
|
||||
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromWordsCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: passphrase opzionale -->
|
||||
<StackPanel IsVisible="{Binding IsStepPassphrase}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.passphrase.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.passphrase.placeholder]}"
|
||||
Text="{Binding PassphraseInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromPassphraseCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo finale: password del file -->
|
||||
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
|
||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
|
||||
Command="{Binding CreateOrRestoreCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ PANNELLO WALLET ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
|
||||
|
||||
<!-- Testata: saldo + rete + chiudi -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
|
||||
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="{Binding Loc[wallet.close]}" VerticalAlignment="Top"
|
||||
Command="{Binding CloseWalletCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Server -->
|
||||
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
|
||||
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[wallet.server]}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1"
|
||||
ItemsSource="{Binding KnownServers}"
|
||||
SelectedItem="{Binding SelectedKnownServer}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
|
||||
IsChecked="{Binding UseSsl}" Margin="8,0"/>
|
||||
<Button Grid.Row="0" Grid.Column="3" Content="{Binding Loc[wallet.connect]}"
|
||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
|
||||
Spacing="8" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding ServerInput}" MinWidth="220"
|
||||
PlaceholderText="{Binding Loc[wallet.manual]}"/>
|
||||
<Button Content="{Binding Loc[wallet.discover]}"
|
||||
Command="{Binding DiscoverServersCommand}"/>
|
||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||
Command="{Binding ResetCertificatesCommand}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
|
||||
Orientation="Horizontal" Spacing="6" Margin="8,8,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse Width="10" Height="10" Fill="LimeGreen"
|
||||
IsVisible="{Binding IsConnected}"/>
|
||||
<Ellipse Width="10" Height="10" Fill="IndianRed"
|
||||
IsVisible="{Binding !IsConnected}"/>
|
||||
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Tab: Ricevi / Storico / Indirizzi / Invia -->
|
||||
<TabControl Grid.Row="2">
|
||||
<TabItem Header="{Binding Loc[tab.receive]}">
|
||||
<StackPanel Spacing="10" Margin="8">
|
||||
<TextBlock Text="{Binding Loc[receive.next]}"/>
|
||||
<SelectableTextBlock Text="{Binding ReceiveAddress}"
|
||||
FontFamily="monospace" FontSize="16"/>
|
||||
<TextBlock Text="{Binding Loc[receive.hint]}"
|
||||
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{Binding Loc[tab.history]}">
|
||||
<ListBox ItemsSource="{Binding History}" Margin="4">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HistoryRow">
|
||||
<Grid ColumnDefinitions="90,160,*,70">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||
FontFamily="monospace" FontSize="12"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{Binding Loc[tab.addresses]}">
|
||||
<Grid RowDefinitions="Auto,*" Margin="4">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
|
||||
</Grid>
|
||||
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AddressRow">
|
||||
<Grid ColumnDefinitions="90,60,*,140,60">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
|
||||
<SelectableTextBlock Grid.Column="2" Text="{Binding Indirizzo}"
|
||||
FontFamily="monospace" FontSize="13"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{Binding Loc[tab.send]}">
|
||||
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
|
||||
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
|
||||
FontFamily="monospace"/>
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
|
||||
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
|
||||
VerticalAlignment="Center" Margin="6,0" Foreground="Gray"/>
|
||||
<CheckBox Grid.Column="2" Content="{Binding Loc[send.all]}" Margin="10,0"
|
||||
IsChecked="{Binding SendAll}"/>
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[send.prepare]}" Command="{Binding PrepareSendCommand}"/>
|
||||
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||
Command="{Binding ConfirmSendCommand}"
|
||||
IsEnabled="{Binding HasPendingSend}"/>
|
||||
</StackPanel>
|
||||
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
|
||||
FontSize="13"/>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
|
||||
<!-- Barra di stato -->
|
||||
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
Padding="10,6">
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<views:MainView/>
|
||||
</Window>
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
|
||||
namespace PalladiumWallet.App.Views;
|
||||
|
||||
/// <summary>Finestra desktop: ospita <see cref="MainView"/> (la UI condivisa con mobile).</summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
|
||||
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm)
|
||||
return;
|
||||
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Apri file wallet",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
||||
vm.OpenFromPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -282,7 +282,7 @@ static int SaveWallet(string words, string[] o)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static (WalletDocument, HdAccount, string) OpenWallet(string[] o)
|
||||
static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
||||
{
|
||||
var path = WalletPath(o, Profile(o));
|
||||
if (!WalletStore.Exists(path))
|
||||
|
||||
@@ -29,6 +29,9 @@ public enum ScriptKind
|
||||
|
||||
/// <summary>P2WSH (multisig native).</summary>
|
||||
NativeSegwitMultisig,
|
||||
|
||||
/// <summary>P2TR key-path only (Taproot, BIP86) — witness v1, bech32m.</summary>
|
||||
Taproot,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,8 @@ public static class ChainProfiles
|
||||
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
||||
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
||||
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
||||
// Nessun SLIP-132 per P2TR: il contesto è dato dal path m/86'/…
|
||||
[ScriptKind.Taproot] = new(0x0488ade4, 0x0488b21e), // xprv / xpub (BIP32 standard)
|
||||
},
|
||||
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
||||
// peer vengono scoperti via server.peers.subscribe.
|
||||
@@ -70,6 +72,7 @@ public static class ChainProfiles
|
||||
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
||||
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
|
||||
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
|
||||
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
||||
},
|
||||
BootstrapServers = [],
|
||||
Checkpoints = [],
|
||||
|
||||
@@ -22,12 +22,16 @@ public static class DerivationPaths
|
||||
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
|
||||
public const int PurposeMultisig = 48;
|
||||
|
||||
/// <summary>Purpose BIP86 (P2TR key-path, Taproot).</summary>
|
||||
public const int PurposeTaproot = 86;
|
||||
|
||||
public static int PurposeFor(ScriptKind kind) => kind switch
|
||||
{
|
||||
ScriptKind.Legacy => PurposeLegacy,
|
||||
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
|
||||
ScriptKind.NativeSegwit => PurposeNativeSegwit,
|
||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
|
||||
ScriptKind.Taproot => PurposeTaproot,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
@@ -41,6 +45,7 @@ public static class DerivationPaths
|
||||
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
|
||||
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
|
||||
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
|
||||
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
|
||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
||||
throw new NotSupportedException(
|
||||
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace PalladiumWallet.Core.Crypto;
|
||||
/// quindi il watch-only funziona per costruzione. Il keystore completo
|
||||
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
|
||||
/// </summary>
|
||||
public sealed class HdAccount
|
||||
public sealed class HdAccount : IWalletAccount
|
||||
{
|
||||
private readonly ExtKey? _accountXprv;
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class HdAccount
|
||||
masterFingerprint ?? default);
|
||||
|
||||
public BitcoinAddress GetAddress(bool isChange, int index) =>
|
||||
GetPublicKey(isChange, index).GetAddress(
|
||||
GetPublicKey(isChange, index)!.GetAddress(
|
||||
DerivationPaths.ScriptPubKeyTypeFor(Kind),
|
||||
PalladiumNetworks.For(Profile.Kind));
|
||||
|
||||
@@ -94,9 +94,16 @@ public sealed class HdAccount
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>Chiave privata di un indirizzo; null se watch-only (§17).</summary>
|
||||
public Key? GetPrivateKey(bool isChange, int index) =>
|
||||
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
||||
|
||||
/// <summary>Gli account HD usano il gap limit: nessuna lista fissa di indirizzi.</summary>
|
||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
||||
|
||||
/// <summary>
|
||||
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
||||
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Astrazione su tutti i tipi di account wallet (HD da seed, HD da xpub/xprv importata,
|
||||
/// chiavi WIF importate). Consente a WalletSynchronizer e TransactionFactory di operare
|
||||
/// indipendentemente dal tipo di keystore sottostante (blueprint §4.4–§4.5).
|
||||
/// </summary>
|
||||
public interface IWalletAccount
|
||||
{
|
||||
ScriptKind Kind { get; }
|
||||
ChainProfile Profile { get; }
|
||||
|
||||
/// <summary>True se l'account non può firmare (assenza di chiavi private).</summary>
|
||||
bool IsWatchOnly { get; }
|
||||
|
||||
BitcoinAddress GetAddress(bool isChange, int index);
|
||||
BitcoinAddress GetReceiveAddress(int index);
|
||||
BitcoinAddress GetChangeAddress(int index);
|
||||
|
||||
/// <summary>Null se la chiave pubblica non è ricavabile dall'account (indirizzi puri watch-only).</summary>
|
||||
PubKey? GetPublicKey(bool isChange, int index);
|
||||
|
||||
/// <summary>Null se l'account è watch-only o l'indice è fuori range.</summary>
|
||||
Key? GetPrivateKey(bool isChange, int index);
|
||||
|
||||
/// <summary>
|
||||
/// Per gli account con indirizzi fissi (WIF importati) restituisce la lista
|
||||
/// completa da scansionare; null per gli account HD che usano il gap limit.
|
||||
/// </summary>
|
||||
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Account da chiavi WIF singole importate (blueprint §4.4 — "Imported"):
|
||||
/// lista fissa di indirizzi, nessuna derivazione HD, nessuna catena di change.
|
||||
/// Il change va sempre al primo indirizzo importato.
|
||||
/// </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("Almeno un indirizzo richiesto.", 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>Il change torna sempre al primo indirizzo importato.</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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
@@ -35,11 +37,15 @@ public sealed class SyncResult
|
||||
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
||||
/// estende la scansione fino al gap limit (§5).
|
||||
/// </summary>
|
||||
public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20)
|
||||
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
||||
{
|
||||
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
||||
public event Action<string>? Progress;
|
||||
|
||||
// Richieste contemporanee verso il server. Troppo alte → -102 "server busy";
|
||||
// troppo basse → throughput scarso su storie grandi.
|
||||
private const int MaxConcurrent = 20;
|
||||
|
||||
// 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
|
||||
@@ -47,56 +53,150 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
private readonly Dictionary<string, Transaction> _txCache = [];
|
||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||
|
||||
// Header grezzi per altezza: una Task<string> per altezza, condivisa tra
|
||||
// tutte le tx dello stesso blocco → ogni blocco viene scaricato una sola
|
||||
// volta anche con centinaia di tx confermate nello stesso blocco.
|
||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pre-popola le cache interne da dati salvati su disco (SyncCache).
|
||||
/// Chiamare prima di SyncOnceAsync per evitare di riscaricale le tx già note.
|
||||
/// </summary>
|
||||
public void PreloadCaches(Dictionary<string, string> rawTxHex,
|
||||
Dictionary<string, int> verifiedAt, Network network)
|
||||
{
|
||||
foreach (var (txid, hex) in rawTxHex)
|
||||
if (!_txCache.ContainsKey(txid))
|
||||
_txCache[txid] = Transaction.Parse(hex, network);
|
||||
foreach (var (txid, height) in verifiedAt)
|
||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||
_verifiedAtHeight[txid] = height;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esporta le cache correnti in forma serializzabile su disco.
|
||||
/// Solo le tx confermate (height > 0) vengono incluse: le non confermate
|
||||
/// possono cambiare (RBF) e vanno sempre riscaricate.
|
||||
/// </summary>
|
||||
public (Dictionary<string, string> RawTxHex, Dictionary<string, int> VerifiedAt)
|
||||
ExportCaches(Network network)
|
||||
{
|
||||
// Includi solo le tx associate a una prova di Merkle verificata
|
||||
// (cioè confermate e verificate): sono le uniche immutabili.
|
||||
var rawHex = _verifiedAtHeight.Keys
|
||||
.Where(_txCache.ContainsKey)
|
||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight));
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tip = await client.SubscribeHeadersAsync(ct);
|
||||
Progress?.Invoke($"tip della catena: {tip.Height}");
|
||||
|
||||
// 1-2. Scansione indirizzi con gap limit, per catena receiving e change.
|
||||
// 1-2. Scansione indirizzi.
|
||||
var tracked = new List<TrackedAddress>();
|
||||
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||
var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
||||
var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
||||
int nextReceive, nextChange;
|
||||
|
||||
if (account.FixedAddresses is { } fixedAddresses)
|
||||
{
|
||||
// Importati WIF: lista fissa, nessun gap limit.
|
||||
// Pochi indirizzi → subscribe diretto per notifiche push.
|
||||
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;
|
||||
|
||||
var histories = await Task.WhenAll(
|
||||
tracked.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||
for (var i = 0; i < tracked.Count; i++)
|
||||
{
|
||||
if (histories[i].Count > 0)
|
||||
historyByAddress[tracked[i].ScriptHash] = histories[i];
|
||||
}
|
||||
// Subscribe a tutti (pochi): notifiche push per ogni indirizzo importato.
|
||||
await Task.WhenAll(tracked.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||
}
|
||||
else
|
||||
{
|
||||
// HD: discovery con GetHistoryAsync (senza subscription → no -101 su wallet grandi);
|
||||
// subscribe solo al gap window per ricevere notifiche push di nuove tx.
|
||||
nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
||||
nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
||||
|
||||
// Iscriviti al gap window (prossimi indirizzi attesi) per notifiche push.
|
||||
// In questo modo il numero di subscription è sempre ≤ 2×gapLimit, indipendentemente
|
||||
// dalla dimensione dello storico — nessun rischio di -101.
|
||||
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 => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||
}
|
||||
|
||||
// 3. Storico unico (txid → altezza massima riportata).
|
||||
var txHeights = new Dictionary<string, int>();
|
||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||
txHeights[item.TxHash] = item.Height;
|
||||
|
||||
// 4. Scarica in parallelo le sole transazioni nuove.
|
||||
// 4. Scarica le transazioni nuove: semaforo MaxConcurrent per non saturare
|
||||
// il server, con aggiornamento progresso in tempo reale.
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
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 dlSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
||||
var dlDone = 0;
|
||||
Progress?.Invoke($"scarico 0/{missing.Count} transazioni…");
|
||||
await Task.WhenAll(missing.Select(async txid =>
|
||||
{
|
||||
await dlSem.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
Progress?.Invoke($"scarico {n}/{missing.Count} transazioni…");
|
||||
}
|
||||
finally { dlSem.Release(); }
|
||||
}));
|
||||
}
|
||||
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.
|
||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4).
|
||||
// Gli header per altezza sono condivisi via _headerFetches: se 500 tx
|
||||
// stanno nello stesso blocco, l'header viene scaricato una sola volta.
|
||||
var toVerify = txHeights
|
||||
.Where(kv => kv.Value > 0
|
||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||
.ToList();
|
||||
if (toVerify.Count > 0)
|
||||
{
|
||||
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
||||
var merkSem = new SemaphoreSlim(MaxConcurrent, MaxConcurrent);
|
||||
var merkDone = 0;
|
||||
Progress?.Invoke($"verifico 0/{toVerify.Count} prove di Merkle…");
|
||||
await Task.WhenAll(toVerify.Select(async kv =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
||||
var proof = await proofTask;
|
||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||
await merkSem.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
// Proof e header in parallelo; l'header è condiviso per altezza.
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
var headerTask = _headerFetches.GetOrAdd(height,
|
||||
h => client.GetBlockHeaderAsync(h, ct));
|
||||
var proof = await proofTask;
|
||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
Progress?.Invoke($"verifico {n}/{toVerify.Count} prove di Merkle…");
|
||||
}
|
||||
finally { merkSem.Release(); }
|
||||
}));
|
||||
foreach (var (txid, height) in toVerify)
|
||||
_verifiedAtHeight[txid] = height;
|
||||
@@ -194,8 +294,10 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
|
||||
/// <summary>
|
||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
||||
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
||||
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit per volta.
|
||||
/// Usa GetHistoryAsync per la discovery — senza subscription → nessun rischio di
|
||||
/// -101 "excessive resource usage" su wallet con molti indirizzi storici.
|
||||
/// Le subscription per notifiche push vengono gestite dal chiamante (solo gap window).
|
||||
/// Ritorna il primo indice non usato.
|
||||
/// </summary>
|
||||
private async Task<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
||||
@@ -214,19 +316,15 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
index += batch.Count;
|
||||
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();
|
||||
// GetHistoryAsync per discovery: risposta vuota [] se inutilizzato,
|
||||
// lista di tx se usato — un solo round-trip per indirizzo.
|
||||
var histories = await Task.WhenAll(
|
||||
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||
for (var i = 0; i < used.Count; i++)
|
||||
historyByAddress[used[i].ScriptHash] = histories[i];
|
||||
batch.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||
|
||||
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||
{
|
||||
if (statuses[i] is null)
|
||||
var history = histories[i];
|
||||
if (history.Count == 0)
|
||||
{
|
||||
consecutiveEmpty++;
|
||||
}
|
||||
@@ -234,6 +332,7 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
{
|
||||
consecutiveEmpty = 0;
|
||||
firstUnused = batch[i].Index + 1;
|
||||
historyByAddress[batch[i].ScriptHash] = history;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ namespace PalladiumWallet.Core.Storage;
|
||||
/// </summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
/// <summary>Codice lingua UI ("it", "en").</summary>
|
||||
public string Language { get; set; } = "it";
|
||||
/// <summary>Codice lingua UI.</summary>
|
||||
public string Language { get; set; } = "en";
|
||||
|
||||
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||
public string Unit { get; set; } = "PLM";
|
||||
|
||||
@@ -3,24 +3,97 @@ using PalladiumWallet.Core.Chain;
|
||||
namespace PalladiumWallet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
|
||||
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
|
||||
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
|
||||
/// esiste una cartella "palladium-data".
|
||||
/// Percorsi dati per piattaforma (blueprint §8). La radice dati può essere:
|
||||
/// 1. <b>portable</b>: cartella "palladium-data" accanto all'eseguibile;
|
||||
/// 2. <b>personalizzata</b>: scelta dall'utente al primo avvio e memorizzata in
|
||||
/// un piccolo file "puntatore" in una posizione di bootstrap fissa;
|
||||
/// 3. <b>legacy</b>: vecchia posizione (%APPDATA%/PalladiumWallet) se contiene già dati;
|
||||
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) o %ProgramFiles%\PalladiumWallet (Windows).
|
||||
/// Sotto la radice c'è una sottocartella per rete (config, wallet, header, certificati).
|
||||
/// </summary>
|
||||
public static class AppPaths
|
||||
{
|
||||
public const string PortableDirName = "palladium-data";
|
||||
|
||||
/// <summary>Nome cartella applicazione, usato nei vari percorsi.</summary>
|
||||
public const string AppDirName = "PalladiumWallet";
|
||||
|
||||
/// <summary>Override esplicito della radice dati (es. CLI --data-dir). Ha priorità su tutto.</summary>
|
||||
public static string? OverrideDataRoot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Radice dati predefinita, secondo la convenzione di ogni piattaforma:
|
||||
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, come Electrum/Bitcoin);
|
||||
/// Linux/macOS → ~/.palladium-wallet (dotfolder minuscolo, come ~/.bitcoin).
|
||||
/// Per-utente e sempre scrivibile, senza privilegi di amministratore.
|
||||
/// </summary>
|
||||
public static string DefaultDataRoot()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
AppDirName);
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(home, ".palladium-wallet");
|
||||
}
|
||||
|
||||
/// <summary>File puntatore alla radice dati scelta dall'utente. Vive in una
|
||||
/// posizione di bootstrap sempre scrivibile e indipendente dalla radice dati.</summary>
|
||||
private static string LocationPointerPath() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
AppDirName, "data-location");
|
||||
|
||||
private static string PortableRoot() =>
|
||||
Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
||||
|
||||
private static bool HasData(string root) =>
|
||||
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
||||
|
||||
/// <summary>Radice dati effettiva, secondo l'ordine di precedenza documentato in classe.</summary>
|
||||
public static string DataRoot()
|
||||
{
|
||||
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
||||
if (!string.IsNullOrEmpty(OverrideDataRoot))
|
||||
return OverrideDataRoot;
|
||||
|
||||
var portable = PortableRoot();
|
||||
if (Directory.Exists(portable))
|
||||
return portable;
|
||||
|
||||
return OperatingSystem.IsWindows()
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet")
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
|
||||
if (ReadPointer() is { } custom)
|
||||
return custom;
|
||||
|
||||
return DefaultDataRoot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// true se la posizione dei dati è già determinata e non serve chiederla
|
||||
/// all'utente: modalità portable, override, puntatore già scritto, oppure
|
||||
/// dati già presenti nella posizione predefinita.
|
||||
/// </summary>
|
||||
public static bool IsDataLocationConfigured() =>
|
||||
!string.IsNullOrEmpty(OverrideDataRoot)
|
||||
|| Directory.Exists(PortableRoot())
|
||||
|| ReadPointer() is not null
|
||||
|| HasData(DefaultDataRoot());
|
||||
|
||||
/// <summary>Memorizza la radice dati scelta dall'utente e la crea su disco.</summary>
|
||||
public static void ConfigureDataLocation(string root)
|
||||
{
|
||||
root = Path.GetFullPath(root.Trim());
|
||||
Directory.CreateDirectory(root);
|
||||
var pointer = LocationPointerPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(pointer)!);
|
||||
File.WriteAllText(pointer, root);
|
||||
}
|
||||
|
||||
private static string? ReadPointer()
|
||||
{
|
||||
var pointer = LocationPointerPath();
|
||||
if (!File.Exists(pointer))
|
||||
return null;
|
||||
var path = File.ReadAllText(pointer).Trim();
|
||||
return string.IsNullOrEmpty(path) ? null : path;
|
||||
}
|
||||
|
||||
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
|
||||
@@ -41,6 +114,15 @@ public static class AppPaths
|
||||
public static string DefaultWalletPath(NetKind net) =>
|
||||
Path.Combine(WalletsDir(net), "default.wallet.json");
|
||||
|
||||
/// <summary>Tutti i file wallet della rete, ordinati per nome (multi-wallet §8).</summary>
|
||||
public static IReadOnlyList<string> WalletFiles(NetKind net)
|
||||
{
|
||||
var dir = WalletsDir(net);
|
||||
return Directory.EnumerateFiles(dir, "*.wallet.json")
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string CertificatePinsPath(NetKind net) =>
|
||||
Path.Combine(ForNetwork(net), "server-certs.json");
|
||||
|
||||
|
||||
@@ -26,24 +26,36 @@ public sealed class WalletDocument
|
||||
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
||||
public string? Passphrase { get; set; }
|
||||
|
||||
/// <summary>Path di account (es. "84'/746'/0'").</summary>
|
||||
public required string AccountPath { get; set; }
|
||||
/// <summary>Path di account (es. "84'/746'/0'"); null per importati WIF.</summary>
|
||||
public string? AccountPath { get; set; }
|
||||
|
||||
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
|
||||
public required string AccountXpub { get; set; }
|
||||
/// <summary>Xpub di account in SLIP-132 per i wallet HD; null per importati WIF.</summary>
|
||||
public string? AccountXpub { get; set; }
|
||||
|
||||
/// <summary>Xprv di account in SLIP-132; presente solo per import da xprv senza seed.</summary>
|
||||
public string? AccountXprv { get; set; }
|
||||
|
||||
public string? MasterFingerprint { get; set; }
|
||||
|
||||
/// <summary>Chiavi WIF importate (in chiaro nel documento — va cifrato!).</summary>
|
||||
public List<string>? WifKeys { get; set; }
|
||||
|
||||
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
||||
public int GapLimit { get; set; } = 20;
|
||||
|
||||
/// <summary>Etichette per indirizzo/txid (§12).</summary>
|
||||
public Dictionary<string, string> Labels { get; set; } = [];
|
||||
|
||||
/// <summary>Rubrica contatti (nome + indirizzo blockchain).</summary>
|
||||
public List<StoredContact> Contacts { get; set; } = [];
|
||||
|
||||
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
||||
public SyncCache? Cache { get; set; }
|
||||
|
||||
public bool IsWatchOnly => Mnemonic is null;
|
||||
public bool IsWatchOnly =>
|
||||
Mnemonic is null &&
|
||||
AccountXprv is null &&
|
||||
(WifKeys is null || WifKeys.Count == 0);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -69,6 +81,13 @@ public sealed class WalletDocument
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Contatto in rubrica: nome leggibile + indirizzo blockchain.</summary>
|
||||
public sealed class StoredContact
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Address { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
|
||||
public sealed class SyncCache
|
||||
{
|
||||
@@ -80,6 +99,20 @@ public sealed class SyncCache
|
||||
public List<CachedTx> History { get; set; } = [];
|
||||
public List<CachedUtxo> Utxos { get; set; } = [];
|
||||
public List<CachedAddress> Addresses { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Cache raw delle transazioni confermate (txid → hex). Evita di riscaricale
|
||||
/// ad ogni avvio dell'app: le tx confermate sono immutabili per definizione.
|
||||
/// Popolata anche parzialmente in caso di sync interrotta (es. -101):
|
||||
/// il synchronizer riprende dal punto in cui si era fermato.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? RawTxHex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prove di Merkle già verificate (txid → altezza blocco). Evita di
|
||||
/// riverificare le stesse prove ad ogni avvio: le conferme sono immutabili.
|
||||
/// </summary>
|
||||
public Dictionary<string, int>? VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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,6 +25,8 @@ public static class WalletStore
|
||||
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)
|
||||
{
|
||||
var content = doc.ToJson();
|
||||
|
||||
@@ -16,10 +16,11 @@ public static class CoinAmount
|
||||
/// <summary>(satoshi per unità, decimali mostrati) di ciascuna unità.</summary>
|
||||
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
||||
{
|
||||
"PLM" => (SatsPerCoin, 8),
|
||||
"mPLM" => (100_000, 5),
|
||||
"µPLM" => (100, 2),
|
||||
"sat" => (1, 0),
|
||||
_ => (SatsPerCoin, 8), // PLM
|
||||
"sat" => (1, 0),
|
||||
_ => throw new ArgumentException($"Unknown coin unit: {unit}", nameof(unit)),
|
||||
};
|
||||
|
||||
public static string Format(long sats, string unit = "") =>
|
||||
@@ -46,7 +47,10 @@ public static class CoinAmount
|
||||
return false;
|
||||
try
|
||||
{
|
||||
sats = (long)(value * factor);
|
||||
var satsDecimal = value * factor;
|
||||
if (satsDecimal % 1 != 0)
|
||||
return false;
|
||||
sats = (long)satsDecimal;
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
@@ -65,7 +69,10 @@ public static class CoinAmount
|
||||
return false;
|
||||
try
|
||||
{
|
||||
sats = (long)(coins * SatsPerCoin);
|
||||
var satsDecimal = coins * SatsPerCoin;
|
||||
if (satsDecimal % 1 != 0)
|
||||
return false;
|
||||
sats = (long)satsDecimal;
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class BuiltTransaction
|
||||
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
|
||||
/// Con un account watch-only produce la PSBT non firmata (§6.5).
|
||||
/// </summary>
|
||||
public sealed class TransactionFactory(HdAccount account)
|
||||
public sealed class TransactionFactory(IWalletAccount account)
|
||||
{
|
||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||
|
||||
@@ -82,7 +82,8 @@ public sealed class TransactionFactory(HdAccount account)
|
||||
if (!account.IsWatchOnly)
|
||||
{
|
||||
builder.AddKeys(spendable
|
||||
.Select(u => account.GetExtPrivateKey(u.IsChange, u.AddressIndex))
|
||||
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
||||
.OfType<Key>()
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>Un input di una transazione, con l'output speso risolto dal server.</summary>
|
||||
public sealed record TxInputInfo(
|
||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
|
||||
|
||||
/// <summary>Un output di una transazione.</summary>
|
||||
public sealed record TxOutputInfo(
|
||||
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
|
||||
|
||||
/// <summary>
|
||||
/// Dati completi di una transazione, assemblati interrogando il server: la tx
|
||||
/// grezza più gli output spesi dagli input (per ricavare importi, indirizzi e
|
||||
/// fee) e l'header del blocco (per la data). Tutto ciò che il protocollo
|
||||
/// ElectrumX-like (§10) permette di sapere su una transazione.
|
||||
/// </summary>
|
||||
public sealed class TransactionDetails
|
||||
{
|
||||
public required string Txid { get; init; }
|
||||
/// <summary>Altezza del blocco; ≤0 = ancora in mempool.</summary>
|
||||
public required int Height { get; init; }
|
||||
public required int Confirmations { get; init; }
|
||||
/// <summary>Effetto netto sul saldo del wallet (delta calcolato in sincronizzazione).</summary>
|
||||
public required long NetSats { get; init; }
|
||||
/// <summary>Fee della transazione; null se un input ha importo non risolvibile (es. coinbase).</summary>
|
||||
public required long? FeeSats { get; init; }
|
||||
public required int TotalSize { get; init; }
|
||||
public required int VirtualSize { get; init; }
|
||||
public required uint Version { get; init; }
|
||||
public required uint LockTime { get; init; }
|
||||
public required bool RbfSignaled { get; init; }
|
||||
/// <summary>Merkle proof verificata in sincronizzazione (§7.4).</summary>
|
||||
public required bool Verified { get; init; }
|
||||
public required DateTimeOffset? BlockTime { get; init; }
|
||||
public required long TotalOutSats { get; init; }
|
||||
public required long? TotalInSats { get; init; }
|
||||
public required IReadOnlyList<TxInputInfo> Inputs { get; init; }
|
||||
public required IReadOnlyList<TxOutputInfo> Outputs { get; init; }
|
||||
|
||||
public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
|
||||
public bool IsIncoming => NetSats >= 0;
|
||||
/// <summary>Importo verso destinatari esterni (output non nostri): l'importo "inviato".</summary>
|
||||
public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
|
||||
public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
|
||||
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
|
||||
/// <summary>
|
||||
/// Indirizzi della controparte: i destinatari esterni per un invio (output non
|
||||
/// nostri), i mittenti esterni per una ricezione (input non nostri).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
|
||||
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
|
||||
: [.. Outputs.Where(o => !o.IsMine && o.Address is not null).Select(o => o.Address!).Distinct()];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recupera dal server tutti i dati di una singola transazione (blueprint §10):
|
||||
/// la transazione grezza e gli output spesi dai suoi input, per ricostruire
|
||||
/// importi, fee e indirizzi che il server non riassume.
|
||||
/// </summary>
|
||||
public static class TransactionInspector
|
||||
{
|
||||
public static async Task<TransactionDetails> FetchAsync(
|
||||
ElectrumClient client, Network network, string txid, int tipHeight, int height,
|
||||
IReadOnlySet<string> ownedAddresses, long netSats, bool verified,
|
||||
IReadOnlyDictionary<string, Transaction>? cache = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
async Task<Transaction> GetTx(string id)
|
||||
{
|
||||
if (cache is not null && cache.TryGetValue(id, out var hit))
|
||||
return hit;
|
||||
return Transaction.Parse(await client.GetTransactionAsync(id, ct), network);
|
||||
}
|
||||
|
||||
async Task<Transaction?> GetTxOrNull(string id)
|
||||
{
|
||||
try { return await GetTx(id); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
async Task<DateTimeOffset?> GetBlockTimeOrNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
|
||||
return DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
string? AddrOf(Script s)
|
||||
{
|
||||
try { return s.GetDestinationAddress(network)?.ToString(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
var tx = await GetTx(txid);
|
||||
|
||||
var outputs = new List<TxOutputInfo>(tx.Outputs.Count);
|
||||
for (var i = 0; i < tx.Outputs.Count; i++)
|
||||
{
|
||||
var o = tx.Outputs[i];
|
||||
var addr = AddrOf(o.ScriptPubKey);
|
||||
outputs.Add(new TxOutputInfo(
|
||||
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
|
||||
addr is not null && ownedAddresses.Contains(addr)));
|
||||
}
|
||||
|
||||
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
|
||||
|
||||
// Le transazioni degli input servono per importi/indirizzi/fee. Si
|
||||
// scaricano in parallelo (id univoci, richieste concorrenti supportate
|
||||
// da ElectrumClient): in sequenza la finestra impiegava un round-trip
|
||||
// per input. Anche l'header del blocco è recuperato in parallelo.
|
||||
var prevTxids = tx.IsCoinBase
|
||||
? []
|
||||
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
|
||||
|
||||
var prevFetch = prevTxids.ToDictionary(id => id, id => GetTxOrNull(id));
|
||||
var headerTask = height > 0 ? GetBlockTimeOrNull() : Task.FromResult<DateTimeOffset?>(null);
|
||||
await Task.WhenAll(prevFetch.Values.Cast<Task>().Append(headerTask));
|
||||
|
||||
var prevTxs = prevFetch.ToDictionary(kv => kv.Key, kv => kv.Value.Result);
|
||||
var blockTime = await headerTask;
|
||||
|
||||
var inputs = new List<TxInputInfo>(tx.Inputs.Count);
|
||||
var feeKnown = !tx.IsCoinBase;
|
||||
long inSum = 0;
|
||||
foreach (var inp in tx.Inputs)
|
||||
{
|
||||
if (tx.IsCoinBase)
|
||||
{
|
||||
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
long? amt = null;
|
||||
string? addr = null;
|
||||
var prev = prevTxs.GetValueOrDefault(inp.PrevOut.Hash.ToString());
|
||||
if (prev is not null && inp.PrevOut.N < prev.Outputs.Count)
|
||||
{
|
||||
var po = prev.Outputs[(int)inp.PrevOut.N];
|
||||
amt = po.Value.Satoshi;
|
||||
addr = AddrOf(po.ScriptPubKey);
|
||||
inSum += po.Value.Satoshi;
|
||||
}
|
||||
else feeKnown = false;
|
||||
|
||||
inputs.Add(new TxInputInfo(
|
||||
inp.PrevOut.Hash.ToString(), inp.PrevOut.N, amt, addr,
|
||||
addr is not null && ownedAddresses.Contains(addr), false));
|
||||
}
|
||||
|
||||
var outSum = tx.Outputs.Sum(o => o.Value.Satoshi);
|
||||
|
||||
return new TransactionDetails
|
||||
{
|
||||
Txid = txid,
|
||||
Height = height,
|
||||
Confirmations = height > 0 && tipHeight >= height ? tipHeight - height + 1 : 0,
|
||||
NetSats = netSats,
|
||||
FeeSats = feeKnown ? inSum - outSum : null,
|
||||
TotalSize = tx.ToBytes().Length,
|
||||
VirtualSize = tx.GetVirtualSize(),
|
||||
Version = tx.Version,
|
||||
LockTime = tx.LockTime.Value,
|
||||
RbfSignaled = rbf,
|
||||
Verified = verified,
|
||||
BlockTime = blockTime,
|
||||
TotalOutSats = outSum,
|
||||
TotalInSats = feeKnown ? inSum : null,
|
||||
Inputs = inputs,
|
||||
Outputs = outputs,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ScriptType(Script script)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = StandardScripts.GetTemplateFromScriptPubKey(script);
|
||||
return t is null
|
||||
? "nonstandard"
|
||||
: t.GetType().Name.Replace("PayTo", "").Replace("Template", "");
|
||||
}
|
||||
catch { return "—"; }
|
||||
}
|
||||
}
|
||||
@@ -6,31 +6,58 @@ using PalladiumWallet.Core.Storage;
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce
|
||||
/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della
|
||||
/// factory dei tipi di wallet; crescerà con multisig e importati.
|
||||
/// Factory dei tipi di wallet (blueprint §4.5): dal documento ricostruisce il tipo
|
||||
/// di account corretto (HD da seed, HD da xprv importata, importato WIF, watch-only).
|
||||
/// </summary>
|
||||
public static class WalletLoader
|
||||
{
|
||||
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
||||
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
||||
|
||||
public static HdAccount ToAccount(WalletDocument doc)
|
||||
public static IWalletAccount ToAccount(WalletDocument doc)
|
||||
{
|
||||
var profile = ProfileOf(doc);
|
||||
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
||||
var path = KeyPath.Parse(doc.AccountPath);
|
||||
var network = PalladiumNetworks.For(profile.Kind);
|
||||
|
||||
// 1. HD da seed (caso più comune)
|
||||
if (doc.Mnemonic is { } words)
|
||||
{
|
||||
if (!Bip39.TryParse(words, out var mnemonic))
|
||||
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);
|
||||
}
|
||||
|
||||
// 2. HD da xprv importata (spendibile, senza seed)
|
||||
if (doc.AccountXprv is { } xprvStr)
|
||||
{
|
||||
if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _))
|
||||
throw new InvalidDataException("Xprv del file wallet non valida per questa rete.");
|
||||
var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null;
|
||||
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
|
||||
}
|
||||
|
||||
// 3. Chiavi WIF importate
|
||||
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 da xpub
|
||||
if (doc.AccountXpub is null)
|
||||
throw new InvalidDataException("File wallet senza xpub e senza seed.");
|
||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
|
||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, path);
|
||||
var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null;
|
||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath);
|
||||
}
|
||||
|
||||
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
||||
@@ -56,4 +83,91 @@ public static class WalletLoader
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crea il documento da una xpub SLIP-132 importata (watch-only).
|
||||
/// Rileva il ScriptKind dagli header SLIP-132; <paramref name="kindOverride"/> lo sovrascrive
|
||||
/// per i prefissi ambigui (xpub può essere Legacy o 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("Chiave pubblica estesa non valida o non riconosciuta per questa rete.");
|
||||
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>
|
||||
/// Crea il documento da una xprv SLIP-132 importata (spendibile senza seed).
|
||||
/// Il documento contiene la xprv in chiaro: deve essere obbligatoriamente cifrato.
|
||||
/// </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("Chiave privata estesa non valida o non riconosciuta per questa rete.");
|
||||
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>
|
||||
/// Crea il documento da una o più chiavi WIF importate.
|
||||
/// Il documento contiene le chiavi WIF in chiaro: deve essere obbligatoriamente cifrato.
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
||||
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
|
||||
{
|
||||
if (wifKeys.Count == 0)
|
||||
throw new InvalidDataException("Almeno una chiave WIF richiesta.");
|
||||
|
||||
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($"Chiave WIF non valida: {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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,42 @@ public class ChainProfileTests
|
||||
Assert.StartsWith("tpub", EncodeWithHeader(headers.Public));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rete_sconosciuta_lancia_ArgumentException()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// Serializza header (4 byte BE) + payload BIP32 di 74 byte e codifica Base58Check:
|
||||
// il prefisso testuale risultante dipende solo dall'header.
|
||||
private static string EncodeWithHeader(uint header)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
|
||||
namespace PalladiumWallet.Tests.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Vettori di test BIP86 (mnemonica abandon-about, senza passphrase).
|
||||
/// La chiave pubblica tweakizzata (output key, 32 byte x-only) è chain-independent:
|
||||
/// viene verificata contro i vettori ufficiali Bitcoin, poi si controlla che
|
||||
/// l'indirizzo PLM abbia il prefisso plm1p (witness v1, bech32m).
|
||||
/// Il path m/86'/0'/0' usa coin_type=0 (non 746) per aderire ai vettori BIP86.
|
||||
/// </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>
|
||||
/// L'output key (chiave tweakizzata x-only, 32 byte) è identica al vettore BIP86
|
||||
/// indipendentemente dalla rete: la rete cambia solo HRP e checksum, non il programma.
|
||||
/// Indirizzi Bitcoin da 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);
|
||||
|
||||
// Il witness program (output key tweakizzata) è 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));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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). Ogni test genera centinaia di input casuali e
|
||||
/// verifica che le proprietà invarianti reggano — crash, eccezioni non attese, o
|
||||
/// violazioni di roundtrip sono failures.
|
||||
/// </summary>
|
||||
public class PropertyTests
|
||||
{
|
||||
// ── generatori riutilizzabili ─────────────────────────────────────────────
|
||||
|
||||
private static readonly Gen<string> GenUnit = Gen.OneOf(
|
||||
Gen.Const("PLM"), Gen.Const("mPLM"), Gen.Const("µPLM"), Gen.Const("sat"));
|
||||
|
||||
// uint256 casuale costruito da 4 ulong
|
||||
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 non deve mai lanciare eccezioni su input arbitrario con unità valide.
|
||||
[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)
|
||||
{
|
||||
// unità sconosciuta: impossibile qui perché usiamo solo unità note
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail($"TryParseIn ha lanciato {ex.GetType().Name} per unit={unit}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// FormatIn → TryParseIn: qualsiasi satoshi [0, MaxSupply] deve fare roundtrip esatto.
|
||||
[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} non si riparsa");
|
||||
Assert.Equal(sats, parsed);
|
||||
});
|
||||
}
|
||||
|
||||
/// TryParseCoins non deve mai lanciare su input arbitrario.
|
||||
[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 ha lanciato {ex.GetType().Name}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Qualsiasi valore accettato da TryParseCoins deve essere ≥ 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 ha restituito {sats} per '{text}'");
|
||||
});
|
||||
}
|
||||
|
||||
// ── EncryptedFile ────────────────────────────────────────────────────────
|
||||
|
||||
/// Encrypt → Decrypt con la stessa password deve restituire il testo originale.
|
||||
[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 con password sbagliata deve lanciare WrongPasswordException, mai altro.
|
||||
[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; // stessa password: roundtrip valido, salta
|
||||
|
||||
var cipher = EncryptedFile.Encrypt(plaintext, pwd1);
|
||||
try
|
||||
{
|
||||
EncryptedFile.Decrypt(cipher, pwd2);
|
||||
Assert.Fail("Decrypt con password sbagliata non ha lanciato");
|
||||
}
|
||||
catch (WrongPasswordException) { /* atteso */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail($"Decrypt ha lanciato {ex.GetType().Name} invece di WrongPasswordException");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// IsEncrypted non deve mai lanciare su input arbitrario.
|
||||
[Fact]
|
||||
public void EncryptedFile_IsEncrypted_non_lancia_mai()
|
||||
{
|
||||
Gen.String.Sample(s =>
|
||||
{
|
||||
try { EncryptedFile.IsEncrypted(s); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Assert.Fail($"IsEncrypted ha lanciato {ex.GetType().Name}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── MerkleProof ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Ogni foglia di un albero Merkle generato casualmente deve verificare contro la radice.
|
||||
[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 fallita per pos={pos} su {txids.Length} foglie");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Un txid non presente nelle foglie non deve verificare (e non deve crashare).
|
||||
[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; // collisione casuale: salta
|
||||
|
||||
var root = MerkleProof.ComputeRootFromLeaves(txids);
|
||||
var branch = BuildBranch(txids, 0);
|
||||
Assert.False(MerkleProof.Verify(extra, 0, branch, root));
|
||||
});
|
||||
}
|
||||
|
||||
// helper: costruisce il branch per la posizione data
|
||||
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,3 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
@@ -17,12 +20,26 @@ public class ScripthashTests
|
||||
var script = Script.FromHex(scriptHex);
|
||||
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
|
||||
{
|
||||
// Blocco Bitcoin 100000: 4 transazioni, merkle root nota — àncora esterna
|
||||
// per la convenzione di hashing/ordinamento.
|
||||
// Blocco Bitcoin 100000: 4 transazioni (pari), merkle root nota.
|
||||
private static readonly uint256[] Block100000Txids =
|
||||
[
|
||||
uint256.Parse("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87"),
|
||||
@@ -34,6 +51,8 @@ public class MerkleProofTests
|
||||
private static readonly uint256 Block100000Root =
|
||||
uint256.Parse("f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766");
|
||||
|
||||
// ---- numero pari di transazioni (4 tx) ----
|
||||
|
||||
[Fact]
|
||||
public void La_radice_calcolata_dalle_foglie_coincide_con_quella_del_blocco()
|
||||
{
|
||||
@@ -51,6 +70,70 @@ public class MerkleProofTests
|
||||
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));
|
||||
}
|
||||
|
||||
// ---- numero dispari di transazioni (duplicazione dell'ultimo) ----
|
||||
|
||||
[Fact]
|
||||
public void Tre_tx_dispari_la_terza_viene_duplicata()
|
||||
{
|
||||
// Con 3 tx: livello 1 = [SHA256d(tx0||tx1), SHA256d(tx2||tx2)]
|
||||
// Verifica che la radice sia deterministica e corretta.
|
||||
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 → livello pari (4) → livello pari (2) → radice
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- due transazioni (pari minimo) ----
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- proof errate ----
|
||||
|
||||
[Fact]
|
||||
public void Una_prova_per_la_posizione_sbagliata_fallisce()
|
||||
{
|
||||
@@ -65,6 +148,29 @@ public class MerkleProofTests
|
||||
Assert.False(MerkleProof.Verify(uint256.One, 0, branch, Block100000Root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Branch_alterato_non_verifica()
|
||||
{
|
||||
var branch = BuildBranch(Block100000Txids, 0);
|
||||
branch[0] = uint256.One; // corrompe il primo elemento del branch
|
||||
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>Costruisce il branch per una foglia ricostruendo i livelli dell'albero.</summary>
|
||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||
{
|
||||
@@ -89,7 +195,6 @@ public class MerkleProofTests
|
||||
|
||||
public class BlockHeaderInfoTests
|
||||
{
|
||||
// Header del blocco genesi di Bitcoin (riusato dalla mainnet PLM, §3).
|
||||
private const string GenesisHeaderHex =
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
|
||||
|
||||
@@ -118,10 +223,20 @@ public class BlockHeaderInfoTests
|
||||
[Fact]
|
||||
public void Con_skip_pow_la_validazione_non_controlla_il_target()
|
||||
{
|
||||
// La genesi ha PoW valido, ma il punto è che con SkipPowValidation=true
|
||||
// (LWMA, §3) il check si limita al collegamento.
|
||||
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
|
||||
Assert.True(ChainProfiles.Mainnet.SkipPowValidation);
|
||||
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
[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,3 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json.Nodes;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Tests.Storage;
|
||||
@@ -14,6 +17,11 @@ public class StorageTests
|
||||
Labels = { ["txid123"] = "caffè" },
|
||||
};
|
||||
|
||||
private static string TempPath() =>
|
||||
Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
|
||||
|
||||
// ---- cifratura AES-GCM ----
|
||||
|
||||
[Fact]
|
||||
public void La_cifratura_fa_roundtrip_con_la_password_giusta()
|
||||
{
|
||||
@@ -34,14 +42,43 @@ public class StorageTests
|
||||
public void Un_file_manomesso_viene_rifiutato()
|
||||
{
|
||||
var cipher = EncryptedFile.Encrypt("contenuto", "pass");
|
||||
// Corrompe un byte del ciphertext mantenendo base64 e JSON validi.
|
||||
var node = System.Text.Json.Nodes.JsonNode.Parse(cipher)!;
|
||||
var node = JsonNode.Parse(cipher)!;
|
||||
var data = Convert.FromBase64String(node["Data"]!.GetValue<string>());
|
||||
data[0] ^= 0xff;
|
||||
node["Data"] = Convert.ToBase64String(data);
|
||||
Assert.Throws<WrongPasswordException>(() => EncryptedFile.Decrypt(node.ToJsonString(), "pass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 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]
|
||||
public void Il_documento_wallet_fa_roundtrip_json()
|
||||
{
|
||||
@@ -58,15 +95,36 @@ public class StorageTests
|
||||
[Fact]
|
||||
public void Una_versione_futura_del_file_viene_rifiutata()
|
||||
{
|
||||
var doc = SampleDoc();
|
||||
var json = doc.ToJson().Replace("\"Version\": 1", "\"Version\": 99");
|
||||
var json = SampleDoc().ToJson().Replace("\"Version\": 1", "\"Version\": 99");
|
||||
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]
|
||||
public void Il_wallet_store_salva_e_riapre_con_e_senza_password()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"plm-test-{Guid.NewGuid()}.wallet.json");
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
WalletStore.Save(SampleDoc(), path);
|
||||
@@ -86,10 +144,132 @@ public class StorageTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_documento_senza_mnemonica_e_watch_only()
|
||||
public void Scrittura_atomica_non_lascia_file_tmp()
|
||||
{
|
||||
var doc = SampleDoc();
|
||||
doc.Mnemonic = null;
|
||||
Assert.True(WalletDocument.FromJson(doc.ToJson()).IsWatchOnly);
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
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()
|
||||
{
|
||||
// Un .lock rimasto da un crash precedente (file esiste ma nessuno lo tiene)
|
||||
// non deve bloccare l'apertura del wallet.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Tests.Wallet;
|
||||
|
||||
public class CoinAmountTests
|
||||
{
|
||||
// ---- importi validi: tutte le unità ----
|
||||
|
||||
[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 _));
|
||||
}
|
||||
|
||||
// ---- unità sconosciuta lancia 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Tests.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Test per ImportedKeyAccount e i nuovi percorsi factory in WalletLoader
|
||||
/// (blueprint §4.4 — importati WIF, xpub, xprv).
|
||||
/// </summary>
|
||||
public class ImportedKeyAccountTests
|
||||
{
|
||||
private static ChainProfile Profile => ChainProfiles.Mainnet;
|
||||
private static Network Network => PalladiumNetworks.For(Profile.Kind);
|
||||
|
||||
// Chiave WIF valida per 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 → primo indirizzo
|
||||
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()
|
||||
{
|
||||
// Crea un HD account, esporta la zpub, reimporta come xpub watch-only.
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ public class TransactionFactoryTests
|
||||
{
|
||||
File.WriteAllText(path, "{ rotto ");
|
||||
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
|
||||
Assert.Equal("it", loaded.Language);
|
||||
Assert.Equal("en", loaded.Language);
|
||||
Assert.Equal("PLM", loaded.Unit);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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);
|
||||
|
||||
// Crea documento watch-only rimuovendo la mnemonica
|
||||
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);
|
||||
}
|
||||
}
|
||||