Compare commits
17 Commits
c75e3921aa
...
d8917fbd9a
| Author | SHA1 | Date | |
|---|---|---|---|
| d8917fbd9a | |||
| 413392f608 | |||
| 970253cbbe | |||
| 31012ce825 | |||
| a46f5e6638 | |||
| 61ed0c0ed0 | |||
| 9097ca4abc | |||
| 2ed8c04064 | |||
| 56135c29f8 | |||
| 5d74038aad | |||
| db5b65ca0d | |||
| 45f7b1401e | |||
| e349a80ddc | |||
| 69be42aba0 | |||
| af2fdcc894 | |||
| 1f4421ae16 | |||
| b304996ff5 |
@@ -46,7 +46,7 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
|||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- Build: `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
|
- 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; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design. Two `internal` test seams (visible via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test), and `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests touching them share the xUnit collection `"AppPaths"` because that state is static).
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- 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)
|
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
|||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- Build: `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
|
- 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; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design. Two `internal` test seams (visible via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test), and `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests touching them share the xUnit collection `"AppPaths"` because that state is static).
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- 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)
|
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
|
|||||||
@@ -152,12 +152,12 @@ The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
|
|||||||
|
|
||||||
| Area | What is verified |
|
| Area | What is verified |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Chain/` | Network profiles (prefixes, ports, coin type 746, SLIP-132 headers), NBitcoin network registration |
|
| `Chain/` | Network profiles (prefixes, ports, coin type 746, SLIP-132 headers), NBitcoin network registration and the `INetworkSet` plumbing |
|
||||||
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode, HD and imported-key accounts, watch-only isolation |
|
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation, all 8 supported wordlist languages), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode including corrupted-payload rejection, HD and imported-key accounts (fund-safety fallbacks for out-of-range indices), watch-only isolation |
|
||||||
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the path where a lying server fails Merkle verification and the sync must abort |
|
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing and PoW target validation, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the paths where a lying server fails Merkle verification or serves a header chain that does not anchor to a hardcoded checkpoint, and the sync must abort |
|
||||||
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), release-tag parsing |
|
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation, oversized responses), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), the update check end-to-end via a stubbed HTTP transport (newer/equal/older tags, HTTP errors, malformed JSON, offline — all best-effort to null) |
|
||||||
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data paths |
|
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data-path resolution with its full precedence chain (override → portable → pointer file → default) |
|
||||||
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting |
|
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path, standardness-policy rejection of absurd fees), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting, corrupted-wallet-file rejection |
|
||||||
|
|
||||||
Network-facing code is tested against an **in-process fake ElectrumX server**
|
Network-facing code is tested against an **in-process fake ElectrumX server**
|
||||||
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
|
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ It does **not** protect against:
|
|||||||
|
|
||||||
This wallet is an SPV client, not a full node. It validates:
|
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`)
|
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) back to the nearest hardcoded checkpoint at or below its height, and that checkpoint's hash must match exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`). Currently populated for mainnet only (every 20,000 blocks); testnet/regtest have no checkpoints yet, so headers there are trusted at face value
|
||||||
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
|
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
|
||||||
|
|
||||||
It does **not** validate:
|
It does **not** validate:
|
||||||
@@ -55,7 +55,7 @@ It cannot (given correct Merkle verification):
|
|||||||
## Encryption at rest
|
## Encryption at rest
|
||||||
|
|
||||||
- Algorithm: AES-256-GCM
|
- Algorithm: AES-256-GCM
|
||||||
- Key derivation: PBKDF2-HMAC-SHA512, 100 000 iterations, 32-byte random salt
|
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible)
|
||||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
- 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
|
- 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
|
||||||
|
|
||||||
|
|||||||
+858
@@ -0,0 +1,858 @@
|
|||||||
|
# Palladium Wallet — User Guide
|
||||||
|
|
||||||
|
This guide covers **every user-facing feature** of Palladium Wallet (GUI and CLI), including
|
||||||
|
default values, validation rules, limits, and edge cases. It is written so that no behavior
|
||||||
|
has to be guessed: when the wallet enforces a rule, the rule is stated here with its exact
|
||||||
|
numbers.
|
||||||
|
|
||||||
|
Applies to version **0.9.x**. Where the GUI and the CLI differ, both are described.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [What Palladium Wallet is (and is not)](#1-what-palladium-wallet-is-and-is-not)
|
||||||
|
2. [Key concepts you must understand before holding funds](#2-key-concepts)
|
||||||
|
3. [Installation and platform differences](#3-installation-and-platform-differences)
|
||||||
|
4. [First launch: the setup wizard](#4-first-launch-the-setup-wizard)
|
||||||
|
5. [Opening, closing and managing multiple wallets](#5-opening-closing-and-managing-multiple-wallets)
|
||||||
|
6. [The main screen](#6-the-main-screen)
|
||||||
|
7. [Receiving funds](#7-receiving-funds)
|
||||||
|
8. [Sending funds](#8-sending-funds)
|
||||||
|
9. [Transaction history and details](#9-transaction-history-and-details)
|
||||||
|
10. [The Addresses tab](#10-the-addresses-tab)
|
||||||
|
11. [Contacts](#11-contacts)
|
||||||
|
12. [Connection, servers and certificate pinning](#12-connection-servers-and-certificate-pinning)
|
||||||
|
13. [Settings](#13-settings)
|
||||||
|
14. [Wallet Information (xpub, seed, fingerprint)](#14-wallet-information)
|
||||||
|
15. [Backup and recovery](#15-backup-and-recovery)
|
||||||
|
16. [Security model and known limitations](#16-security-model-and-known-limitations)
|
||||||
|
17. [Command-line interface (CLI)](#17-command-line-interface-cli)
|
||||||
|
18. [Troubleshooting and error messages](#18-troubleshooting-and-error-messages)
|
||||||
|
19. [Glossary](#19-glossary)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What Palladium Wallet is (and is not)
|
||||||
|
|
||||||
|
Palladium Wallet is a **self-custody SPV (Simplified Payment Verification) wallet** for the
|
||||||
|
Palladium (PLM) cryptocurrency, a Bitcoin-derived UTXO chain with 2-minute blocks. It runs on
|
||||||
|
Windows, Linux and Android from the same codebase, plus a separate command-line interface.
|
||||||
|
|
||||||
|
**Self-custody** means: *you* hold the keys. There is no account, no server-side backup, no
|
||||||
|
password recovery service. If you lose your seed phrase (and your wallet file plus its
|
||||||
|
password), your funds are permanently unrecoverable. Nobody — including the developer — can
|
||||||
|
restore them.
|
||||||
|
|
||||||
|
**SPV** means the wallet does **not** download or validate the full blockchain. It connects to
|
||||||
|
an **indexing server** (ElectrumX-compatible, ports 50001 TCP / 50002 SSL) and downloads only
|
||||||
|
the data relevant to your addresses. Every *confirmed* transaction the server reports is
|
||||||
|
independently verified with a **Merkle proof** against the block header; a server that lies
|
||||||
|
about confirmed transactions is detected and the synchronization aborts. See
|
||||||
|
[section 16](#16-security-model-and-known-limitations) for exactly what the server can and
|
||||||
|
cannot do to you.
|
||||||
|
|
||||||
|
What this wallet intentionally does **not** include (as of this version):
|
||||||
|
|
||||||
|
- Lightning Network
|
||||||
|
- Hardware-wallet integration
|
||||||
|
- Coin control (manual UTXO selection) — coin selection is always automatic
|
||||||
|
- RBF fee bumping (transactions *signal* RBF, but there is no "bump fee" button)
|
||||||
|
- Tor/proxy support — the indexing server can observe which addresses you query
|
||||||
|
- Fiat currency conversion
|
||||||
|
- Network selection in the GUI — the **GUI operates on mainnet only**; testnet and regtest
|
||||||
|
are available through the CLI (`--net`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Key concepts
|
||||||
|
|
||||||
|
Read this section once, carefully. Every rule below is enforced by the software exactly as
|
||||||
|
written.
|
||||||
|
|
||||||
|
### 2.1 The seed phrase (BIP39 mnemonic)
|
||||||
|
|
||||||
|
- When you create a new wallet in the GUI, it generates a **12-word English** BIP39 mnemonic.
|
||||||
|
It is shown **once**, during the wizard, and you must write it down **on paper, in order**.
|
||||||
|
- Whoever knows these words controls the funds — from any device, forever, with no further
|
||||||
|
information needed (unless you also set a passphrase, see below).
|
||||||
|
- When *restoring*, the wallet accepts any valid BIP39 mnemonic (12 or 24 words in the GUI
|
||||||
|
prompt; the parser also accepts 15/18/21-word mnemonics and auto-detects the wordlist
|
||||||
|
language). The checksum is validated: a mistyped word is rejected, not silently accepted.
|
||||||
|
|
||||||
|
### 2.2 The optional passphrase ("25th word")
|
||||||
|
|
||||||
|
During creation or restore you may set a **BIP39 passphrase**. Understand precisely what it
|
||||||
|
does:
|
||||||
|
|
||||||
|
- The passphrase is combined with the seed words to derive the keys. Seed + passphrase
|
||||||
|
produce a **completely different wallet** than the same seed without a passphrase (or with
|
||||||
|
any other passphrase, including one differing by a single character or by case).
|
||||||
|
- There is **no validity check possible**: entering the "wrong" passphrase during a restore
|
||||||
|
does not produce an error — it silently opens a different, empty wallet. If you restore and
|
||||||
|
see a zero balance where you expected funds, the first thing to check is the passphrase
|
||||||
|
(then the script type, see 2.3).
|
||||||
|
- Store the passphrase **separately** from the seed words. Losing the passphrase makes the
|
||||||
|
funds unrecoverable even if you still have the 12 words.
|
||||||
|
|
||||||
|
### 2.3 Script type (address format)
|
||||||
|
|
||||||
|
Each wallet is created with exactly one **script type**, which determines the address format
|
||||||
|
and the derivation path. It cannot be changed later; to switch, create a new wallet and move
|
||||||
|
the funds.
|
||||||
|
|
||||||
|
| Script type | Standard | Derivation | Mainnet addresses look like |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Legacy | BIP44 | `m/44'/746'/0'` | start with `P` |
|
||||||
|
| Wrapped SegWit | BIP49 | `m/49'/746'/0'` | start with `3` |
|
||||||
|
| **Native SegWit** (default, recommended) | BIP84 | `m/84'/746'/0'` | start with `plm1q` |
|
||||||
|
| Taproot | BIP86 | `m/86'/746'/0'` | start with `plm1p` |
|
||||||
|
|
||||||
|
**Restoring with the wrong script type is the same trap as the wrong passphrase**: the seed
|
||||||
|
is accepted, but different addresses are derived and the balance shows zero. When restoring,
|
||||||
|
select the same script type the wallet was created with. If unsure, try Native SegWit first
|
||||||
|
(the default), then the others.
|
||||||
|
|
||||||
|
The coin type in the derivation path is **746** on mainnet (1 on testnet/regtest).
|
||||||
|
|
||||||
|
### 2.4 Wallet types
|
||||||
|
|
||||||
|
| Type | Created via | Can sign/spend | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| HD (BIP39 seed) | Create new / Restore from seed | Yes | The normal case |
|
||||||
|
| HD (imported xprv) | Import extended key (xprv/yprv/zprv) | Yes | Full account key, no seed words |
|
||||||
|
| Watch-only (xpub) | Import extended key (xpub/ypub/zpub) | **No** | Sees balances/history; cannot sign. In the GUI it can *prepare* a transaction preview but not sign or broadcast it. The CLI produces an unsigned PSBT for offline signing |
|
||||||
|
| Imported WIF keys | Import WIF key(s) | Yes | Fixed address list, **no HD derivation and no change chain — change always returns to the first imported address** (this links your coins together; prefer an HD wallet for privacy) |
|
||||||
|
|
||||||
|
The extended-key format is **SLIP-132**: `xpub`/`xprv` (Legacy and Taproot), `ypub`/`yprv`
|
||||||
|
(Wrapped SegWit), `zpub`/`zprv` (Native SegWit). The script type is auto-detected from the
|
||||||
|
prefix when importing.
|
||||||
|
|
||||||
|
### 2.5 The wallet file and its encryption
|
||||||
|
|
||||||
|
Everything the wallet knows is stored in **one file per wallet**:
|
||||||
|
`<data folder>/mainnet/wallets/<name>.wallet.json` (default name `default.wallet.json`).
|
||||||
|
It contains the seed (or xpub/xprv/WIF keys), the derivation settings, your **contacts**, and
|
||||||
|
a sync cache (balances, history, verified proofs).
|
||||||
|
|
||||||
|
- If you set a password, the file is encrypted with **AES-256-GCM**; the key is derived with
|
||||||
|
**PBKDF2-HMAC-SHA512, 600,000 iterations, random 16-byte salt** (fresh salt and nonce on
|
||||||
|
every save). Tampering with the file is detected before decryption.
|
||||||
|
- If you **decline encryption** (the wizard checkbox, or omitting `--password` in the CLI),
|
||||||
|
**the seed is stored in plaintext on disk**. The wizard warns you explicitly. Only do this
|
||||||
|
on a disk you fully trust (e.g. an encrypted volume).
|
||||||
|
- There are **no password strength requirements and no attempt limits**. An empty password is
|
||||||
|
not allowed when encryption is enabled. Choose a strong password yourself: an attacker who
|
||||||
|
copies the file can try passwords offline at their leisure.
|
||||||
|
- There is currently **no "change password" function** in the GUI. To change the password,
|
||||||
|
restore the wallet from its seed into a new wallet file with the new password, verify the
|
||||||
|
new file opens and syncs, then delete the old file. (Contacts must be re-entered — see
|
||||||
|
[section 15](#15-backup-and-recovery).)
|
||||||
|
|
||||||
|
### 2.6 Confirmations and spendability
|
||||||
|
|
||||||
|
Incoming funds are not immediately spendable. The exact rules on mainnet:
|
||||||
|
|
||||||
|
- **Unconfirmed (mempool)** transactions: shown as *"pending confirmation … not yet
|
||||||
|
spendable"*. Never spendable, and — important — unconfirmed amounts are reported by the
|
||||||
|
server **without cryptographic proof**, so treat them as provisional until confirmed.
|
||||||
|
- **Regular outputs**: spendable after **6 confirmations** (~12 minutes at 2-minute blocks).
|
||||||
|
- **Mining (coinbase) outputs**: spendable after **121 confirmations** (~4 hours). Until
|
||||||
|
then they appear as *"maturing … not yet spendable"*.
|
||||||
|
|
||||||
|
The main balance shown is *confirmed minus immature*; pending and maturing amounts are shown
|
||||||
|
as separate amber lines under the balance when present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Installation and platform differences
|
||||||
|
|
||||||
|
### 3.1 Desktop (Windows / Linux)
|
||||||
|
|
||||||
|
Release binaries are self-contained — no runtime to install. Run the single executable
|
||||||
|
(`PalladiumWallet.exe` on Windows, `PalladiumWallet` on Linux).
|
||||||
|
|
||||||
|
### 3.2 Android
|
||||||
|
|
||||||
|
Install the `.apk` (sideloading must be allowed). Minimum Android version: 6.0 (API 23).
|
||||||
|
The app requests the **camera** permission only if you use the QR scanner; **internet** is
|
||||||
|
required for synchronization.
|
||||||
|
|
||||||
|
> **Updating in place**: releases built with the project's official signing key update over
|
||||||
|
> the installed app. If Android refuses to install an update ("app not installed" /
|
||||||
|
> signature mismatch), the new apk was signed with a different key — you would have to
|
||||||
|
> uninstall first, **which deletes the wallet file**. Back up your seed before uninstalling,
|
||||||
|
> always.
|
||||||
|
|
||||||
|
### 3.3 What differs between desktop and Android
|
||||||
|
|
||||||
|
| Aspect | Desktop | Android |
|
||||||
|
|---|---|---|
|
||||||
|
| Data location | Chosen at first run (default or custom folder) | Fixed: the app's private sandbox (step skipped) |
|
||||||
|
| Menu bar | File / Network / Settings / Wallet / Help | No menu bar; same functions reachable from the UI |
|
||||||
|
| Close overlays | `Esc` key or click the dark backdrop | Hardware/gesture **Back** button or tap the backdrop |
|
||||||
|
| QR code **scanning** (Send) | Not available | Camera scan button in the Send form |
|
||||||
|
| CLI | Available | Not available |
|
||||||
|
|
||||||
|
Everything else — wallet formats, security, sync, features — is identical.
|
||||||
|
|
||||||
|
### 3.4 Where your data lives (desktop)
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. A `palladium-data/` folder next to the executable, if present (**portable mode** — create
|
||||||
|
the folder manually before first launch to get a fully portable install).
|
||||||
|
2. The custom folder you picked in the first-run wizard (remembered via a small pointer file
|
||||||
|
in the default location).
|
||||||
|
3. The default: `%APPDATA%\PalladiumWallet` on Windows, `~/.palladium-wallet` on Linux.
|
||||||
|
|
||||||
|
Inside the data root: `mainnet/wallets/*.wallet.json` (your wallets),
|
||||||
|
`mainnet/server-certs.json` (pinned TLS certificates), `mainnet/servers.json` (discovered
|
||||||
|
servers), `config.json` (language, unit, last server — shared across wallets).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. First launch: the setup wizard
|
||||||
|
|
||||||
|
### 4.1 Step: data location (desktop only, very first run)
|
||||||
|
|
||||||
|
Choose where the wallet stores its data: **"Use the default path"** (shown on screen) or
|
||||||
|
**"Choose a folder…"** (native folder picker). This step never reappears once configured.
|
||||||
|
On Android it is skipped entirely.
|
||||||
|
|
||||||
|
### 4.2 Step: start
|
||||||
|
|
||||||
|
Five options:
|
||||||
|
|
||||||
|
1. **Open existing wallet** — see [section 5](#5-opening-closing-and-managing-multiple-wallets).
|
||||||
|
2. **Create a new wallet**
|
||||||
|
3. **Restore from seed**
|
||||||
|
4. **Import xpub / xprv**
|
||||||
|
5. **Import WIF key**
|
||||||
|
|
||||||
|
### 4.3 Creating a new wallet
|
||||||
|
|
||||||
|
1. **Your seed (12 words)** — the freshly generated mnemonic is displayed. Write the words
|
||||||
|
on paper, in order. *They are never shown in full again except through the password-gated
|
||||||
|
"Show seed" function ([section 14](#14-wallet-information)).* Do not photograph them, do
|
||||||
|
not store them in a cloud note.
|
||||||
|
2. **Confirm seed** — retype the 12 words, space-separated. Comparison is case-insensitive
|
||||||
|
and tolerant of extra spaces. A mismatch shows *"The words do not match: check what you
|
||||||
|
wrote on paper."* and lets you retry.
|
||||||
|
3. **Optional passphrase** — leave empty to skip. Re-read [section 2.2](#22-the-optional-passphrase-25th-word)
|
||||||
|
before setting one.
|
||||||
|
4. **Script type** — default Native SegWit. If unsure, keep the default.
|
||||||
|
5. **Password step** — see 4.7.
|
||||||
|
|
||||||
|
### 4.4 Restoring from seed
|
||||||
|
|
||||||
|
1. Enter the mnemonic, words separated by spaces. Invalid words or a bad checksum produce
|
||||||
|
*"Invalid mnemonic (wrong words or checksum): check again."*
|
||||||
|
2. Enter the **same passphrase** used originally (empty if none was used).
|
||||||
|
3. Select the **same script type** used originally.
|
||||||
|
4. Password step (4.7).
|
||||||
|
|
||||||
|
After the first synchronization the balance and full history reappear. If the balance is
|
||||||
|
zero when it shouldn't be: wrong passphrase or wrong script type
|
||||||
|
([sections 2.2–2.3](#22-the-optional-passphrase-25th-word)).
|
||||||
|
|
||||||
|
### 4.5 Importing an extended key (xpub / xprv)
|
||||||
|
|
||||||
|
Paste a SLIP-132 extended key:
|
||||||
|
|
||||||
|
- **Public key** (`xpub`/`ypub`/`zpub`) → **watch-only** wallet: monitors balances and
|
||||||
|
history, cannot spend.
|
||||||
|
- **Private key** (`xprv`/`yprv`/`zprv`) → fully spendable HD wallet (account level, no seed
|
||||||
|
words).
|
||||||
|
|
||||||
|
The script type is detected automatically from the key prefix and shown for confirmation
|
||||||
|
(e.g. *"NativeSegwit (watch-only)"*). An unrecognized key shows *"Extended key not
|
||||||
|
recognised for this network."* — check that it is a Palladium key of a supported format, not
|
||||||
|
a key from another coin.
|
||||||
|
|
||||||
|
### 4.6 Importing WIF private keys
|
||||||
|
|
||||||
|
Paste one or more WIF keys, **one per line**. Each key controls exactly one address; there
|
||||||
|
is no HD derivation. Select the script type (which address form the keys map to), then the
|
||||||
|
password step. Remember the change-address caveat from
|
||||||
|
[section 2.4](#24-wallet-types).
|
||||||
|
|
||||||
|
### 4.7 The password step (all flows)
|
||||||
|
|
||||||
|
- **Wallet name** (optional): letters/numbers recommended; invalid filesystem characters are
|
||||||
|
replaced with `_`. Left blank → automatic name (`default`, then `wallet-2`, `wallet-3`, …).
|
||||||
|
A name that already exists is rejected: *"A wallet with this name already exists."*
|
||||||
|
- **"Encrypt the wallet file with the password"** — checked by default. If you uncheck it,
|
||||||
|
the file (including the seed) is written **in plaintext** and the UI warns you.
|
||||||
|
- **Password + confirmation** — must match and be non-empty when encryption is on. No other
|
||||||
|
rules are imposed; strength is your responsibility.
|
||||||
|
- **Create wallet** finalizes: the file is written, locked against concurrent access, opened,
|
||||||
|
and the first connection/sync starts automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Opening, closing and managing multiple wallets
|
||||||
|
|
||||||
|
- You can keep **any number of wallets**: each is a separate `*.wallet.json` file in the
|
||||||
|
wallets folder. With more than one file, "Open existing wallet" shows a chooser list; with
|
||||||
|
exactly one, it goes straight to the password prompt.
|
||||||
|
- **Password prompt**: leave empty for an unencrypted wallet. Wrong password → *"Wrong
|
||||||
|
password."* — retries are unlimited.
|
||||||
|
- The GUI **only lists wallets inside its own wallets folder**. To use a wallet file from
|
||||||
|
elsewhere, copy it into `<data folder>/mainnet/wallets/` first (while the app is closed,
|
||||||
|
or before pressing Open).
|
||||||
|
- **Single-instance lock**: a wallet file can be open in only one running instance at a
|
||||||
|
time. A second attempt fails with *"Wallet already open in another instance of the
|
||||||
|
application."* A stale `.lock` file left by a crash is released automatically when the
|
||||||
|
owning process is gone.
|
||||||
|
- **Switching wallets**: menu *File → Close wallet* (returns to the wizard), then *Open
|
||||||
|
existing wallet*. *File → New / restore wallet…* also closes the current wallet first.
|
||||||
|
- **Deleting a wallet**: there is no in-app delete. Close the app and delete the
|
||||||
|
`.wallet.json` file manually — **only after verifying you have the seed on paper** (or,
|
||||||
|
for imported wallets, the original keys). Deleting the file of an unbacked-up wallet
|
||||||
|
destroys the funds' keys.
|
||||||
|
|
||||||
|
After opening, the header shows the wallet's identity, e.g.
|
||||||
|
`Mainnet · NativeSegwit · m/84'/746'/0'`, with a ` · watch-only` or ` · imported` tag when
|
||||||
|
applicable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The main screen
|
||||||
|
|
||||||
|
Five tabs: **History**, **Send**, **Receive**, **Addresses**, **Contacts**.
|
||||||
|
|
||||||
|
- **Balance** (top): confirmed, spendable balance in your chosen unit. Below it, when
|
||||||
|
applicable, two amber advisory lines: *pending confirmation* (mempool, unproven, not
|
||||||
|
spendable) and *maturing* (young coinbase, not spendable — see
|
||||||
|
[section 2.6](#26-confirmations-and-spendability)).
|
||||||
|
- **Status bar** (bottom): left, the sync status (e.g. *"Synchronized: height 123456, 42
|
||||||
|
SPV-verified transactions. Real-time updates active."*); right, the **connection
|
||||||
|
indicator** (*connected host:port* / *connecting…* / *not connected* / *reconnecting…*).
|
||||||
|
**Click/tap the connection indicator to open the server settings.**
|
||||||
|
- All secondary screens (settings, details, wallet info, help) are **in-app overlays**, not
|
||||||
|
separate windows. Close them with the ✕ button, by clicking the dark backdrop, with `Esc`
|
||||||
|
(desktop) or Back (Android).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Receiving funds
|
||||||
|
|
||||||
|
The **Receive** tab always shows the **next unused receive address**, as text and as a QR
|
||||||
|
code (the QR encodes the bare address).
|
||||||
|
|
||||||
|
- **Copy** puts the address on the clipboard (*"Address copied to clipboard"*).
|
||||||
|
- Give a **fresh address to each payer**. After an address receives a payment and a sync
|
||||||
|
runs, the tab automatically advances to the next unused address. Reusing addresses is not
|
||||||
|
blocked, but it degrades your privacy by linking payments together.
|
||||||
|
- Incoming payments appear in History **at the next synchronization** — normally within
|
||||||
|
seconds, because the wallet subscribes to server push notifications. A payment shows as
|
||||||
|
*mempool* first, then with its block height once mined.
|
||||||
|
- The address format is fixed by the wallet's script type
|
||||||
|
([section 2.3](#23-script-type-address-format)); senders must support that format (in
|
||||||
|
particular, very old software may not send to `plm1…` bech32 addresses).
|
||||||
|
|
||||||
|
**Gap limit — the one receiving rule you must know.** The wallet derives addresses on
|
||||||
|
demand and, when restoring from seed, scans forward until it finds **20 consecutive unused
|
||||||
|
addresses** and then stops. Consequence: if you hand out many addresses that never receive
|
||||||
|
funds, do not skip ahead by more than 20 — a payment to address #30, with #10–#29 unused,
|
||||||
|
would **not be found after a restore from seed**. Under normal use (addresses handed out
|
||||||
|
sequentially, most getting used) this never triggers. The limit (20) is stored per wallet
|
||||||
|
and is not editable in the GUI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sending funds
|
||||||
|
|
||||||
|
Prerequisites: the wallet must be **connected and synchronized** (otherwise: *"Connect to
|
||||||
|
the server and synchronize before sending"* / *"Synchronize before sending"*), and the
|
||||||
|
wallet must be spendable (not watch-only) to actually broadcast.
|
||||||
|
|
||||||
|
### 8.1 The form
|
||||||
|
|
||||||
|
- **From contacts** — optional dropdown that fills the recipient address from your address
|
||||||
|
book.
|
||||||
|
- **Recipient address** — validated when you prepare: an address of the wrong network or
|
||||||
|
with a typo is rejected (checksums make silent typos virtually impossible).
|
||||||
|
- **Amount** — interpreted in the **currently selected display unit** (PLM by default — check
|
||||||
|
the unit in Settings before typing!). Rejected if negative, malformed, or more precise
|
||||||
|
than 1 satoshi (0.00000001 PLM).
|
||||||
|
- **Send all** — checkbox; disables the amount field and sends the entire spendable balance,
|
||||||
|
**with the fee deducted from the amount** (the recipient receives balance − fee).
|
||||||
|
- **Fee (sat/vB)** — a single manually-entered fee rate. **Default: 1 sat/vB.** There are no
|
||||||
|
presets and no automatic fee estimation. On the Palladium chain, blocks are rarely full,
|
||||||
|
so 1 sat/vB normally confirms in the next few blocks; raise it if a transaction lingers in
|
||||||
|
the mempool. Must be a number greater than zero.
|
||||||
|
- **QR scan** (Android only) — camera button; reads a plain address or a
|
||||||
|
`palladium:` payment URI (the address part is used).
|
||||||
|
|
||||||
|
### 8.2 Prepare, then confirm
|
||||||
|
|
||||||
|
Sending is a **two-step** operation:
|
||||||
|
|
||||||
|
1. **Prepare transaction** — builds and signs the transaction locally, without sending
|
||||||
|
anything. The summary card shows the txid (truncated), the **exact fee**, and the virtual
|
||||||
|
size, e.g. `txid 4f3a…| fee 0.00000141 PLM (141 vB)`. Nothing has left your wallet yet;
|
||||||
|
you can change the fields and prepare again, or simply navigate away to abandon it.
|
||||||
|
2. **CONFIRM AND BROADCAST** — transmits the prepared transaction to the server. Success
|
||||||
|
shows the full txid; the form clears and a re-sync starts. After broadcasting, **a
|
||||||
|
transaction cannot be cancelled** — it is in the network's mempool and will confirm.
|
||||||
|
|
||||||
|
### 8.3 How the transaction is built (facts you may need)
|
||||||
|
|
||||||
|
- **Coin selection is automatic** (there is no coin control). The wallet spends from its
|
||||||
|
spendable UTXOs and sends any **change back to its own internal change chain** — this is
|
||||||
|
why, in transaction details, one output to an address you don't recognize as "yours" may
|
||||||
|
still be marked as yours: it is your change.
|
||||||
|
- All transactions **signal RBF** (BIP125) and use transaction version 2. The wallet does
|
||||||
|
not offer fee bumping; if you underpay the fee, wait — with RBF signaled, other wallets
|
||||||
|
could in principle replace it, but this wallet has no UI for that.
|
||||||
|
- **Dust change is absorbed into the fee**: if the change output would be uneconomically
|
||||||
|
small, it is added to the fee instead of creating a dust output. The fee shown in the
|
||||||
|
summary already accounts for this — always check the summary fee before confirming.
|
||||||
|
- A prepared transaction is fully verified against standardness policy before it can be
|
||||||
|
confirmed; a policy failure surfaces as *"Invalid transaction: …"* instead of a broadcast.
|
||||||
|
|
||||||
|
### 8.4 Why "insufficient funds" can appear despite a visible balance
|
||||||
|
|
||||||
|
The error message itemizes the reasons. Funds may be temporarily unspendable because they
|
||||||
|
are:
|
||||||
|
|
||||||
|
- **in the mempool** (unconfirmed — never spendable),
|
||||||
|
- **recently confirmed** (fewer than 6 confirmations),
|
||||||
|
- **immature coinbase** (mining rewards younger than 121 blocks).
|
||||||
|
|
||||||
|
Wait for confirmations and retry. Also remember that the fee itself must fit: sending your
|
||||||
|
exact full balance fails unless you use **Send all**.
|
||||||
|
|
||||||
|
### 8.5 Watch-only wallets and offline signing
|
||||||
|
|
||||||
|
A watch-only (xpub) wallet in the **GUI** can prepare a transaction to preview the fee and
|
||||||
|
size — the summary is tagged *"unsigned (watch-only)"* — but the confirm button stays
|
||||||
|
disabled: **the GUI cannot export the unsigned transaction**. To actually use the
|
||||||
|
watch-only + offline-signing workflow, use the **CLI**, whose `send` command prints an
|
||||||
|
**unsigned PSBT (base64)** for a watch-only wallet
|
||||||
|
([section 17](#17-command-line-interface-cli)). Sign that PSBT on the offline machine with
|
||||||
|
software of your choice, then broadcast the finalized transaction.
|
||||||
|
|
||||||
|
### 8.6 The Donate tab (Help → Donate)
|
||||||
|
|
||||||
|
The Help overlay includes a donation form (developer address
|
||||||
|
`plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay`) using the same prepare/confirm flow and the
|
||||||
|
fee rate from the Send tab (falling back to 1 sat/vB if invalid). Entirely optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Transaction history and details
|
||||||
|
|
||||||
|
The **History** tab lists all wallet transactions, newest first; unconfirmed ones are
|
||||||
|
labeled **mempool** and sorted on top, confirmed ones show their **block height**. The
|
||||||
|
amount is the transaction's net effect on your wallet (`+` incoming).
|
||||||
|
|
||||||
|
**Double-click** (desktop) or **tap** (Android) a row to open the full details. This
|
||||||
|
requires a live server connection (details are fetched on demand): otherwise *"Connect to
|
||||||
|
the server to view transaction details."*
|
||||||
|
|
||||||
|
The details overlay shows:
|
||||||
|
|
||||||
|
- **Overview** — status (*"N confirmations (block H)"* or *"0 confirmations · in
|
||||||
|
mempool"*), local date/time, counterparty addresses, and the signed net amount. Mining
|
||||||
|
rewards show *"Newly generated (mining)"* as the sender.
|
||||||
|
- **Amounts & fees** — the absolute fee, your net amount, and the fee rate in sat/vB.
|
||||||
|
- **Technical details** — full transaction ID, total size, virtual size, **Replaceable
|
||||||
|
(RBF)** yes/no, version, locktime.
|
||||||
|
- **Inputs** — each spent outpoint with its address and amount; your own inputs are
|
||||||
|
highlighted. Coinbase inputs display the miner's **scriptSig message** when it is readable
|
||||||
|
text.
|
||||||
|
- **Outputs** — each output with index, address and amount; your own outputs (including
|
||||||
|
change) are highlighted. **OP_RETURN** outputs display their decoded text message when the
|
||||||
|
payload is valid readable text; non-standard outputs show their script type.
|
||||||
|
|
||||||
|
Notes and limits:
|
||||||
|
|
||||||
|
- Confirmation counts advance automatically as blocks arrive (real-time header
|
||||||
|
subscription).
|
||||||
|
- Every **confirmed** transaction in your history has been verified with a Merkle proof
|
||||||
|
during sync. **Unconfirmed** entries are the server's word only — do not treat a mempool
|
||||||
|
payment as received until it confirms.
|
||||||
|
- There are no per-transaction labels and no block-explorer links in this version. The
|
||||||
|
public explorer is at `https://explorer.palladium-coin.com/` — paste the txid there
|
||||||
|
manually if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. The Addresses tab
|
||||||
|
|
||||||
|
A complete table of every derived address: **type** (receive/change), **index**,
|
||||||
|
**address**, **balance**, and **transaction count**. Before the first sync it shows the
|
||||||
|
first 10 receive addresses with no data.
|
||||||
|
|
||||||
|
Tap (or right-click) a row to open the **address details** overlay:
|
||||||
|
|
||||||
|
- the full address and its **derivation path** (e.g. `m/84'/746'/0'/0/3`),
|
||||||
|
- the **public key** (hex),
|
||||||
|
- the **private key (WIF)**, hidden behind a **"Show"** button. For an encrypted wallet,
|
||||||
|
revealing it requires re-entering the wallet password (*"Enter the wallet password to
|
||||||
|
reveal the private key."*); for an unencrypted wallet it is shown directly. **Never
|
||||||
|
reveal a private key with anyone watching your screen** — one key controls that one
|
||||||
|
address's funds. Watch-only wallets have no private keys to show.
|
||||||
|
|
||||||
|
`change` addresses are where your own transactions send their change
|
||||||
|
([section 8.3](#83-how-the-transaction-is-built-facts-you-may-need)) — seeing balance on
|
||||||
|
them is normal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Contacts
|
||||||
|
|
||||||
|
A simple per-wallet address book, on its own tab:
|
||||||
|
|
||||||
|
- **Add**: name + address, both required (whitespace is trimmed). Note: the address format
|
||||||
|
is **not validated when saving** — it is only validated when you actually send to it. Copy
|
||||||
|
addresses carefully.
|
||||||
|
- **Remove selected**: deletes the highlighted contact. There is no edit function — remove
|
||||||
|
and re-add.
|
||||||
|
- Contacts are used by the **From contacts** dropdown on the Send tab.
|
||||||
|
- Contacts are stored **inside the wallet file**. They travel with a file backup, but are
|
||||||
|
**not** recoverable from the seed phrase ([section 15](#15-backup-and-recovery)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Connection, servers and certificate pinning
|
||||||
|
|
||||||
|
### 12.1 Server settings
|
||||||
|
|
||||||
|
Open by clicking the **connection indicator** in the status bar (or Settings → *Indexing
|
||||||
|
server…*, or menu *Network* on desktop). Fields:
|
||||||
|
|
||||||
|
- **Host** and **Port** — defaults: port **50001** (plain TCP) or **50002** (SSL). Editing
|
||||||
|
one of the known ports auto-toggles the SSL switch to match, and toggling SSL swaps the
|
||||||
|
port. Note these are the *indexing server* ports — not the Palladium node's P2P port
|
||||||
|
(2333), which this wallet never uses.
|
||||||
|
- **Use SSL** — default **on**. Strongly recommended: without SSL, traffic (including which
|
||||||
|
addresses you query) is readable on the wire.
|
||||||
|
- **Known servers** list — click an entry to select it. The wallet ships with four bootstrap
|
||||||
|
mainnet servers and remembers any servers learned via discovery.
|
||||||
|
- **Discover servers (peers)** — asks the connected server for its known peers and adds new
|
||||||
|
ones to the list.
|
||||||
|
|
||||||
|
Connection behavior: the wallet tries your selected server first, then walks the rest of
|
||||||
|
the known list until one answers. The last working server is remembered and reused at next
|
||||||
|
launch. A 20-second keep-alive ping detects drops and reconnects automatically;
|
||||||
|
server push notifications trigger instant re-syncs when a new block or a relevant
|
||||||
|
transaction appears (*"Real-time updates active"*).
|
||||||
|
|
||||||
|
### 12.2 TLS certificate pinning (TOFU) — read before "fixing" a certificate error
|
||||||
|
|
||||||
|
SSL connections use **Trust On First Use**: the first time you connect to a server, its
|
||||||
|
certificate's fingerprint is saved (in `server-certs.json`). Every later connection must
|
||||||
|
present the **same** certificate. If it doesn't, the connection is refused with a message
|
||||||
|
stating that the TLS certificate of that server **has changed**.
|
||||||
|
|
||||||
|
This is a security feature, not a bug. A changed certificate means one of two things:
|
||||||
|
|
||||||
|
1. **Benign**: the server operator renewed/rotated the certificate (common, e.g. Let's
|
||||||
|
Encrypt renewals).
|
||||||
|
2. **Hostile**: someone is intercepting your connection (man-in-the-middle).
|
||||||
|
|
||||||
|
You cannot distinguish the two from the wallet alone. If the change is expected or you can
|
||||||
|
verify it out-of-band, clear the pins: menu **Network → Reset SSL certificates** (desktop),
|
||||||
|
the **Reset certs** button in server settings, or `reset-certs` in the CLI. This deletes
|
||||||
|
**all** pinned certificates for the network; the next connection re-pins whatever it sees.
|
||||||
|
If you have any reason to suspect interception, switch networks (e.g. off a public Wi-Fi)
|
||||||
|
or servers instead of resetting.
|
||||||
|
|
||||||
|
### 12.3 What synchronization actually does
|
||||||
|
|
||||||
|
Each sync: gets the chain tip → scans your receive and change address chains (gap limit
|
||||||
|
20) → downloads your transactions → **verifies a Merkle proof for every confirmed
|
||||||
|
transaction** against its block header → rebuilds your UTXO set and balances locally.
|
||||||
|
On mainnet, each block header used for a proof is also chained back (via its previous-block
|
||||||
|
hash) to the nearest built-in checkpoint, so a server cannot substitute a fabricated header
|
||||||
|
for a real one. Verified data is cached in the wallet file, so later syncs only fetch what
|
||||||
|
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
|
||||||
|
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
|
||||||
|
unverified data; switch servers.
|
||||||
|
|
||||||
|
If the server is overloaded (busy responses), the wallet retries automatically up to 8
|
||||||
|
times with increasing back-off — a large wallet's first sync may take a little while, but it
|
||||||
|
resumes from the cache instead of restarting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Settings
|
||||||
|
|
||||||
|
The Settings overlay (gear icon / menu *Settings*) contains exactly two preferences, plus
|
||||||
|
the door to the server settings:
|
||||||
|
|
||||||
|
- **Language** — Italiano, English, Español, Français, Português, Deutsch. Applies
|
||||||
|
immediately. Default: English.
|
||||||
|
- **Amount unit** — how amounts are displayed *and interpreted when you type them*:
|
||||||
|
|
||||||
|
| Unit | In satoshi | Decimals shown |
|
||||||
|
|---|---|---|
|
||||||
|
| PLM (default) | 100,000,000 | 8 |
|
||||||
|
| mPLM | 100,000 | 5 |
|
||||||
|
| µPLM | 100 | 2 |
|
||||||
|
| sat | 1 | 0 |
|
||||||
|
|
||||||
|
⚠ The Send form's amount field uses this unit. If you switch to `sat` and forget, typing
|
||||||
|
`100` means 100 satoshi, not 100 PLM. Double-check the unit before sending.
|
||||||
|
|
||||||
|
Settings are saved globally (per data folder, shared by all wallets), not per wallet.
|
||||||
|
There is no theme selector (the app uses its built-in dark theme) and no fiat display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Wallet Information
|
||||||
|
|
||||||
|
Menu **Wallet → Wallet Information** (desktop) or the equivalent button. Shows:
|
||||||
|
|
||||||
|
- **File name**, **network**, **wallet type** (*HD (BIP39 seed)* / *HD (imported xprv)* /
|
||||||
|
*Imported WIF key* / *Watch-only (xpub)*), **script type**, **derivation path**.
|
||||||
|
- **Extended public key (xpub)** — displayed as selectable text. This is how you *export*
|
||||||
|
the xpub: select and copy it. Give the xpub to another device/app to create a
|
||||||
|
**watch-only** copy of this wallet — it reveals your entire address history and balance to
|
||||||
|
whoever holds it (privacy risk), but can never spend.
|
||||||
|
- **Master fingerprint** — 4-byte identifier of the master key, used in PSBT workflows.
|
||||||
|
- **BIP39 passphrase** — shows only whether one is *set*; the passphrase itself is never
|
||||||
|
displayed anywhere.
|
||||||
|
- **Show seed** — reveals the mnemonic, after re-entering the wallet password (for
|
||||||
|
encrypted wallets). Use it to re-verify your paper backup. The on-screen warning is
|
||||||
|
literal: *never share these words; whoever holds them controls the funds.* Watch-only and
|
||||||
|
imported wallets have no seed to show.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Backup and recovery
|
||||||
|
|
||||||
|
### 15.1 What to back up
|
||||||
|
|
||||||
|
There are two complementary backups; know exactly what each one restores:
|
||||||
|
|
||||||
|
| Backup | Restores | Does NOT restore |
|
||||||
|
|---|---|---|
|
||||||
|
| **Seed words on paper** (+ passphrase if set, + script type) | All keys, addresses, balance and on-chain history, on any device, forever | Contacts; wallet name; settings |
|
||||||
|
| **The wallet file** (+ its password) | Everything, including contacts | — |
|
||||||
|
|
||||||
|
Recommended: **always** have the paper seed (it survives disk failure, device loss, and
|
||||||
|
software obsolescence); *additionally* copy the `.wallet.json` file if you care about your
|
||||||
|
contacts list. An encrypted wallet file is safe to store on ordinary media — but it is only
|
||||||
|
as strong as its password, and it is useless without it.
|
||||||
|
|
||||||
|
For a **watch-only** wallet, the file only restores the watching capability; the actual
|
||||||
|
keys live wherever you keep the corresponding seed/xprv — back *that* up.
|
||||||
|
|
||||||
|
For an **imported WIF** wallet, back up the WIF keys themselves; they are not derivable from
|
||||||
|
anything else.
|
||||||
|
|
||||||
|
### 15.2 Restoring, step by step
|
||||||
|
|
||||||
|
1. Install the wallet on the new device.
|
||||||
|
2. Wizard → **Restore from seed** → enter the words → enter the **same passphrase** (or
|
||||||
|
leave empty if none) → choose the **same script type** → set a (new) password.
|
||||||
|
3. Let it synchronize. Balance and history are rebuilt from the chain.
|
||||||
|
|
||||||
|
If the balance is zero: wrong passphrase, or wrong script type, or (rare) funds beyond the
|
||||||
|
gap limit ([section 7](#7-receiving-funds)). Nothing is lost — the coins are still on the
|
||||||
|
chain; you are simply looking at the wrong derived wallet. Retry with the right parameters.
|
||||||
|
|
||||||
|
Restoring from a **file copy** instead: place the `.wallet.json` into
|
||||||
|
`<data folder>/mainnet/wallets/` and open it with its password.
|
||||||
|
|
||||||
|
### 15.3 Unrecoverable situations — be aware
|
||||||
|
|
||||||
|
- Seed lost **and** wallet file (or its password) lost → funds gone. No exceptions.
|
||||||
|
- Passphrase forgotten → funds gone, even with the 12 words in hand.
|
||||||
|
- Encrypted file's password forgotten, no seed backup → funds gone (the encryption has no
|
||||||
|
backdoor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Security model and known limitations
|
||||||
|
|
||||||
|
A condensed version of the project's published threat model (`SECURITY.md`).
|
||||||
|
|
||||||
|
**The wallet protects you against:**
|
||||||
|
|
||||||
|
- Theft of the wallet file at rest (AES-256-GCM + PBKDF2, [section 2.5](#25-the-wallet-file-and-its-encryption)) —
|
||||||
|
provided you set a good password.
|
||||||
|
- A **lying indexing server**: it cannot fabricate a confirmed transaction or forge a
|
||||||
|
payment to a wrong address, because every confirmed transaction must carry a valid Merkle
|
||||||
|
proof, which the wallet checks itself.
|
||||||
|
- **Silent TLS interception after first contact** (certificate pinning,
|
||||||
|
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
|
||||||
|
|
||||||
|
**The server can still (semi-trusted component):**
|
||||||
|
|
||||||
|
- lie about **unconfirmed** transactions (which is why they are marked not-spendable and
|
||||||
|
should not be trusted until confirmed);
|
||||||
|
- **withhold** information — hide transactions, delay new blocks, refuse to broadcast;
|
||||||
|
- **observe** which addresses belong to you (no Tor/proxy support in this version; using
|
||||||
|
your own indexing server is the strongest mitigation).
|
||||||
|
|
||||||
|
**The wallet cannot protect you against:**
|
||||||
|
|
||||||
|
- malware on your device (anything that can read process memory can take the keys once the
|
||||||
|
wallet is unlocked);
|
||||||
|
- an attacker holding both your wallet file **and** its password (or your seed);
|
||||||
|
- yourself: there are no confirmation "cool-downs", no address whitelists, and broadcast is
|
||||||
|
irreversible.
|
||||||
|
|
||||||
|
**Honest limitations of this version** (also listed in [section 1](#1-what-palladium-wallet-is-and-is-not)):
|
||||||
|
single server connection (no pooling), no coin control, no fee estimation, no RBF bumping,
|
||||||
|
no hardware wallets, no Tor, GUI mainnet-only, and PSBT export only via the CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Command-line interface (CLI)
|
||||||
|
|
||||||
|
The CLI (`src/Cli`) runs on the same core as the GUI: same wallet files, same validation,
|
||||||
|
same security. It is desktop-only and its console output is in English. Run it
|
||||||
|
as `dotnet run --project src/Cli -- <command>` from the repository (or the published `Cli`
|
||||||
|
binary); with no arguments it prints usage.
|
||||||
|
|
||||||
|
**Shared conventions.** Default wallet file:
|
||||||
|
`~/.palladium-wallet/<network>/wallets/default.wallet.json` — override with `--file PATH`.
|
||||||
|
Default network mainnet — override with `--net testnet|regtest` (the CLI is the only way to
|
||||||
|
use test networks). `--password P` both decrypts an existing file and encrypts on save;
|
||||||
|
**omitting it on `create`/`restore` writes the seed in plaintext** (a warning is printed).
|
||||||
|
`--kind legacy|wrapped|segwit` selects the script type (default Native SegWit — note that
|
||||||
|
only the literal values `legacy` and `wrapped` switch away from the default). Errors print
|
||||||
|
to stderr and set exit code 1.
|
||||||
|
|
||||||
|
### Wallet commands
|
||||||
|
|
||||||
|
```
|
||||||
|
newseed [--words 24]
|
||||||
|
```
|
||||||
|
Prints a fresh mnemonic (12 words unless `--words 24`) and writes nothing to disk.
|
||||||
|
|
||||||
|
```
|
||||||
|
create [--words 24] [--kind K] [--net N] [--passphrase W] [--password P] [--file PATH] [--path m/...]
|
||||||
|
```
|
||||||
|
Generates a mnemonic, prints it once, saves the wallet, prints the file path and the first
|
||||||
|
receive address. `--path` overrides the derivation path (advanced; you must remember it to
|
||||||
|
restore).
|
||||||
|
|
||||||
|
```
|
||||||
|
restore "<mnemonic>" [same options as create]
|
||||||
|
```
|
||||||
|
Restores from an existing mnemonic (quoted, words space-separated).
|
||||||
|
|
||||||
|
```
|
||||||
|
restore-xpub <slip132-key> [--net N] [--password P] [--file PATH]
|
||||||
|
```
|
||||||
|
Creates a **watch-only** wallet from an xpub/ypub/zpub; the script type is inferred from
|
||||||
|
the key prefix.
|
||||||
|
|
||||||
|
```
|
||||||
|
info [--net N] [--password P] [--file PATH] [--addresses]
|
||||||
|
```
|
||||||
|
Prints the wallet's identity and, if it has ever synced, the balance breakdown (spendable /
|
||||||
|
maturing / pending), tip height, transaction count and next receive address.
|
||||||
|
`--addresses` adds the per-address list with balances.
|
||||||
|
|
||||||
|
```
|
||||||
|
addresses "<mnemonic>" [--kind K] [--net N] [--count N] [--passphrase W] [--path m/...]
|
||||||
|
```
|
||||||
|
Stateless tool: derives and prints receive (default 5) and change addresses from a mnemonic
|
||||||
|
without creating any file. Useful to check "which script type was this seed used with"
|
||||||
|
before restoring.
|
||||||
|
|
||||||
|
### Network commands
|
||||||
|
|
||||||
|
```
|
||||||
|
sync [--server host[:port]] [--ssl] [--net N] [--password P] [--file PATH]
|
||||||
|
```
|
||||||
|
Connects (default: the first known server; port defaults to 50001, or 50002 with `--ssl`),
|
||||||
|
synchronizes with full Merkle verification, persists the cache into the wallet file, and
|
||||||
|
prints balance and history. Transactions the server reported but that are unconfirmed are
|
||||||
|
marked as unverified.
|
||||||
|
|
||||||
|
```
|
||||||
|
send --to ADDRESS (--amount X | --all) [--feerate R] [--broadcast]
|
||||||
|
[--server ...] [--ssl] [--net N] [--password P] [--file PATH]
|
||||||
|
```
|
||||||
|
Builds a transaction from the synced cache (`sync` must have been run first). `--amount` is
|
||||||
|
in **PLM**; `--all` sends everything minus the fee. `--feerate` is in sat/vB, **default 1**.
|
||||||
|
Behavior by wallet type and flags:
|
||||||
|
|
||||||
|
- **Watch-only** wallet → prints the **unsigned PSBT in base64** and stops. This is the
|
||||||
|
air-gapped workflow: carry the PSBT to the offline signer, sign it there, broadcast the
|
||||||
|
final transaction by any means.
|
||||||
|
- Spendable wallet, **without `--broadcast`** → signs and prints the raw transaction hex,
|
||||||
|
explicitly marked as *not transmitted*. Nothing is sent — a dry run you can inspect or
|
||||||
|
broadcast elsewhere.
|
||||||
|
- Spendable wallet, **with `--broadcast`** → signs, transmits, prints the txid. Irreversible
|
||||||
|
from this point.
|
||||||
|
|
||||||
|
```
|
||||||
|
servers [--discover] [--server ...] [--ssl] [--net N]
|
||||||
|
```
|
||||||
|
Lists known servers; with `--discover`, connects and asks the server for its peers, adding
|
||||||
|
new ones.
|
||||||
|
|
||||||
|
```
|
||||||
|
reset-certs [--net N]
|
||||||
|
```
|
||||||
|
Deletes all pinned TLS certificates for the network (the CLI counterpart of *Network →
|
||||||
|
Reset SSL certificates* — see
|
||||||
|
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Troubleshooting and error messages
|
||||||
|
|
||||||
|
| Symptom / message | Meaning | What to do |
|
||||||
|
|---|---|---|
|
||||||
|
| *Wrong password.* | The password does not decrypt the file (or the file was tampered with — the two are indistinguishable by design). | Retry; check keyboard layout/caps lock. If truly lost, restore from seed ([15.2](#152-restoring-step-by-step)). |
|
||||||
|
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
|
||||||
|
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
|
||||||
|
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
|
||||||
|
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
|
||||||
|
| Restored wallet shows zero balance | Wrong passphrase, wrong script type, or funds beyond the gap limit. | See [15.2](#152-restoring-step-by-step) — the coins are not lost. |
|
||||||
|
| *Invalid mnemonic (wrong words or checksum)* | A word is misspelled, not on the BIP39 list, or the checksum fails. | Compare against your paper backup, word by word. |
|
||||||
|
| *Extended key not recognised for this network.* | The pasted xpub/xprv is not a SLIP-132 key of this network. | Check the key's prefix and the source wallet's export format. |
|
||||||
|
| *A wallet with this name already exists.* | Filename collision in the wallets folder. | Pick another name, or leave blank for an automatic one. |
|
||||||
|
| Payment sent to me doesn't appear | Not yet synced/connected, or sender hasn't broadcast. | Check the connection indicator; mempool entries appear within seconds of broadcast when connected. |
|
||||||
|
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
|
||||||
|
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
|
||||||
|
| First sync is slow / server busy errors | Server throttling; the wallet retries automatically (up to 8 attempts, growing back-off). | Wait; progress is cached, so restarting resumes rather than repeats. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Glossary
|
||||||
|
|
||||||
|
- **SPV** — Simplified Payment Verification: validating that transactions are included in
|
||||||
|
blocks via Merkle proofs, without running a full node.
|
||||||
|
- **Seed / mnemonic** — the 12 (or 24) BIP39 words that deterministically generate every key
|
||||||
|
in the wallet.
|
||||||
|
- **Passphrase (BIP39)** — optional extra secret combined with the seed; a different
|
||||||
|
passphrase yields a different wallet.
|
||||||
|
- **Script type** — the address format standard (Legacy/BIP44, Wrapped SegWit/BIP49, Native
|
||||||
|
SegWit/BIP84, Taproot/BIP86).
|
||||||
|
- **xpub / xprv** — extended public/private key of the wallet's account; the xpub watches,
|
||||||
|
the xprv spends. SLIP-132 variants: ypub/zpub etc.
|
||||||
|
- **WIF** — Wallet Import Format: a single address's private key as text.
|
||||||
|
- **UTXO** — Unspent Transaction Output: a discrete "coin" the wallet can spend.
|
||||||
|
- **Change** — the portion of spent UTXOs returned to your own (internal) addresses.
|
||||||
|
- **Coinbase** — the transaction paying the miner of a block; spendable after 121
|
||||||
|
confirmations.
|
||||||
|
- **Mempool** — the set of broadcast-but-unconfirmed transactions.
|
||||||
|
- **Gap limit** — how many consecutive unused addresses the wallet scans past before
|
||||||
|
stopping (20).
|
||||||
|
- **PSBT** — Partially Signed Bitcoin Transaction: the standard interchange format for
|
||||||
|
signing a transaction on a device other than the one that built it.
|
||||||
|
- **RBF** — Replace-By-Fee: a signal that a transaction may be replaced by a higher-fee
|
||||||
|
version while unconfirmed.
|
||||||
|
- **TOFU** — Trust On First Use: pinning a server's TLS certificate at first contact and
|
||||||
|
refusing silent changes afterwards.
|
||||||
|
- **Watch-only** — a wallet holding only public keys: full visibility, zero spending
|
||||||
|
ability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This guide documents observed behavior of the software and is kept in sync with the code.
|
||||||
|
For the formal threat model see [SECURITY.md](SECURITY.md); for per-release changes see
|
||||||
|
[CHANGELOG.md](CHANGELOG.md).*
|
||||||
@@ -403,6 +403,24 @@ public sealed class Loc
|
|||||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
"Certificados SSL redefinidos: tente novamente a conexão.",
|
||||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
||||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
||||||
|
["msg.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler"],
|
||||||
|
["msg.peer.discovery.error"] = ["Errore nella scoperta peer", "Peer discovery error", "Error al descubrir peers", "Erreur de découverte des pairs", "Erro na descoberta de peers", "Fehler bei der Peer-Suche"],
|
||||||
|
["msg.peer.discovery.found"] = ["Trovati {0} nuovi server dai peer (totale {1}).", "Found {0} new servers from peers (total {1}).", "Se encontraron {0} nuevos servidores de peers (total {1}).", "{0} nouveaux serveurs trouvés via les pairs (total {1}).", "Encontrados {0} novos servidores dos peers (total {1}).", "{0} neue Server von Peers gefunden (insgesamt {1})."],
|
||||||
|
["msg.peer.discovery.none"] = ["Nessun nuovo server annunciato (totale {0}).", "No new servers announced (total {0}).", "No se anunciaron nuevos servidores (total {0}).", "Aucun nouveau serveur annoncé (total {0}).", "Nenhum novo servidor anunciado (total {0}).", "Keine neuen Server angekündigt (insgesamt {0})."],
|
||||||
|
["msg.send.sync.first"] = ["Sincronizza prima di inviare.", "Synchronize before sending.", "Sincroniza antes de enviar.", "Synchronisez avant d'envoyer.", "Sincronize antes de enviar.", "Vor dem Senden synchronisieren."],
|
||||||
|
["msg.send.connect.first"] = ["Connettiti al server e sincronizza prima di inviare.", "Connect to the server and synchronize before sending.", "Conéctate al servidor y sincroniza antes de enviar.", "Connectez-vous au serveur et synchronisez avant d'envoyer.", "Conecte-se ao servidor e sincronize antes de enviar.", "Mit dem Server verbinden und vor dem Senden synchronisieren."],
|
||||||
|
["msg.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag."],
|
||||||
|
["msg.feerate.invalid"] = ["Fee rate non valido.", "Invalid fee rate.", "Tarifa no válida.", "Taux de frais invalide.", "Taxa inválida.", "Ungültige Gebührenrate."],
|
||||||
|
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen"],
|
||||||
|
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid"],
|
||||||
|
["msg.unsigned.watchonly"] = [" · NON firmata (watch-only)", " · NOT signed (watch-only)", " · NO firmada (watch-only)", " · NON signée (watch-only)", " · NÃO assinada (watch-only)", " · NICHT signiert (watch-only)"],
|
||||||
|
["msg.cert.mismatch"] = [
|
||||||
|
"Il certificato TLS di {0} è cambiato rispetto a quello salvato. Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.",
|
||||||
|
"The TLS certificate of {0} has changed from the one saved. If the server renewed its certificate, reset the SSL certificates.",
|
||||||
|
"El certificado TLS de {0} ha cambiado respecto al guardado. Si el servidor renovó su certificado, restablece los certificados SSL.",
|
||||||
|
"Le certificat TLS de {0} a changé par rapport à celui enregistré. Si le serveur a renouvelé son certificat, réinitialisez les certificats SSL.",
|
||||||
|
"O certificado TLS de {0} mudou em relação ao salvo. Se o servidor renovou o certificado, redefina os certificados SSL.",
|
||||||
|
"Das TLS-Zertifikat von {0} hat sich gegenüber dem gespeicherten geändert. Wenn der Server das Zertifikat erneuert hat, setzen Sie die SSL-Zertifikate zurück."],
|
||||||
|
|
||||||
// Contacts
|
// Contacts
|
||||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Wallet;
|
using PalladiumWallet.Core.Wallet;
|
||||||
@@ -32,7 +33,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
||||||
{
|
{
|
||||||
DonatePreview = "Sincronizza prima di inviare.";
|
DonatePreview = Loc.Tr("msg.send.sync.first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
@@ -40,7 +41,7 @@ public partial class MainWindowViewModel
|
|||||||
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
||||||
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
||||||
{
|
{
|
||||||
DonatePreview = "Importo non valido.";
|
DonatePreview = Loc.Tr("msg.amount.invalid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
||||||
@@ -61,7 +62,7 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
_pendingDonate = null;
|
_pendingDonate = null;
|
||||||
HasPendingDonate = false;
|
HasPendingDonate = false;
|
||||||
DonatePreview = $"Errore: {ex.Message}";
|
DonatePreview = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||||
}
|
}
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@ public partial class MainWindowViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
||||||
DonatePreview = $"Grazie! txid: {txid}";
|
DonatePreview = $"{Loc.Tr("msg.donate.thanks")}: {txid}";
|
||||||
DonateAmount = "";
|
DonateAmount = "";
|
||||||
_pendingDonate = null;
|
_pendingDonate = null;
|
||||||
HasPendingDonate = false;
|
HasPendingDonate = false;
|
||||||
@@ -82,7 +83,7 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
DonatePreview = $"Errore broadcast: {ex.Message}";
|
DonatePreview = $"{Loc.Tr("msg.broadcast.error")}: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
using PalladiumWallet.App.Services;
|
using PalladiumWallet.App.Services;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
@@ -47,14 +48,14 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
if (_account is null || _doc?.Cache is null)
|
if (_account is null || _doc?.Cache is null)
|
||||||
{
|
{
|
||||||
SendPreview = "Sincronizza prima di inviare.";
|
SendPreview = Loc.Tr("msg.send.sync.first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_lastTransactions is null)
|
if (_lastTransactions is null)
|
||||||
{
|
{
|
||||||
SendPreview = "Connettiti al server e sincronizza prima di inviare.";
|
SendPreview = Loc.Tr("msg.send.connect.first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +63,12 @@ public partial class MainWindowViewModel
|
|||||||
long amount = 0;
|
long amount = 0;
|
||||||
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
||||||
{
|
{
|
||||||
SendPreview = "Importo non valido.";
|
SendPreview = Loc.Tr("msg.amount.invalid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
|
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
|
||||||
{
|
{
|
||||||
SendPreview = "Fee rate non valido.";
|
SendPreview = Loc.Tr("msg.feerate.invalid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,14 +79,14 @@ public partial class MainWindowViewModel
|
|||||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||||
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
|
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
||||||
HasPendingSend = _pendingSend.Signed;
|
HasPendingSend = _pendingSend.Signed;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_pendingSend = null;
|
_pendingSend = null;
|
||||||
HasPendingSend = false;
|
HasPendingSend = false;
|
||||||
SendPreview = $"Errore: {ex.Message}";
|
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||||
}
|
}
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ public partial class MainWindowViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
|
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
|
||||||
SendPreview = $"Trasmessa: {txid}";
|
SendPreview = $"{Loc.Tr("msg.broadcasted")}: {txid}";
|
||||||
SendTo = SendAmount = "";
|
SendTo = SendAmount = "";
|
||||||
_pendingSend = null;
|
_pendingSend = null;
|
||||||
HasPendingSend = false;
|
HasPendingSend = false;
|
||||||
@@ -106,7 +107,7 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
SendPreview = $"Errore broadcast: {ex.Message}";
|
SendPreview = $"{Loc.Tr("msg.broadcast.error")}: {DescribeError(ex)}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,14 +319,14 @@ public partial class MainWindowViewModel
|
|||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||||
StatusMessage = ex.Message;
|
StatusMessage = DescribeError(ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||||
if (_account is not null)
|
if (_account is not null)
|
||||||
{
|
{
|
||||||
_syncFailed = true;
|
_syncFailed = true;
|
||||||
@@ -378,7 +378,8 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
if (temp is null)
|
if (temp is null)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Errore nella scoperta peer: {lastError?.Message ?? Loc.Tr("conn.none")}";
|
StatusMessage = $"{Loc.Tr("msg.peer.discovery.error")}: " +
|
||||||
|
(lastError is null ? Loc.Tr("conn.none") : DescribeError(lastError));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
@@ -401,15 +402,26 @@ public partial class MainWindowViewModel
|
|||||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||||
?? KnownServers.FirstOrDefault();
|
?? KnownServers.FirstOrDefault();
|
||||||
StatusMessage = added > 0
|
StatusMessage = added > 0
|
||||||
? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})."
|
? string.Format(Loc.Tr("msg.peer.discovery.found"), added, KnownServers.Count)
|
||||||
: $"Nessun nuovo server annunciato (totale {KnownServers.Count}).";
|
: string.Format(Loc.Tr("msg.peer.discovery.none"), KnownServers.Count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Errore nella scoperta peer: {ex.Message}";
|
StatusMessage = $"{Loc.Tr("msg.peer.discovery.error")}: {DescribeError(ex)}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates known Core exception types to the current UI language; falls back to the
|
||||||
|
/// exception's own message (Core has no localization, so it is English by default).
|
||||||
|
/// </summary>
|
||||||
|
private static string DescribeError(Exception ex) => ex switch
|
||||||
|
{
|
||||||
|
CertificatePinMismatchException cert =>
|
||||||
|
string.Format(Loc.Tr("msg.cert.mismatch"), $"{cert.Host}:{cert.Port}"),
|
||||||
|
_ => ex.Message,
|
||||||
|
};
|
||||||
|
|
||||||
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
||||||
{
|
{
|
||||||
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +473,7 @@ public partial class MainWindowViewModel
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
newLock.Dispose();
|
newLock.Dispose();
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-50
@@ -6,8 +6,8 @@ using PalladiumWallet.Core.Spv;
|
|||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
using PalladiumWallet.Core.Wallet;
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
// CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di
|
// Wallet CLI (blueprint §13): Core use cases exposed via command line,
|
||||||
// comando, per scripting, test e confronto col wallet di riferimento.
|
// for scripting, testing and comparison against the reference wallet.
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ try
|
|||||||
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
|
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
|
||||||
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
|
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Errore: {ex.Message}");
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ static int Addresses(string words, string[] o)
|
|||||||
if (account is null)
|
if (account is null)
|
||||||
return 1;
|
return 1;
|
||||||
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
|
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
|
||||||
Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}");
|
Console.WriteLine($"network: {account.Profile.NetName} kind: {account.Kind} path: m/{account.AccountPath}");
|
||||||
Console.WriteLine($"account: {account.ToSlip132()}");
|
Console.WriteLine($"account: {account.ToSlip132()}");
|
||||||
for (var i = 0; i < count; i++)
|
for (var i = 0; i < count; i++)
|
||||||
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
|
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
|
||||||
@@ -58,7 +58,7 @@ static int Addresses(string words, string[] o)
|
|||||||
static int Create(string[] o)
|
static int Create(string[] o)
|
||||||
{
|
{
|
||||||
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
|
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
|
||||||
Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):");
|
Console.WriteLine("New mnemonic (write it on paper, it is NOT shown again):");
|
||||||
Console.WriteLine($" {mnemonic}");
|
Console.WriteLine($" {mnemonic}");
|
||||||
return SaveWallet(mnemonic.ToString(), o);
|
return SaveWallet(mnemonic.ToString(), o);
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ static int Restore(string words, string[] o)
|
|||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out _))
|
if (!Bip39.TryParse(words, out _))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
|
Console.Error.WriteLine("Invalid mnemonic (wrong words or checksum).");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return SaveWallet(words.Trim(), o);
|
return SaveWallet(words.Trim(), o);
|
||||||
@@ -78,7 +78,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
|||||||
var profile = Profile(o);
|
var profile = Profile(o);
|
||||||
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
|
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete.");
|
Console.Error.WriteLine("Extended key not recognised for this network.");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
||||||
@@ -91,7 +91,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
|||||||
};
|
};
|
||||||
var path = WalletPath(o, profile);
|
var path = WalletPath(o, profile);
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
Console.WriteLine($"Wallet watch-only salvato in {path}");
|
Console.WriteLine($"Watch-only wallet saved to {path}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,27 +99,27 @@ static int Info(string[] o)
|
|||||||
{
|
{
|
||||||
var (doc, account, path) = OpenWallet(o);
|
var (doc, account, path) = OpenWallet(o);
|
||||||
Console.WriteLine($"file: {path}");
|
Console.WriteLine($"file: {path}");
|
||||||
Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
|
Console.WriteLine($"network: {doc.Network} kind: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
|
||||||
Console.WriteLine($"path: m/{doc.AccountPath}");
|
Console.WriteLine($"path: m/{doc.AccountPath}");
|
||||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||||
if (doc.Cache is { } cache)
|
if (doc.Cache is { } cache)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendable"
|
||||||
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} in maturazione (non spendibile)" : "")
|
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
|
||||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa di conferma (non spendibile)." : ""));
|
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
||||||
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
||||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(0)} (mai sincronizzato)");
|
Console.WriteLine($"receive: {account.GetReceiveAddress(0)} (never synchronized)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (o.Contains("--addresses") && doc.Cache is { } c)
|
if (o.Contains("--addresses") && doc.Cache is { } c)
|
||||||
{
|
{
|
||||||
Console.WriteLine("indirizzi:");
|
Console.WriteLine("addresses:");
|
||||||
foreach (var a in c.Addresses)
|
foreach (var a in c.Addresses)
|
||||||
Console.WriteLine($" {(a.IsChange ? "change " : "ricev. ")}{a.Index,3} {a.Address} " +
|
Console.WriteLine($" {(a.IsChange ? "change " : "receive")}{a.Index,3} {a.Address} " +
|
||||||
$"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)");
|
$"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)");
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -160,14 +160,14 @@ static async Task<int> Sync(string[] o)
|
|||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendable"
|
||||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} in maturazione (non spendibile)" : "")
|
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
||||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
||||||
Console.WriteLine($"Storico ({result.History.Count}):");
|
Console.WriteLine($"History ({result.History.Count}):");
|
||||||
foreach (var tx in result.History)
|
foreach (var tx in result.History)
|
||||||
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
|
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
|
||||||
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
|
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
|
||||||
(tx.Verified ? "" : " (non verificata)"));
|
(tx.Verified ? "" : " (unverified)"));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,18 +176,18 @@ static async Task<int> Send(string[] o)
|
|||||||
var (doc, account, path) = OpenWallet(o);
|
var (doc, account, path) = OpenWallet(o);
|
||||||
if (doc.Cache is null)
|
if (doc.Cache is null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'.");
|
Console.Error.WriteLine("Wallet never synchronized: run 'sync' first.");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to mancante.");
|
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to missing.");
|
||||||
var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind));
|
var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind));
|
||||||
var sendAll = o.Contains("--all");
|
var sendAll = o.Contains("--all");
|
||||||
long amount = 0;
|
long amount = 0;
|
||||||
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
|
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
|
||||||
throw new WalletSpendException("--amount mancante o non valido (oppure usa --all).");
|
throw new WalletSpendException("--amount missing or invalid (or use --all).");
|
||||||
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
|
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
|
||||||
|
|
||||||
// Tx di provenienza degli UTXO: servono per importi e firma.
|
// Source transactions of the UTXOs: needed for amounts and signing.
|
||||||
await using var client = await Connect(o, account.Profile);
|
await using var client = await Connect(o, account.Profile);
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var transactions = new Dictionary<string, Transaction>();
|
var transactions = new Dictionary<string, Transaction>();
|
||||||
@@ -204,20 +204,20 @@ static async Task<int> Send(string[] o)
|
|||||||
|
|
||||||
if (!built.Signed)
|
if (!built.Signed)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Wallet watch-only: PSBT da firmare offline (§6.5):");
|
Console.WriteLine("Watch-only wallet: PSBT to sign offline (§6.5):");
|
||||||
Console.WriteLine(built.Psbt.ToBase64());
|
Console.WriteLine(built.Psbt.ToBase64());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!o.Contains("--broadcast"))
|
if (!o.Contains("--broadcast"))
|
||||||
{
|
{
|
||||||
Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):");
|
Console.WriteLine("Signed transaction (NOT broadcast, add --broadcast):");
|
||||||
Console.WriteLine(built.ToHex());
|
Console.WriteLine(built.ToHex());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var txid2 = await client.BroadcastAsync(built.ToHex());
|
var txid2 = await client.BroadcastAsync(built.ToHex());
|
||||||
Console.WriteLine($"Trasmessa: {txid2}");
|
Console.WriteLine($"Broadcast: {txid2}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ static int ResetCerts(string[] o)
|
|||||||
{
|
{
|
||||||
var profile = Profile(o);
|
var profile = Profile(o);
|
||||||
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
|
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
|
||||||
Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati.");
|
Console.WriteLine($"Saved SSL certificates for {profile.NetName} cleared.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,16 +238,16 @@ static async Task<int> Servers(string[] o)
|
|||||||
{
|
{
|
||||||
await using var client = await Connect(o, profile);
|
await using var client = await Connect(o, profile);
|
||||||
var added = await registry.DiscoverAsync(client);
|
var added = await registry.DiscoverAsync(client);
|
||||||
Console.WriteLine($"Scoperti {added} nuovi server dai peer.");
|
Console.WriteLine($"Discovered {added} new servers from peers.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Server noti ({profile.NetName}):");
|
Console.WriteLine($"Known servers ({profile.NetName}):");
|
||||||
foreach (var server in registry.All)
|
foreach (var server in registry.All)
|
||||||
Console.WriteLine($" {server}");
|
Console.WriteLine($" {server}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- helper comuni -----
|
// ----- shared helpers -----
|
||||||
|
|
||||||
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
|
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
|
||||||
{
|
{
|
||||||
@@ -270,7 +270,7 @@ static HdAccount? AccountFromWords(string words, string[] o)
|
|||||||
{
|
{
|
||||||
if (!Bip39.TryParse(words, out var mnemonic))
|
if (!Bip39.TryParse(words, out var mnemonic))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
|
Console.Error.WriteLine("Invalid mnemonic (wrong words or checksum).");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
var profile = Profile(o);
|
var profile = Profile(o);
|
||||||
@@ -289,11 +289,11 @@ static int SaveWallet(string words, string[] o)
|
|||||||
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
|
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
|
||||||
var password = Opt(o, "--password");
|
var password = Opt(o, "--password");
|
||||||
if (string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(password))
|
||||||
Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17).");
|
Console.WriteLine("WARNING: no --password, the seed will be stored in plaintext on disk (§17).");
|
||||||
var path = WalletPath(o, profile);
|
var path = WalletPath(o, profile);
|
||||||
WalletStore.Save(doc, path, password);
|
WalletStore.Save(doc, path, password);
|
||||||
Console.WriteLine($"Wallet salvato in {path}");
|
Console.WriteLine($"Wallet saved to {path}");
|
||||||
Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}");
|
Console.WriteLine($"First address: {account.GetReceiveAddress(0)}");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
|||||||
{
|
{
|
||||||
var path = WalletPath(o, Profile(o));
|
var path = WalletPath(o, Profile(o));
|
||||||
if (!WalletStore.Exists(path))
|
if (!WalletStore.Exists(path))
|
||||||
throw new WalletSpendException($"Nessun wallet in {path}: usa 'create' o 'restore'.");
|
throw new WalletSpendException($"No wallet at {path}: use 'create' or 'restore'.");
|
||||||
var doc = WalletStore.Load(path, Opt(o, "--password"));
|
var doc = WalletStore.Load(path, Opt(o, "--password"));
|
||||||
return (doc, WalletLoader.ToAccount(doc), path);
|
return (doc, WalletLoader.ToAccount(doc), path);
|
||||||
}
|
}
|
||||||
@@ -320,15 +320,15 @@ static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Senza --server si usa il primo server noto (bootstrap §3 o scoperto §9).
|
// Without --server, use the first known server (bootstrap §3 or discovered §9).
|
||||||
var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default
|
var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default
|
||||||
?? throw new WalletSpendException("Nessun server noto per questa rete: usa --server host:porta.");
|
?? throw new WalletSpendException("No known server for this network: use --server host:port.");
|
||||||
host = known.Host;
|
host = known.Host;
|
||||||
port = known.PortFor(useSsl);
|
port = known.PortFor(useSsl);
|
||||||
}
|
}
|
||||||
|
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
|
||||||
Console.WriteLine($"Connessione a {host}:{port}{(useSsl ? " (TLS)" : "")}…");
|
Console.WriteLine($"Connecting to {host}:{port}{(useSsl ? " (TLS)" : "")}…");
|
||||||
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
|
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,20 +346,20 @@ static int Usage()
|
|||||||
Wallet:
|
Wallet:
|
||||||
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
|
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
|
||||||
[--passphrase W] [--password P] [--file PATH]
|
[--passphrase W] [--password P] [--file PATH]
|
||||||
restore "<mnemonica>" [stesse opzioni di create] [--path m/...]
|
restore "<mnemonic>" [same options as create] [--path m/...]
|
||||||
restore-xpub <xpub slip132> [--net ...] [--password P] [--file PATH] (watch-only)
|
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||||
info [--net ...] [--password P] [--file PATH]
|
info [--net ...] [--password P] [--file PATH]
|
||||||
|
|
||||||
Rete (server di indicizzazione; senza --server usa il primo server noto):
|
Network (indexing server; without --server the first known server is used):
|
||||||
sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH]
|
sync [--server host[:port]] [--ssl] [--net ...] [--password P] [--file PATH]
|
||||||
send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB]
|
send --to ADDRESS (--amount X | --all) [--feerate sat/vB]
|
||||||
[--server host[:porta]] [--ssl] [--broadcast] [...]
|
[--server host[:port]] [--ssl] [--broadcast] [...]
|
||||||
servers [--discover] [--server host[:porta]] [--ssl] [--net ...]
|
servers [--discover] [--server host[:port]] [--ssl] [--net ...]
|
||||||
reset-certs [--net ...]
|
reset-certs [--net ...]
|
||||||
|
|
||||||
Strumenti:
|
Tools:
|
||||||
newseed [--words 12|24]
|
newseed [--words 12|24]
|
||||||
addresses "<mnemonica>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
|
addresses "<mnemonic>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
|
||||||
""");
|
""");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,38 @@ public static class ChainProfiles
|
|||||||
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
||||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||||
],
|
],
|
||||||
// TODO: populate with the chain's real [hash, bits] (§7.3) before release.
|
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||||
Checkpoints = [],
|
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one
|
||||||
|
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||||
|
// bounds how far back a forged header chain must be walked to be caught, since
|
||||||
|
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
||||||
|
Checkpoints =
|
||||||
|
[
|
||||||
|
new Checkpoint(20000, "00000000000018ffaa9a332cfb418b5c8c3f988cf26598e378bbea9e93d26f74", 0x1b00ffff),
|
||||||
|
new Checkpoint(40000, "000000000000011ad2ae53d9647a2130d2b1c41b18d200455211f1fef2a4ffb7", 0x1a013d28),
|
||||||
|
new Checkpoint(60000, "000000000000034288c737d62011855fb598cd5c6ebb5c46c08acdec17620c8b", 0x1a0390b5),
|
||||||
|
new Checkpoint(80000, "00000000000003662534639c7eb1dd7166efd85c308be19b367467c015279361", 0x1a0577b1),
|
||||||
|
new Checkpoint(100000, "0000000000000850eba93bbc491f085e2c79c0c30c497292858c72e90cae69a5", 0x1a2c39e4),
|
||||||
|
new Checkpoint(120000, "0000000000004c421e06c84f08a947d994cb801b8ac7cade12d616209d851d43", 0x1a510836),
|
||||||
|
new Checkpoint(140000, "00000000000075b1a095a5969a2ca729b646ab0e2b9a9bd72aa603b3b889c398", 0x1b00a257),
|
||||||
|
new Checkpoint(160000, "000000000000028c8ba89e695f80fc78491bcf7e583fc7cd868e0a9c2973dfbe", 0x1a0ed8bb),
|
||||||
|
new Checkpoint(180000, "00000000000003632ffdcf60ce3892f44613dbbfe761b14522e91a1e650c092f", 0x1a064544),
|
||||||
|
new Checkpoint(200000, "000000000000221a9e16556453fc86308b260d95d80c14bafaf053a09374e7eb", 0x1a22c142),
|
||||||
|
new Checkpoint(220000, "0000000000001f63d259df5b9b20182dbaea92f2858fe836b895b2a33430c6dd", 0x1a3311af),
|
||||||
|
new Checkpoint(240000, "0000000000004c8a80484a1d6ab8a08460ac688445ccafe5cbac8b11bc471f11", 0x1a764de8),
|
||||||
|
new Checkpoint(260000, "000000000000ab1b71140485359633ef991588613f520052ce87acb70a36b4de", 0x1b022691),
|
||||||
|
new Checkpoint(280000, "00000000000678b07eda63cf01b099737ce832470d0e71769d157190f4d9ac9b", 0x1b118047),
|
||||||
|
new Checkpoint(300000, "0000000000013acdf07a4fb988bbe9824c36eb421478a71c8196cf524dcba143", 0x1b01ddc1),
|
||||||
|
new Checkpoint(320000, "000000000000079f2fb9866f1bb452ee5f47a35e0d494c6bc90331f582b07991", 0x1a0e8592),
|
||||||
|
new Checkpoint(340000, "000000000000000e45f7fbcff239da7965e1bd58aea3a10aef2bc8afbca822be", 0x1a0328e2),
|
||||||
|
new Checkpoint(360000, "000000000000016d60397423447eb42f8b4ba693fe16f1e64e9fdf58c94ca2a6", 0x1a020861),
|
||||||
|
new Checkpoint(380000, "000000000000004ba0d45a0462501a12251947d27e52ec810edd69007b7acf90", 0x1a01568d),
|
||||||
|
new Checkpoint(400000, "0000000000000010ac708514e8b837703233161099bf55400433cef32311f495", 0x1a01ce70),
|
||||||
|
new Checkpoint(420000, "00000000000000351392487709e637bc7a9b0b2296a0e443f54e2af5b3f00e16", 0x1a01197e),
|
||||||
|
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
||||||
|
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
||||||
|
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ChainProfile Testnet { get; } = Mainnet with
|
public static ChainProfile Testnet { get; } = Mainnet with
|
||||||
@@ -78,6 +108,9 @@ public static class ChainProfiles
|
|||||||
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
||||||
},
|
},
|
||||||
BootstrapServers = [],
|
BootstrapServers = [],
|
||||||
|
// TODO: populate from a synced testnet palladiumd (§7.3) — left empty because
|
||||||
|
// WalletSynchronizer already treats "no checkpoint at or below this height" as
|
||||||
|
// a no-op, so an empty array is safe, just unanchored.
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +123,7 @@ public static class ChainProfiles
|
|||||||
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
||||||
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
||||||
NodeP2pPort = 28444,
|
NodeP2pPort = 28444,
|
||||||
|
// Regtest is regenerated locally on demand: hardcoded checkpoints make no sense here.
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -284,5 +284,9 @@ public sealed class ElectrumServerException(string error) : Exception(error);
|
|||||||
/// It is unlocked with an explicit reset of the certificates.
|
/// It is unlocked with an explicit reset of the certificates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
||||||
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
$"The TLS certificate of {host}:{port} has changed from the one saved. " +
|
||||||
"Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.");
|
"If the server renewed its certificate, reset the SSL certificates.")
|
||||||
|
{
|
||||||
|
public string Host { get; } = host;
|
||||||
|
public int Port { get; } = port;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,11 +28,19 @@ public static class UpdateChecker
|
|||||||
public string? HtmlUrl { get; set; }
|
public string? HtmlUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default)
|
public static Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default) =>
|
||||||
|
CheckAsync(currentVersion, handler: null, ct);
|
||||||
|
|
||||||
|
/// <summary>Test seam: <paramref name="handler"/> replaces the real HTTP transport.</summary>
|
||||||
|
internal static async Task<LatestRelease?> CheckAsync(string currentVersion,
|
||||||
|
HttpMessageHandler? handler, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
using var http = handler is null
|
||||||
|
? new HttpClient()
|
||||||
|
: new HttpClient(handler, disposeHandler: false);
|
||||||
|
http.Timeout = TimeSpan.FromSeconds(10);
|
||||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
||||||
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
|
// checkpoint height -> highest height already proven to hash-chain back to it
|
||||||
|
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
||||||
|
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||||
|
|
||||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||||
// discovery — already-used addresses are fetched in a single burst instead of
|
// discovery — already-used addresses are fetched in a single burst instead of
|
||||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
||||||
@@ -189,6 +193,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
h => client.GetBlockHeaderAsync(h, ct));
|
h => client.GetBlockHeaderAsync(h, ct));
|
||||||
var proof = await proofTask;
|
var proof = await proofTask;
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
|
await AnchorToCheckpointAsync(height, ct);
|
||||||
if (!MerkleProof.Verify(
|
if (!MerkleProof.Verify(
|
||||||
uint256.Parse(txid), proof.Pos,
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
@@ -297,6 +302,45 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anchors a header at <paramref name="height"/> to the nearest hardcoded checkpoint at
|
||||||
|
/// or below it (§7.3): downloads every intervening header and verifies an unbroken
|
||||||
|
/// prev-hash chain from the checkpoint's known-good hash up to this height. Without this,
|
||||||
|
/// the Merkle proof above only proves a transaction belongs to *some* header the server
|
||||||
|
/// handed over — on this LWMA chain the wallet cannot recompute PoW to catch a forged one,
|
||||||
|
/// so the checkpoint is the only fixed point of truth. A no-op when the network profile has
|
||||||
|
/// no checkpoint at or below <paramref name="height"/> (e.g. testnet/regtest today, or
|
||||||
|
/// mainnet heights below the first checkpoint).
|
||||||
|
/// </summary>
|
||||||
|
private async Task AnchorToCheckpointAsync(int height, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var profile = account.Profile;
|
||||||
|
Checkpoint? checkpoint = null;
|
||||||
|
foreach (var c in profile.Checkpoints)
|
||||||
|
if (c.Height <= height && (checkpoint is not { } best || c.Height > best.Height))
|
||||||
|
checkpoint = c;
|
||||||
|
if (checkpoint is not { } cp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||||
|
.Select(async h => BlockHeaderInfo.Parse(
|
||||||
|
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct)))));
|
||||||
|
|
||||||
|
if (!headers[0].MatchesCheckpoint(cp))
|
||||||
|
throw new SpvVerificationException(
|
||||||
|
$"Header at checkpoint height {cp.Height} does not match the hardcoded hash: server is not trustworthy.");
|
||||||
|
|
||||||
|
for (var i = 1; i < headers.Length; i++)
|
||||||
|
if (!headers[i].IsValidChild(headers[i - 1].Hash, profile))
|
||||||
|
throw new SpvVerificationException(
|
||||||
|
$"Broken header chain at height {cp.Height + i}: server is not trustworthy.");
|
||||||
|
|
||||||
|
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans one chain (receiving or change).
|
/// Scans one chain (receiving or change).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ public static class AppPaths
|
|||||||
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
|
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
|
||||||
public static string? OverrideDataRoot { get; set; }
|
public static string? OverrideDataRoot { get; set; }
|
||||||
|
|
||||||
|
// Test seams: redirect the machine-global bootstrap locations (APPDATA pointer
|
||||||
|
// dir, executable dir, per-user default root) to a sandbox so the resolution
|
||||||
|
// precedence can be exercised without touching real user data.
|
||||||
|
internal static string? BootstrapDirOverride { get; set; }
|
||||||
|
internal static string? PortableBaseOverride { get; set; }
|
||||||
|
internal static string? DefaultRootOverride { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default data root, following each platform's convention:
|
/// Default data root, following each platform's convention:
|
||||||
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
|
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
|
||||||
@@ -29,6 +36,8 @@ public static class AppPaths
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static string DefaultDataRoot()
|
public static string DefaultDataRoot()
|
||||||
{
|
{
|
||||||
|
if (DefaultRootOverride is { } o)
|
||||||
|
return o;
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
return Path.Combine(
|
return Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
@@ -41,11 +50,12 @@ public static class AppPaths
|
|||||||
/// bootstrap location that is always writable and independent of the data root.</summary>
|
/// bootstrap location that is always writable and independent of the data root.</summary>
|
||||||
private static string LocationPointerPath() =>
|
private static string LocationPointerPath() =>
|
||||||
Path.Combine(
|
Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
BootstrapDirOverride ?? Path.Combine(
|
||||||
AppDirName, "data-location");
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDirName),
|
||||||
|
"data-location");
|
||||||
|
|
||||||
private static string PortableRoot() =>
|
private static string PortableRoot() =>
|
||||||
Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
Path.Combine(PortableBaseOverride ?? AppContext.BaseDirectory, PortableDirName);
|
||||||
|
|
||||||
private static bool HasData(string root) =>
|
private static bool HasData(string root) =>
|
||||||
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
||||||
|
|||||||
@@ -75,4 +75,25 @@ public class PalladiumNetworksTests
|
|||||||
Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
|
Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
|
||||||
Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
|
Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void For_con_un_kind_fuori_enum_lancia_eccezione()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => PalladiumNetworks.For((NetKind)99));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_network_set_espone_le_tre_reti_sotto_il_codice_plm()
|
||||||
|
{
|
||||||
|
var set = PalladiumNetworkSet.Instance;
|
||||||
|
|
||||||
|
Assert.Equal("PLM", set.CryptoCode);
|
||||||
|
Assert.Same(PalladiumNetworks.Mainnet, set.Mainnet);
|
||||||
|
Assert.Same(PalladiumNetworks.Testnet, set.Testnet);
|
||||||
|
Assert.Same(PalladiumNetworks.Regtest, set.Regtest);
|
||||||
|
Assert.Same(PalladiumNetworks.Mainnet, set.GetNetwork(ChainName.Mainnet));
|
||||||
|
Assert.Same(PalladiumNetworks.Testnet, set.GetNetwork(ChainName.Testnet));
|
||||||
|
Assert.Same(PalladiumNetworks.Regtest, set.GetNetwork(ChainName.Regtest));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => set.GetNetwork(new ChainName("signet")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,4 +90,40 @@ public class Bip39Tests
|
|||||||
Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
|
Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
|
||||||
Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
|
Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void Testo_vuoto_o_solo_spazi_viene_rifiutato(string? text)
|
||||||
|
{
|
||||||
|
Assert.False(Bip39.TryParse(text!, out var mnemonic));
|
||||||
|
Assert.Null(mnemonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(MnemonicLanguage.English)]
|
||||||
|
[InlineData(MnemonicLanguage.Spanish)]
|
||||||
|
[InlineData(MnemonicLanguage.French)]
|
||||||
|
[InlineData(MnemonicLanguage.Japanese)]
|
||||||
|
[InlineData(MnemonicLanguage.PortugueseBrazil)]
|
||||||
|
[InlineData(MnemonicLanguage.ChineseSimplified)]
|
||||||
|
[InlineData(MnemonicLanguage.ChineseTraditional)]
|
||||||
|
[InlineData(MnemonicLanguage.Czech)]
|
||||||
|
public void Ogni_lingua_supportata_genera_e_riparsa_la_propria_mnemonica(MnemonicLanguage language)
|
||||||
|
{
|
||||||
|
var mnemonic = Bip39.Generate(MnemonicLength.Twelve, language);
|
||||||
|
|
||||||
|
// Explicit-language parse must always succeed; it also covers the full
|
||||||
|
// language→wordlist map (USERGUIDE §2.1 promises these languages on restore).
|
||||||
|
Assert.True(Bip39.TryParse(mnemonic.ToString(), out var parsed, language));
|
||||||
|
Assert.Equal(mnemonic.ToString(), parsed!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_lingua_fuori_enum_viene_rifiutata()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
|
() => Bip39.Generate(MnemonicLength.Twelve, (MnemonicLanguage)99));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
using NBitcoin.DataEncoders;
|
||||||
using PalladiumWallet.Core.Chain;
|
using PalladiumWallet.Core.Chain;
|
||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
|
||||||
@@ -74,6 +75,39 @@ public class Bip84Slip132Tests
|
|||||||
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_xkey_base58_valida_ma_di_lunghezza_sbagliata_viene_rifiutata()
|
||||||
|
{
|
||||||
|
// Well-formed Base58Check, but the decoded payload is not 78 bytes.
|
||||||
|
var tooShort = Encoders.Base58Check.EncodeData([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
Assert.False(Slip132.TryDecodePublic(tooShort, ChainProfiles.Mainnet, out _, out _));
|
||||||
|
Assert.False(Slip132.TryDecodePrivate(tooShort, ChainProfiles.Mainnet, out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_zpub_con_payload_corrotto_viene_rifiutata_senza_eccezioni()
|
||||||
|
{
|
||||||
|
// Correct zpub header, but the 33-byte pubkey has an invalid point prefix:
|
||||||
|
// the ExtPubKey constructor must fail and TryDecodePublic must return false.
|
||||||
|
var data = Encoders.Base58Check.DecodeData(Account().ToSlip132());
|
||||||
|
data[45] = 0xFF; // pubkey prefix (offset 4 header + 41) — not 0x02/0x03
|
||||||
|
var corrupted = Encoders.Base58Check.EncodeData(data);
|
||||||
|
|
||||||
|
Assert.False(Slip132.TryDecodePublic(corrupted, ChainProfiles.Mainnet, out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_zprv_con_payload_corrotto_viene_rifiutata_senza_eccezioni()
|
||||||
|
{
|
||||||
|
// Correct zprv header, but the byte before the 32-byte key must be 0x00:
|
||||||
|
// ExtKey.CreateFromBytes must fail and TryDecodePrivate must return false.
|
||||||
|
var data = Encoders.Base58Check.DecodeData(Account().ToSlip132Private());
|
||||||
|
data[45] = 0xFF;
|
||||||
|
var corrupted = Encoders.Base58Check.EncodeData(data);
|
||||||
|
|
||||||
|
Assert.False(Slip132.TryDecodePrivate(corrupted, ChainProfiles.Mainnet, out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
|
[InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
|
||||||
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
|
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
|
||||||
|
|||||||
@@ -98,6 +98,21 @@ public class ElectrumClientTests
|
|||||||
await disconnected.Task.WaitAsync(Timeout);
|
await disconnected.Task.WaitAsync(Timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_risposta_molto_grande_arriva_integra()
|
||||||
|
{
|
||||||
|
// A payload far beyond the PipeReader's segment size forces the
|
||||||
|
// multi-segment dispatch path (ArrayPool copy) instead of the fast span.
|
||||||
|
var big = new string('x', 256 * 1024);
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.transaction.get", _ => big);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var result = await client.GetTransactionAsync("00").WaitAsync(Timeout);
|
||||||
|
Assert.Equal(big, result);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,86 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
namespace PalladiumWallet.Tests.Net;
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the release-tag parsing used by the update check. The network
|
/// Tests for the update check: tag parsing plus the full CheckAsync flow via
|
||||||
/// call itself is best-effort by design (any failure → null) and is not
|
/// a stub HttpMessageHandler (the same seam the production overload wraps),
|
||||||
/// exercised here: only the version-comparison logic is deterministic.
|
/// so no real GitHub call is ever made.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UpdateCheckerTests
|
public class UpdateCheckerTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Handler stub: replies with a fixed status/body, or throws.</summary>
|
||||||
|
private sealed class StubHandler(HttpStatusCode status, string? body = null,
|
||||||
|
Exception? throws = null) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken ct) =>
|
||||||
|
throws is not null
|
||||||
|
? Task.FromException<HttpResponseMessage>(throws)
|
||||||
|
: Task.FromResult(new HttpResponseMessage(status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body ?? "", Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<LatestRelease?> Check(string current, HttpStatusCode status,
|
||||||
|
string? body = null, Exception? throws = null) =>
|
||||||
|
UpdateChecker.CheckAsync(current, new StubHandler(status, body, throws));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_release_piu_nuova_viene_segnalata_con_tag_e_url()
|
||||||
|
{
|
||||||
|
var release = await Check("0.9.1", HttpStatusCode.OK,
|
||||||
|
"""{"tag_name":"v1.0.0","html_url":"https://example.test/rel/v1.0.0"}""");
|
||||||
|
|
||||||
|
Assert.NotNull(release);
|
||||||
|
Assert.Equal("v1.0.0", release!.Tag);
|
||||||
|
Assert.Equal("https://example.test/rel/v1.0.0", release.HtmlUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Senza_html_url_viene_costruito_il_link_alla_pagina_della_release()
|
||||||
|
{
|
||||||
|
var release = await Check("0.9.1", HttpStatusCode.OK, """{"tag_name":"v1.0.0"}""");
|
||||||
|
|
||||||
|
Assert.NotNull(release);
|
||||||
|
Assert.Contains("/releases/tag/v1.0.0", release!.HtmlUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"tag_name":"v0.9.1"}""")] // same version
|
||||||
|
[InlineData("""{"tag_name":"v0.9.0"}""")] // older
|
||||||
|
[InlineData("""{"tag_name":"main"}""")] // unparseable tag
|
||||||
|
[InlineData("""{"tag_name":""}""")] // empty tag
|
||||||
|
[InlineData("""{}""")] // missing tag
|
||||||
|
public async Task Nessun_aggiornamento_quando_il_tag_non_e_piu_nuovo(string body)
|
||||||
|
{
|
||||||
|
Assert.Null(await Check("0.9.1", HttpStatusCode.OK, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_risposta_http_di_errore_risolve_a_null()
|
||||||
|
{
|
||||||
|
Assert.Null(await Check("0.9.1", HttpStatusCode.NotFound));
|
||||||
|
Assert.Null(await Check("0.9.1", HttpStatusCode.InternalServerError));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Json_malformato_o_errore_di_rete_risolvono_a_null()
|
||||||
|
{
|
||||||
|
Assert.Null(await Check("0.9.1", HttpStatusCode.OK, "not json at all"));
|
||||||
|
Assert.Null(await Check("0.9.1", HttpStatusCode.OK,
|
||||||
|
"""{"tag_name":"v9.9.9"}""", throws: new HttpRequestException("offline")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_versione_locale_non_parsabile_risolve_a_null()
|
||||||
|
{
|
||||||
|
Assert.Null(await Check("dev-build", HttpStatusCode.OK, """{"tag_name":"v9.9.9"}"""));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("v1.2.3", "1.2.3")]
|
[InlineData("v1.2.3", "1.2.3")]
|
||||||
[InlineData("V0.9.1", "0.9.1")]
|
[InlineData("V0.9.1", "0.9.1")]
|
||||||
|
|||||||
@@ -239,4 +239,29 @@ public class BlockHeaderInfoTests
|
|||||||
{
|
{
|
||||||
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ"));
|
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Senza_skip_pow_un_hash_sotto_il_target_passa()
|
||||||
|
{
|
||||||
|
// Bitcoin's genesis satisfies its own 0x1d00ffff target by construction.
|
||||||
|
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
|
||||||
|
var powProfile = ChainProfiles.Mainnet with { SkipPowValidation = false };
|
||||||
|
|
||||||
|
Assert.True(header.IsValidChild(uint256.Zero, powProfile));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Senza_skip_pow_un_hash_sopra_il_target_viene_rifiutato()
|
||||||
|
{
|
||||||
|
// Rewrite the genesis bits to an almost-impossible target (0x03000001 →
|
||||||
|
// tiny) without re-mining: the untouched hash can no longer satisfy it.
|
||||||
|
var raw = Convert.FromHexString(GenesisHeaderHex);
|
||||||
|
raw[72] = 0x01; raw[73] = 0x00; raw[74] = 0x00; raw[75] = 0x03;
|
||||||
|
var header = BlockHeaderInfo.Parse(raw);
|
||||||
|
var powProfile = ChainProfiles.Mainnet with { SkipPowValidation = false };
|
||||||
|
|
||||||
|
Assert.False(header.IsValidChild(uint256.Zero, powProfile));
|
||||||
|
// Same header with skip enabled: the target is ignored.
|
||||||
|
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ public class WalletSynchronizerTests
|
|||||||
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
||||||
private static readonly Network Net = PalladiumNetworks.Regtest;
|
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||||
|
|
||||||
private static HdAccount Account()
|
private static HdAccount Account(ChainProfile? profile = null)
|
||||||
{
|
{
|
||||||
Assert.True(Bip39.TryParse(
|
Assert.True(Bip39.TryParse(
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
out var mnemonic));
|
out var mnemonic));
|
||||||
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, profile ?? Profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -71,10 +71,11 @@ public class WalletSynchronizerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
|
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
|
||||||
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null)
|
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null,
|
||||||
|
uint256? prevHash = null)
|
||||||
{
|
{
|
||||||
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
header.HashPrevBlock = uint256.Zero;
|
header.HashPrevBlock = prevHash ?? uint256.Zero;
|
||||||
header.HashMerkleRoot = merkleRoot ?? txid;
|
header.HashMerkleRoot = merkleRoot ?? txid;
|
||||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||||
header.Bits = new Target(0x1d00ffffu);
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
@@ -273,6 +274,158 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Contains(funding.GetHash().ToString(), ex.Message);
|
Assert.Contains(funding.GetHash().ToString(), ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- checkpoint anchoring ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a chain of single-tx headers from <paramref name="fromHeight"/> to
|
||||||
|
/// <paramref name="toHeight"/> (inclusive), each linked to the previous via
|
||||||
|
/// HashPrevBlock. The last height carries <paramref name="txid"/> as its Merkle
|
||||||
|
/// root; <paramref name="roots"/> overrides the root at specific heights.
|
||||||
|
/// </summary>
|
||||||
|
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid,
|
||||||
|
Dictionary<int, uint256>? roots = null)
|
||||||
|
{
|
||||||
|
var headers = new Dictionary<int, string>();
|
||||||
|
uint256? prevHash = null;
|
||||||
|
for (var h = fromHeight; h <= toHeight; h++)
|
||||||
|
{
|
||||||
|
var root = roots?.GetValueOrDefault(h) ?? (h == toHeight ? txid : uint256.One);
|
||||||
|
var hex = Scenario.SingleTxHeaderHex(root, h, merkleRoot: root, prevHash: prevHash);
|
||||||
|
headers[h] = hex;
|
||||||
|
prevHash = BlockHeaderInfo.Parse(hex).Hash;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_tx_anchorata_a_un_checkpoint_valido_verifica_la_catena_di_header()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||||
|
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||||
|
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||||
|
|
||||||
|
var checkpointProfile = Profile with
|
||||||
|
{
|
||||||
|
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||||
|
};
|
||||||
|
var checkpointAccount = Account(checkpointProfile);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint.
|
||||||
|
Assert.Equal(6, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||||
|
|
||||||
|
// Second tx (at height 103) known upfront so the chain can commit to it,
|
||||||
|
// but announced by the server only after the first sync.
|
||||||
|
var tx2 = Net.CreateTransaction();
|
||||||
|
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||||
|
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
|
||||||
|
|
||||||
|
var chain = ChainedHeaders(100, 105, funding.GetHash(),
|
||||||
|
roots: new() { [103] = tx2.GetHash() });
|
||||||
|
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||||
|
|
||||||
|
var checkpointProfile = Profile with
|
||||||
|
{
|
||||||
|
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||||
|
};
|
||||||
|
var checkpointAccount = Account(checkpointProfile);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var sync = new WalletSynchronizer(checkpointAccount, client);
|
||||||
|
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
||||||
|
|
||||||
|
// 103 <= the memoized 105: anchoring must early-return without re-walking,
|
||||||
|
// so the header call count stays at the 6 of the first walk (103 is cached).
|
||||||
|
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
||||||
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1_500_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(6, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||||
|
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||||
|
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||||
|
|
||||||
|
var checkpointProfile = Profile with
|
||||||
|
{
|
||||||
|
// Wrong hash at the checkpoint height: the real chain does not match it.
|
||||||
|
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
|
||||||
|
};
|
||||||
|
var checkpointAccount = Account(checkpointProfile);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||||
|
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||||
|
Assert.Contains("checkpoint height 100", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_catena_di_header_spezzata_fa_fallire_la_sync()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||||
|
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||||
|
// Tamper with block 103: it no longer points at block 102's real hash.
|
||||||
|
chain[103] = Scenario.SingleTxHeaderHex(uint256.One, 103, prevHash: uint256.One);
|
||||||
|
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||||
|
|
||||||
|
var checkpointProfile = Profile with
|
||||||
|
{
|
||||||
|
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||||
|
};
|
||||||
|
var checkpointAccount = Account(checkpointProfile);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||||
|
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||||
|
Assert.Contains("Broken header chain at height 103", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op()
|
||||||
|
{
|
||||||
|
// Regtest profile (Profile) has no checkpoints at all: same behaviour as before
|
||||||
|
// this feature existed — only the Merkle proof is checked.
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(1, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- resilienza ----
|
// ---- resilienza ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -302,6 +455,34 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_download_di_transazioni_su_server_busy_vengono_ritentati()
|
||||||
|
{
|
||||||
|
// Same throttling scenario but on transaction.get, which runs inside the
|
||||||
|
// download tasks (the non-generic retry overload, unlike get_history).
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
scenario.WireTo(server);
|
||||||
|
var failures = 2;
|
||||||
|
var txs = scenario.Txs;
|
||||||
|
server.Handle("blockchain.transaction.get", p =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Decrement(ref failures) >= 0)
|
||||||
|
throw new FakeElectrumError(-101, "server busy");
|
||||||
|
return txs[p[0].GetString()!].ToHex();
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var _ = server;
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(funding.GetHash().ToString(), Assert.Single(result.History).Txid);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- cache su disco ----
|
// ---- cache su disco ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace PalladiumWallet.Tests.Storage;
|
|||||||
/// Tests for the data-path resolution (§8), pinned to a temporary root via
|
/// Tests for the data-path resolution (§8), pinned to a temporary root via
|
||||||
/// <see cref="AppPaths.OverrideDataRoot"/> — the same seam the Android head and
|
/// <see cref="AppPaths.OverrideDataRoot"/> — the same seam the Android head and
|
||||||
/// the CLI --data-dir use — so nothing outside the temp folder is touched.
|
/// the CLI --data-dir use — so nothing outside the temp folder is touched.
|
||||||
/// The pointer-file and portable-mode branches read machine-global locations
|
/// The pointer/portable/default precedence has its own sandboxed tests in
|
||||||
/// and are deliberately not exercised here.
|
/// <see cref="AppPathsResolutionTests"/>; both classes share a collection
|
||||||
|
/// because AppPaths state is static.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Collection("AppPaths")]
|
||||||
public class AppPathsTests : IDisposable
|
public class AppPathsTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _root;
|
private readonly string _root;
|
||||||
@@ -82,3 +84,98 @@ public class AppPathsTests : IDisposable
|
|||||||
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest));
|
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Precedence tests for <see cref="AppPaths.DataRoot"/> — override → portable →
|
||||||
|
/// pointer → default — using the internal bootstrap seams to sandbox the
|
||||||
|
/// machine-global locations (APPDATA pointer dir, executable dir, default root).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("AppPaths")]
|
||||||
|
public sealed class AppPathsResolutionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _sandbox;
|
||||||
|
private readonly string? _savedOverride;
|
||||||
|
|
||||||
|
public AppPathsResolutionTests()
|
||||||
|
{
|
||||||
|
_sandbox = Path.Combine(Path.GetTempPath(), $"plm-paths-{Guid.NewGuid()}");
|
||||||
|
Directory.CreateDirectory(_sandbox);
|
||||||
|
_savedOverride = AppPaths.OverrideDataRoot;
|
||||||
|
AppPaths.OverrideDataRoot = null;
|
||||||
|
AppPaths.BootstrapDirOverride = Path.Combine(_sandbox, "bootstrap");
|
||||||
|
AppPaths.PortableBaseOverride = Path.Combine(_sandbox, "exe");
|
||||||
|
AppPaths.DefaultRootOverride = Path.Combine(_sandbox, "default-root");
|
||||||
|
Directory.CreateDirectory(AppPaths.PortableBaseOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AppPaths.OverrideDataRoot = _savedOverride;
|
||||||
|
AppPaths.BootstrapDirOverride = null;
|
||||||
|
AppPaths.PortableBaseOverride = null;
|
||||||
|
AppPaths.DefaultRootOverride = null;
|
||||||
|
if (Directory.Exists(_sandbox))
|
||||||
|
Directory.Delete(_sandbox, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PortableDir => Path.Combine(_sandbox, "exe", AppPaths.PortableDirName);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Senza_alcuna_configurazione_vince_il_default_e_la_posizione_non_e_configurata()
|
||||||
|
{
|
||||||
|
Assert.Equal(Path.Combine(_sandbox, "default-root"), AppPaths.DataRoot());
|
||||||
|
Assert.False(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void La_cartella_portable_accanto_all_eseguibile_vince_sul_pointer_e_sul_default()
|
||||||
|
{
|
||||||
|
AppPaths.ConfigureDataLocation(Path.Combine(_sandbox, "custom"));
|
||||||
|
Directory.CreateDirectory(PortableDir);
|
||||||
|
|
||||||
|
Assert.Equal(PortableDir, AppPaths.DataRoot());
|
||||||
|
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_pointer_scritto_da_ConfigureDataLocation_vince_sul_default()
|
||||||
|
{
|
||||||
|
var custom = Path.Combine(_sandbox, "custom");
|
||||||
|
AppPaths.ConfigureDataLocation($" {custom} "); // trims and creates
|
||||||
|
|
||||||
|
Assert.True(Directory.Exists(custom));
|
||||||
|
Assert.Equal(custom, AppPaths.DataRoot());
|
||||||
|
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_pointer_vuoto_viene_ignorato_e_si_ricade_sul_default()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(AppPaths.BootstrapDirOverride!);
|
||||||
|
File.WriteAllText(Path.Combine(AppPaths.BootstrapDirOverride!, "data-location"), " ");
|
||||||
|
|
||||||
|
Assert.Equal(Path.Combine(_sandbox, "default-root"), AppPaths.DataRoot());
|
||||||
|
Assert.False(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dati_gia_presenti_nel_default_contano_come_posizione_configurata()
|
||||||
|
{
|
||||||
|
var defaultRoot = Path.Combine(_sandbox, "default-root");
|
||||||
|
Directory.CreateDirectory(defaultRoot);
|
||||||
|
Assert.False(AppPaths.IsDataLocationConfigured()); // exists but empty
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(defaultRoot, "config.json"), "{}");
|
||||||
|
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L_override_esplicito_vince_anche_su_portable_e_pointer()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(PortableDir);
|
||||||
|
AppPaths.ConfigureDataLocation(Path.Combine(_sandbox, "custom"));
|
||||||
|
AppPaths.OverrideDataRoot = Path.Combine(_sandbox, "override");
|
||||||
|
|
||||||
|
Assert.Equal(Path.Combine(_sandbox, "override"), AppPaths.DataRoot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,55 @@ public class ImportedKeyAccountTests
|
|||||||
Assert.Null(account.GetPrivateKey(false, 0));
|
Assert.Null(account.GetPrivateKey(false, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Lista_vuota_viene_rifiutata()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(
|
||||||
|
() => new ImportedKeyAccount([], ScriptKind.NativeSegwit, Profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAddress_fuori_range_o_change_ricade_sul_primo_indirizzo()
|
||||||
|
{
|
||||||
|
var keys = Enumerable.Range(0, 2).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 first = entries[0].Item1.ToString();
|
||||||
|
|
||||||
|
// Fund safety: change and any out-of-range index must map to the first
|
||||||
|
// address, never to an address the wallet does not control.
|
||||||
|
Assert.Equal(first, account.GetAddress(isChange: true, 1).ToString());
|
||||||
|
Assert.Equal(first, account.GetAddress(isChange: false, -1).ToString());
|
||||||
|
Assert.Equal(first, account.GetAddress(isChange: false, 99).ToString());
|
||||||
|
Assert.Equal(first, account.GetChangeAddress(5).ToString());
|
||||||
|
Assert.Equal(entries[1].Item1.ToString(), account.GetAddress(isChange: false, 1).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPublicKey_segue_gli_stessi_confini_di_GetPrivateKey()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.Equal(key.PubKey, account.GetPublicKey(false, 0));
|
||||||
|
Assert.Null(account.GetPublicKey(true, 0));
|
||||||
|
Assert.Null(account.GetPublicKey(false, -1));
|
||||||
|
Assert.Null(account.GetPublicKey(false, 99));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPublicKey_e_null_per_una_entry_watch_only()
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||||
|
var account = new ImportedKeyAccount([(addr, (Key?)null)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
Assert.Null(account.GetPublicKey(false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FixedAddresses_copre_tutti_gli_indirizzi()
|
public void FixedAddresses_copre_tutti_gli_indirizzi()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,6 +104,21 @@ public class TransactionFactoryTests
|
|||||||
Assert.Contains("unconfirmed", ex.Message);
|
Assert.Contains("unconfirmed", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_fee_oltre_la_policy_standard_viene_rifiutata_prima_del_broadcast()
|
||||||
|
{
|
||||||
|
// An absurd fee rate produces a fee far above NBitcoin's standard policy
|
||||||
|
// cap: the builder.Verify safety net must refuse the transaction instead
|
||||||
|
// of letting a fat-finger fee reach the network.
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 100_000_000); // 1 PLM
|
||||||
|
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 1_000_000,
|
||||||
|
feeRateSatPerVByte: 500_000, changeIndex: 0, tipHeight: 100));
|
||||||
|
Assert.Contains("Invalid transaction", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Gli_utxo_congelati_sono_esclusi_dalla_spesa()
|
public void Gli_utxo_congelati_sono_esclusi_dalla_spesa()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -186,4 +186,67 @@ public class WalletLoaderTests
|
|||||||
AccountPath = "84'/0'/0'", AccountXpub = "xpub" };
|
AccountPath = "84'/0'/0'", AccountXpub = "xpub" };
|
||||||
Assert.Equal(expected, WalletLoader.ProfileOf(doc).Kind);
|
Assert.Equal(expected, WalletLoader.ProfileOf(doc).Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- corrupted/incomplete documents (defensive branches of ToAccount) ----
|
||||||
|
|
||||||
|
private static WalletDocument EmptyDoc() => new()
|
||||||
|
{
|
||||||
|
Network = "mainnet",
|
||||||
|
ScriptKind = "NativeSegwit",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAccount_mnemonica_corrotta_nel_file_lancia_eccezione()
|
||||||
|
{
|
||||||
|
var doc = EmptyDoc();
|
||||||
|
doc.Mnemonic = "not a valid mnemonic at all";
|
||||||
|
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(doc));
|
||||||
|
Assert.Contains("mnemonic", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAccount_xprv_corrotta_nel_file_lancia_eccezione()
|
||||||
|
{
|
||||||
|
var doc = EmptyDoc();
|
||||||
|
doc.AccountXprv = "zprvGarbageGarbageGarbage";
|
||||||
|
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(doc));
|
||||||
|
Assert.Contains("Xprv", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAccount_xpub_corrotta_nel_file_lancia_eccezione()
|
||||||
|
{
|
||||||
|
var doc = EmptyDoc();
|
||||||
|
doc.AccountXpub = "zpubGarbageGarbageGarbage";
|
||||||
|
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(doc));
|
||||||
|
Assert.Contains("Xpub", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAccount_documento_senza_alcuna_chiave_lancia_eccezione()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(EmptyDoc()));
|
||||||
|
Assert.Contains("no xpub and no seed", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromXpub_chiave_invalida_lancia_eccezione()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidDataException>(
|
||||||
|
() => WalletLoader.NewFromXpub("zpubGarbage", ChainProfiles.Mainnet));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromXprv_chiave_invalida_lancia_eccezione()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidDataException>(
|
||||||
|
() => WalletLoader.NewFromXprv("zprvGarbage", ChainProfiles.Mainnet));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewFromWif_senza_chiavi_lancia_eccezione()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidDataException>(
|
||||||
|
() => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user