Compare commits
118 Commits
8ab8bbd8b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a4fefadc3 | |||
| 3460e53b4f | |||
| feb765663c | |||
| 322ce8f305 | |||
| 077835a30b | |||
| 663460d62e | |||
| be818a50a8 | |||
| 6f4ae679e5 | |||
| cdede17683 | |||
| a264669151 | |||
| d9c75eaec2 | |||
| c868c17505 | |||
| d8917fbd9a | |||
| 413392f608 | |||
| 970253cbbe | |||
| 31012ce825 | |||
| a46f5e6638 | |||
| 61ed0c0ed0 | |||
| 9097ca4abc | |||
| 2ed8c04064 | |||
| 56135c29f8 | |||
| 5d74038aad | |||
| db5b65ca0d | |||
| 45f7b1401e | |||
| e349a80ddc | |||
| 69be42aba0 | |||
| af2fdcc894 | |||
| 1f4421ae16 | |||
| b304996ff5 | |||
| c75e3921aa | |||
| 50d8e7f21a | |||
| 15978bf564 | |||
| 31aabd0856 | |||
| 56ce1d4259 | |||
| 448f7dc65d | |||
| e8ff99a768 | |||
| 27231c8eec | |||
| eb7ec9e835 | |||
| f0fb3fe05d | |||
| 3f26f52f8b | |||
| cb3ff714d9 | |||
| d0037747b5 | |||
| 34b4313a36 | |||
| 2d017231eb | |||
| e7797017f6 | |||
| 6c24a8bb46 | |||
| 9aecdb1aaa | |||
| 2be43d3177 | |||
| ef60ec0330 | |||
| 567c0de501 | |||
| 6a5daa0c18 | |||
| 4d730d7860 | |||
| 068d85e0e2 | |||
| 1f857ddf52 | |||
| 2953ba35ba | |||
| c0193ae440 | |||
| affeaccae8 | |||
| 4a97172346 | |||
| 5d6ff07f0b | |||
| c5290a1796 | |||
| 227ec62d53 | |||
| fa9d9b12f1 | |||
| 96b6a7e291 | |||
| b13b66160c | |||
| 9744619eb4 | |||
| 5d2d0ad312 | |||
| 3d5a226a5a | |||
| 4b82a0852c | |||
| 41eb1bb788 | |||
| 5ff2075a45 | |||
| 38e0f0a52e | |||
| 14ae39c5aa | |||
| 3054a9baaa | |||
| 4dc37f1b42 | |||
| 47b6064964 | |||
| 002c854497 | |||
| 8b960458ee | |||
| 1b784a6c73 | |||
| 200c12651b | |||
| 5f983ca84e | |||
| 53ecd701c0 | |||
| bfee0dde03 | |||
| 8476eeb247 | |||
| 1914d9462b | |||
| 4c0edde4f7 | |||
| cfc48ff86f | |||
| bb9819ec96 | |||
| 8cdbd70966 | |||
| ee0b73dd52 | |||
| a8d48bedad | |||
| 5d061c6f21 | |||
| 2ddc0f920c | |||
| 58645bd7a7 | |||
| 008e9c395a | |||
| 70cce640aa | |||
| 0f8a764a44 | |||
| d66490b6be | |||
| b00c5821f2 | |||
| 658fcdbced | |||
| e94eaf7700 | |||
| a9ded6497a | |||
| 46aca513b8 | |||
| 6c01d7e6bd | |||
| 3cffba6e98 | |||
| 865daa137d | |||
| 4735490759 | |||
| 4c7e8696cb | |||
| 7727dbfddc | |||
| 58b86ad1af | |||
| 7f2759b2fc | |||
| a8a97f09b7 | |||
| f3bf4cf94a | |||
| 87e1c82610 | |||
| 51c87a7dc9 | |||
| 28cb4ce6ae | |||
| cf6e2d7654 | |||
| fe320584eb | |||
| 6fe31964e1 |
@@ -0,0 +1,80 @@
|
||||
name: Bug Report
|
||||
description: Report a problem with Palladium Wallet
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug.
|
||||
Please fill in every section — incomplete reports are hard to reproduce and may be closed.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Wallet version
|
||||
description: Shown in Help → Info (e.g. `1.0.0`).
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the bug clearly and concisely. What did you see? What did you expect?
|
||||
placeholder: "When I click … the wallet shows … instead of …"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps that reliably trigger the bug.
|
||||
placeholder: |
|
||||
1. Open the wallet
|
||||
2. Go to …
|
||||
3. Click …
|
||||
4. Observe …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should have happened instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / error messages
|
||||
description: |
|
||||
Paste any error dialogs, crash messages, or stack traces.
|
||||
Leave blank if none.
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate
|
||||
required: true
|
||||
- label: I reproduced this on the latest available version
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/davide3011/PalladiumWallet/security/policy
|
||||
about: Please report security vulnerabilities privately, not as a public issue. See SECURITY.md.
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or improvement for Palladium Wallet
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an improvement.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What problem are you trying to solve? Is it related to something you find missing or frustrating?
|
||||
placeholder: "I'm always frustrated when …"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe what you'd like to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any alternative solutions or workarounds you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Affected platform(s)
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- CLI
|
||||
- All
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate
|
||||
required: true
|
||||
@@ -0,0 +1,26 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change and why? -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- Closes #... (if applicable) -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / cleanup
|
||||
- [ ] Documentation
|
||||
- [ ] Build / CI
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How was this verified? e.g. `dotnet test`, manual run on Windows/Linux/Android, golden-vector comparison -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `dotnet test` passes
|
||||
- [ ] No seed/private key material logged or persisted in plaintext (if touching Crypto/Wallet/Storage)
|
||||
- [ ] Server responses still validated with Merkle + checkpoints (if touching Spv/Net)
|
||||
- [ ] Code/comments/commit messages are in English
|
||||
@@ -74,6 +74,8 @@ temp/
|
||||
*.key
|
||||
*.pfx
|
||||
*.p12
|
||||
*.keystore
|
||||
*.jks
|
||||
secrets/
|
||||
|
||||
# Wallet data and local runtime state
|
||||
@@ -91,6 +93,7 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
CLAUDE.md
|
||||
|
||||
blueprint.md
|
||||
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
|
||||
findings/
|
||||
crash-*.bin
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents (OpenAI Codex and others following the AGENTS.md convention) when working with code in this repository.
|
||||
|
||||
> **Sync rule:** `CLAUDE.md` (read by Claude Code) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **Code, comments, commit messages, documentation files**: English only.
|
||||
|
||||
## How to assist
|
||||
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||
|
||||
## Stack and structure
|
||||
|
||||
.NET 10 + Avalonia UI 12 + NBitcoin.
|
||||
|
||||
```
|
||||
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||
```
|
||||
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- **Build:** `dotnet build`
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- 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): test against the in-process fake server `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 (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **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)
|
||||
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||
- 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 commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
## Architecture (points that require reading multiple files)
|
||||
|
||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||
@@ -0,0 +1,306 @@
|
||||
# Changelog
|
||||
|
||||
Technical changelog for PalladiumWallet. Format loosely follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); entries are grouped
|
||||
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||
release and covers the full history from the initial commit.
|
||||
|
||||
## [1.0.0] — 2026-07-09
|
||||
|
||||
First stable release. Closes the last open security gap from 0.9.x (header
|
||||
trust was not actually anchored to any checkpoint), fixes several crash
|
||||
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
|
||||
decoding to the transaction detail view.
|
||||
|
||||
### Security
|
||||
|
||||
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
|
||||
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
|
||||
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
|
||||
plus one near tip). Previously the checkpoint array was empty and the
|
||||
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
|
||||
never called — on this LWMA chain, where PoW can't be recomputed
|
||||
client-side, a malicious or eclipsing server could hand back any
|
||||
internally-consistent header for a Merkle proof with nothing tying it to
|
||||
the real chain. For every header used in a Merkle proof, the intervening
|
||||
headers are now downloaded back to the nearest checkpoint and verified as
|
||||
an unbroken prev-hash chain terminating in that checkpoint's exact hash
|
||||
(memoized per sync session). Testnet/Regtest remain unanchored (no node
|
||||
available to source checkpoints from); a missing checkpoint is a no-op,
|
||||
not a failure.
|
||||
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
|
||||
target per untrusted-input parser (header, Merkle proof, peer list,
|
||||
wallet file, mnemonic/key/address/amount), each encoding that parser's
|
||||
documented error contract. Found and fixed:
|
||||
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
|
||||
wordlist instead of failing gracefully.
|
||||
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
|
||||
shape other than the expected `[ip, hostname, [features...]]`, and on a
|
||||
JSON string containing invalid UTF-8.
|
||||
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
|
||||
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
|
||||
the documented `WrongPasswordException`/`InvalidDataException`
|
||||
contract; worse, the PBKDF2 iteration count was read from the
|
||||
(attacker-controlled) container with no upper bound — a tampered file
|
||||
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
|
||||
count is now clamped to 10,000,000.
|
||||
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
|
||||
connection until manually deleted; now falls back to first-contact
|
||||
TOFU like `ServerRegistry` already did.
|
||||
- The seed corpus (incl. a regression input per fixed finding) replays
|
||||
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
|
||||
regress.
|
||||
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
|
||||
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
|
||||
and disclosure of the AI-assisted testing methodology used (adversarial
|
||||
fake-server simulation, property-based fuzzing, targeted security
|
||||
review) as a complement to, not a replacement for, independent review.
|
||||
|
||||
### Added
|
||||
|
||||
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
|
||||
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
|
||||
are each decoded independently) and coinbase scriptSig pool tags (e.g.
|
||||
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
|
||||
height/extranonce). Both were previously dropped entirely — discarded
|
||||
once no destination address could be derived from the script.
|
||||
- Help overlay: "User guide" button next to "Report a bug", linking to
|
||||
`USERGUIDE.md` on GitHub.
|
||||
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
|
||||
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
|
||||
exact numbers the software enforces.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
|
||||
in Italian regardless of the active language; routed through `Loc` with
|
||||
the missing keys added. `CertificatePinMismatchException` no longer
|
||||
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
|
||||
the UI to translate.
|
||||
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
|
||||
the code/docs-are-English-only policy; translated every user-facing
|
||||
string and comment.
|
||||
|
||||
### Testing
|
||||
|
||||
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
|
||||
checkpoint-anchoring (including the memoization and non-generic-retry
|
||||
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
|
||||
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
|
||||
wordlist languages plus the empty-input guard, SLIP-132 rejection of
|
||||
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
|
||||
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
|
||||
`WalletLoader`'s defensive branches for corrupted files,
|
||||
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
|
||||
precedence chain (via new internal override seams), `UpdateChecker`
|
||||
end-to-end via a stub-transport seam, and `ElectrumClient`'s
|
||||
multi-segment response dispatch.
|
||||
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
|
||||
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
|
||||
absence of false positives on standard outputs/inputs.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
|
||||
dense prose to scannable bullet lists; a stale `SECURITY.md` file
|
||||
reference fixed.
|
||||
- `README.md` test-coverage section and `SECURITY.md` updated to describe
|
||||
the checkpoint anchoring and fuzzing guarantees actually enforced now.
|
||||
|
||||
## [0.9.1] — 2026-07-02
|
||||
|
||||
### Testing
|
||||
|
||||
- Test suite expanded from 239 to 307 tests; `Core` line coverage raised
|
||||
from ~50% to ~92% (branch coverage from ~41% to ~79%).
|
||||
- In-process fake ElectrumX server (`tests/.../Net/FakeElectrumServer.cs`):
|
||||
a real loopback TCP socket speaking newline-delimited JSON-RPC 2.0,
|
||||
optionally TLS with a self-signed certificate, with per-method handlers
|
||||
and call counters — exercises the network stack against real socket code
|
||||
instead of mocks.
|
||||
- New end-to-end coverage for previously untested network/SPV code:
|
||||
`ElectrumClient` (request pipelining, error mapping, notifications,
|
||||
disconnection, cancellation, TLS/TOFU handshake), `WalletSynchronizer`
|
||||
(gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature
|
||||
balances, busy-retry, disk-cache reuse, and the security-critical path
|
||||
where a lying server fails Merkle verification and the sync aborts),
|
||||
`TransactionInspector` (fee calculation, mine/theirs attribution, RBF,
|
||||
coinbase handling), `CertificatePinStore` (TOFU pin/match/mismatch/reset),
|
||||
`ServerRegistry` peer discovery, and `UpdateChecker` tag parsing.
|
||||
- `TransactionFactory`: added coverage for legacy/P2SH/segwit destinations,
|
||||
multi-UTXO selection, dust change absorbed into the fee, and a golden
|
||||
txid anchoring the PSBT signing path (deterministic via RFC 6979).
|
||||
- Property-based tests extended: SLIP-132 roundtrip for every script kind
|
||||
and network, `WalletDocument` JSON roundtrip with arbitrary
|
||||
labels/contacts, `Scripthash` cross-checked against an independent
|
||||
SHA-256 computation.
|
||||
- `update-version.sh`: single script to bump the version across the project
|
||||
ahead of a release tag.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `CertificatePinStore.Load`: a corrupted pin file threw and blocked every
|
||||
SSL connection until the user manually deleted it; now falls back to
|
||||
first-contact TOFU, same as `ServerRegistry` already did for its own file.
|
||||
- `EncryptedFile.IsEncrypted`: threw on valid JSON with a non-object root
|
||||
(e.g. a bare number or array) or on invalid UTF-16 input, instead of
|
||||
returning `false`. Both bugs were found by the expanded property tests.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md`: "Running tests" section rewritten with a per-area coverage
|
||||
table, the coverage-measurement command, and a description of the fake
|
||||
ElectrumX server.
|
||||
- `CLAUDE.md`/`AGENTS.md` kept in sync, pointing future work at extending
|
||||
the fake ElectrumX server instead of mocking `ElectrumClient` (not an
|
||||
interface, by design).
|
||||
|
||||
## [0.9.0] — 2026-07-02
|
||||
|
||||
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
|
||||
Bitcoin-derived UTXO chain — targeting desktop (Windows/Linux) and Android
|
||||
from a single Avalonia UI codebase on .NET 10 + NBitcoin.
|
||||
|
||||
### Core — Chain
|
||||
|
||||
- Network profiles and consensus constants centralized in `Core/Chain`
|
||||
(`ChainProfiles`/`PalladiumNetworks`): address prefixes, BIP32 headers,
|
||||
bech32 HRP, genesis, ports, `coin_type` 746 — selectable per network
|
||||
(mainnet/testnet/regtest), no scattered magic numbers.
|
||||
- LWMA difficulty / 2-minute blocks: SPV cannot recompute LWMA retargeting,
|
||||
so PoW validation is skipped and trust is anchored to hardcoded
|
||||
checkpoints instead.
|
||||
|
||||
### Core — Crypto
|
||||
|
||||
- HD key derivation: BIP32/39/84, HD accounts, SLIP-132 extended key
|
||||
serialization.
|
||||
- Address types: P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86).
|
||||
- `IWalletAccount` abstraction with WIF/xpub/xprv keystore import (including
|
||||
watch-only accounts from public material only).
|
||||
|
||||
### Core — Net
|
||||
|
||||
- Custom `ElectrumClient`: JSON-RPC 2.0 client for the ElectrumX-like
|
||||
indexing server, with TLS support and TOFU certificate pinning.
|
||||
- `ServerRegistry`: bootstrap server list, peer discovery, persisted last-used
|
||||
server, fallback resolution for `--server`.
|
||||
- Batched writes, zero-allocation reads, bounded in-flight requests for
|
||||
network throughput.
|
||||
- Fix: allow changing the indexing server at any time, including during an
|
||||
active sync.
|
||||
|
||||
### Core — Spv
|
||||
|
||||
- Header sync with Merkle proof verification and scripthash subscriptions
|
||||
against hardcoded checkpoints (no full PoW recomputation, per the chain
|
||||
profile above).
|
||||
- Per-address balance and transaction-count aggregation in sync results.
|
||||
- Electrum-style continuous updates: incremental, parallelized sync across
|
||||
address chains, with a persistent header cache.
|
||||
- Fix: resilient sync — history discovery via `GetHistory`, transaction
|
||||
caching, automatic reconnect fallback.
|
||||
|
||||
### Core — Storage
|
||||
|
||||
- Versioned, encrypted JSON wallet file (`WalletDocument`) with dedicated
|
||||
persistence and loader layer.
|
||||
- `WalletLock`: prevents concurrent access to the same wallet file; acquired
|
||||
before load and before close (fixed a race where it wasn't).
|
||||
- XDG-compliant data paths (`AppPaths`) with per-platform override seam used
|
||||
by the Android head; English as default UI language.
|
||||
- Contacts list (name + address) persisted in `WalletDocument`.
|
||||
- Documented caveat: `WalletStore.Save` writes plaintext JSON when the
|
||||
wallet is unencrypted — seed/keys are only ever encrypted-at-rest when the
|
||||
user opts into a password.
|
||||
|
||||
### Core — Wallet
|
||||
|
||||
- Coin selection, PSBT-centric transaction factory, and wallet loader.
|
||||
- Confirmation-threshold enforcement before spending UTXOs; immature
|
||||
(coinbase) balance surfaced separately from confirmed balance; pending
|
||||
mempool balance shown, with unconfirmed UTXOs excluded from spending by
|
||||
default.
|
||||
- Fix: reject amounts with sub-satoshi precision.
|
||||
|
||||
### App — Avalonia UI (shared, `src/App`)
|
||||
|
||||
- Single shared `MainView` (UserControl) hosted by `MainWindow` on desktop
|
||||
and as the single-view root on Android; single `MainWindowViewModel`
|
||||
(CommunityToolkit.Mvvm), later split into partial files by area
|
||||
(Wizard/Settings/Sync/Send/Contacts/Receive/...).
|
||||
- Step-by-step setup wizard (replacing a single-form panel), including a
|
||||
first-run data-location step, multi-wallet chooser, confirm-password and
|
||||
encrypt toggle, and dedicated flows for creating a wallet vs. importing
|
||||
from xpub/xprv/WIF.
|
||||
- In-app overlay pattern for details/settings/help (`IsXxxOpen` flags, no OS
|
||||
windows) replacing earlier nested submenus and a separate
|
||||
`AddressInfoWindow`: server settings, app settings (language/unit),
|
||||
wallet info (xpub, password-gated seed reveal), address detail
|
||||
(password-gated private key reveal), transaction detail with full
|
||||
on-chain data (inputs/outputs, no truncation), and a Help overlay
|
||||
(Info/Donate tabs).
|
||||
- Connection-status indicator in the bottom bar; connect-before-wallet-open
|
||||
flow; persisted last-used server; server discovery split into its own
|
||||
always-usable button; mainnet hardcoded (network selector removed for the
|
||||
first release).
|
||||
- Localization: `Loc` key → 6-language dictionary (it/en/es/fr/pt/de) with
|
||||
live language switching.
|
||||
- Receive: QR code generation and copy-to-clipboard for the receive address.
|
||||
- Android: QR code scanner for the Send address field.
|
||||
- Centralized design system (color tokens, gradient hero, SVG tab icons);
|
||||
responsive layout for portrait mobile, unified tab bar (desktop +
|
||||
mobile), two-column desktop layout for Send/Receive; various mobile-only
|
||||
fixes (tab bar sizing/indicator overlap, text overflow, full-screen server
|
||||
overlay on mobile).
|
||||
- App version shown in window title and Help overlay, read from the single
|
||||
`<Version>` source in the App csproj.
|
||||
- In-app update check: compares the running version against the latest
|
||||
GitHub release tag on startup and shows an overlay with the new tag when
|
||||
one is available (best-effort — silent on network/parse failure).
|
||||
- In-app bug-report button (Help overlay) opening a pre-filled GitHub issue
|
||||
template; issue/PR templates completed.
|
||||
|
||||
### Android head (`src/App.Android`)
|
||||
|
||||
- Architecture split: shared UI library (`src/App`) + `src/App.Desktop` +
|
||||
`src/App.Android` heads from the same source (`refactor(arch)`), each
|
||||
carrying only the per-platform entry point and packages.
|
||||
- App logo as launcher icon.
|
||||
- Persistent release-signing keystore workflow
|
||||
(`docker/keystore/generate-keystore.sh`, git-ignored output): every
|
||||
release APK is signed with the same key so installing a newer build
|
||||
updates a previous install in place instead of requiring an uninstall.
|
||||
`versionCode` derived from `<Version>` instead of a fixed constant.
|
||||
|
||||
### CLI (`src/Cli`)
|
||||
|
||||
- Commands: `create`/`restore`/`restore-xpub`/`info`,
|
||||
`sync`/`send`/`servers`/`reset-certs`, `newseed`/`addresses`.
|
||||
- `servers` command, `info --addresses`, and registry-based fallback
|
||||
resolution for `--server`.
|
||||
|
||||
### Build & Distribution
|
||||
|
||||
- Docker-based reproducible build system (`docker/build.sh` +
|
||||
`docker/Dockerfile.*`): pinned toolchain (.NET 10 SDK, JDK, Android SDK),
|
||||
builds Windows/Linux single-file executables and a signed Android APK
|
||||
without any SDK installed on the host.
|
||||
- Android release signing wired into `build_android` (see Android head
|
||||
above): requires the persistent keystore, prompts for its passwords at
|
||||
build time, mounts it read-only into the build container.
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit test coverage across all Core modules, later expanded to 209 tests.
|
||||
- Property-based tests via CsCheck (`PropertyTests.cs`), bringing total
|
||||
coverage to 218 tests; dedicated `WalletLock` concurrency tests.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md` (project overview, quickstart, reproducible builds), `CLAUDE.md`
|
||||
(codebase guidance for AI tooling, kept in sync with the multi-head
|
||||
architecture and .NET 10 migration), `SECURITY.md` (threat model and SPV
|
||||
trust assumptions), coding-agent guide.
|
||||
- Code comments translated to English project-wide, per the language policy
|
||||
(Italian conversation, English code/docs).
|
||||
@@ -0,0 +1,104 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Sync rule:** `AGENTS.md` (read by other coding agents) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **Code, comments, commit messages, documentation files**: English only.
|
||||
|
||||
## How to assist
|
||||
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||
|
||||
## Stack and structure
|
||||
|
||||
.NET 10 + Avalonia UI 12 + NBitcoin.
|
||||
|
||||
```
|
||||
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||
```
|
||||
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- **Build:** `dotnet build`
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- 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): test against the in-process fake server `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 (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **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)
|
||||
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||
- 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 commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
## Architecture (points that require reading multiple files)
|
||||
|
||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||
- **Network profile:** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Davide Grilli
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,50 +1,134 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# Palladium Wallet
|
||||
|
||||
**An SPV wallet built specifically for the Palladium (PLM) cryptocurrency** and optimized for its chain. Runs on desktop (Windows/Linux) and Android from a single shared codebase.
|
||||
|
||||
Unlike generic wallets adapted to many coins, Palladium Wallet is designed around Palladium's consensus parameters — a Bitcoin-derived UTXO chain with 2-minute blocks and LWMA difficulty — and centralizes them in a single network profile. This keeps it lightweight, predictable and faithful to the chain: no client-side difficulty recalculation (trust is anchored to hardcoded checkpoints), mandatory Merkle verification on every confirmed transaction, and a network client written specifically for Palladium's indexing server.
|
||||
|
||||
## Features
|
||||
|
||||
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
||||
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub.
|
||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
|
||||
- **Multi-network**: mainnet, testnet, regtest.
|
||||
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
||||
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
PalladiumWallet.sln
|
||||
├─ src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||
├─ src/App/ shared Avalonia UI library (Views, ViewModels, Loc, Assets)
|
||||
├─ src/App.Desktop/ desktop head (Windows/Linux) → runnable
|
||||
├─ src/App.Android/ Android head → apk
|
||||
├─ src/Cli/ CLI on the same Core
|
||||
└─ tests/ xUnit
|
||||
```
|
||||
|
||||
The UI is written **once** in `src/App`; the desktop and Android heads only add the per-platform
|
||||
entry point and packages.
|
||||
|
||||
Stack: **.NET 10 + Avalonia UI 12 + NBitcoin**.
|
||||
|
||||
---
|
||||
|
||||
## Development environment
|
||||
|
||||
For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto are fully testable without the GUI or a real network.
|
||||
|
||||
### Windows
|
||||
|
||||
1. Install the .NET 10 SDK:
|
||||
```powershell
|
||||
winget install Microsoft.DotNet.SDK.10
|
||||
```
|
||||
(alternatively, the installer from <https://dotnet.microsoft.com/download/dotnet/10.0>)
|
||||
2. Clone the repository and restore dependencies:
|
||||
```powershell
|
||||
git clone <repo-URL>
|
||||
cd PalladiumWallet
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
|
||||
```bash
|
||||
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir "$HOME/.dotnet10"
|
||||
export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"
|
||||
```
|
||||
(add the two `export` lines to your `~/.bashrc` to make them permanent)
|
||||
2. Clone and restore:
|
||||
```bash
|
||||
git clone <repo-URL>
|
||||
cd PalladiumWallet
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
> The GUI uses Avalonia, which runs natively on Windows and Linux with no extra graphics dependencies.
|
||||
|
||||
### Android (additional setup)
|
||||
|
||||
Building the apk also requires the Android workload, a JDK, and the Android SDK:
|
||||
|
||||
```bash
|
||||
dotnet workload install android # .NET Android build packs
|
||||
# JDK 17+ must be available (set JAVA_HOME)
|
||||
# Provision the Android SDK once into ~/android-sdk:
|
||||
dotnet build src/App.Android -t:InstallAndroidDependencies \
|
||||
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true
|
||||
```
|
||||
|
||||
To run the apk on an emulator (instead of a physical device), see
|
||||
[*Android emulator (developer setup)*](#android-emulator-developer-setup) below.
|
||||
|
||||
---
|
||||
|
||||
## Running it
|
||||
|
||||
### Desktop GUI in debug (Linux & Windows)
|
||||
|
||||
The desktop head runs the same way on both OSes (`Debug` is the default configuration). Run it from
|
||||
the repo root:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/App.Desktop # single run (Debug)
|
||||
dotnet watch --project src/App.Desktop # with hot reload (edit XAML/C# and see changes live)
|
||||
dotnet run --project src/App.Desktop -c Release # to try the Release config
|
||||
```
|
||||
|
||||
- **Linux** — runs natively on X11/Wayland, no extra graphics packages. On **WSL2** the window
|
||||
appears on the Windows desktop through WSLg (already working here, nothing to install).
|
||||
- **Windows** — runs natively; use the same commands from PowerShell or a terminal.
|
||||
|
||||
The app writes its data under the per-user data folder (see *User guide → First launch*); delete it
|
||||
to start from a clean first-run wizard.
|
||||
|
||||
### CLI
|
||||
|
||||
Same core, useful for scripts and headless environments:
|
||||
```bash
|
||||
dotnet run --project src/Cli -- <command>
|
||||
```
|
||||
Run without arguments for the full list of commands.
|
||||
|
||||
### Android
|
||||
|
||||
There is no `dotnet run` for a phone: build the apk and install it (see *Building → Android apk*),
|
||||
or run it on an emulator (see *Android emulator (developer setup)*).
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
Tests are the **primary verification layer** — the core logic and crypto run headless, without the GUI or a real network.
|
||||
|
||||
Run the whole suite:
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
Run a single test (or a group) by name:
|
||||
```bash
|
||||
dotnet test --filter "FullyQualifiedName~TestName"
|
||||
```
|
||||
|
||||
Run only the tests in one project:
|
||||
```bash
|
||||
dotnet test tests/PalladiumWallet.Tests
|
||||
```
|
||||
|
||||
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`):
|
||||
```bash
|
||||
dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
||||
|
||||
### What the suite covers
|
||||
|
||||
The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
|
||||
|
||||
| Area | What is verified |
|
||||
|---|---|
|
||||
| `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, 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 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, 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-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, 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**
|
||||
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
|
||||
socket speaking newline-delimited JSON-RPC (optionally TLS with a self-signed
|
||||
certificate), with per-method handlers and call counters. Client, synchroniser
|
||||
and inspector therefore exercise the same code paths used in production —
|
||||
framing, retries, TLS pinning included — without any external dependency.
|
||||
|
||||
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||
|
||||
### Fuzzing
|
||||
|
||||
`tests/PalladiumWallet.Fuzz` fuzzes every parser that consumes untrusted input
|
||||
(server-supplied headers/proofs/peer lists, wallet files, user-pasted
|
||||
keys/mnemonics/addresses/amounts) via [SharpFuzz](https://github.com/Metalnem/sharpfuzz):
|
||||
each target enforces the parser's documented error contract, so any other
|
||||
exception escaping is a finding. The seed corpus — including a regression input
|
||||
for every crash found so far — replays automatically inside `dotnet test`;
|
||||
coverage-guided campaigns run separately with afl++ (`tests/PalladiumWallet.Fuzz/fuzz.sh`),
|
||||
and a built-in random-mutation mode (`dotnet run -- <target> --random N`) needs
|
||||
no external tooling. See `tests/PalladiumWallet.Fuzz/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
### Reproducible builds (Docker) — recommended for release binaries
|
||||
|
||||
All three distribution targets (Windows exe, Linux binary, Android apk) can be
|
||||
built with one command inside Docker, with **no SDK installed on the host**:
|
||||
|
||||
```bash
|
||||
./docker/build.sh # interactive menu, or: ./docker/build.sh all
|
||||
```
|
||||
|
||||
Why build this way: the whole toolchain is pinned in the Dockerfiles, so every
|
||||
build uses exactly the same SDK versions regardless of the host machine — no
|
||||
toolchain drift between releases — and the build environment itself is
|
||||
reviewable in the repo. For a wallet this is a trust property, not a
|
||||
convenience: anyone can rebuild the published binaries from source and check
|
||||
they were produced by the process the repository declares.
|
||||
|
||||
See [docker/README.md](docker/README.md) for prerequisites, usage, how to run
|
||||
each produced artifact, and troubleshooting. The sections below cover manual
|
||||
builds with a locally installed SDK (the normal path during development).
|
||||
|
||||
### Development build
|
||||
|
||||
```bash
|
||||
dotnet build # whole solution (debug)
|
||||
dotnet build src/App.Desktop # desktop head only
|
||||
```
|
||||
|
||||
> A solution-wide `dotnet build` also builds the Android head, which needs the Android SDK
|
||||
> (see *Android emulator (developer setup)* below). If you don't have it, build the specific
|
||||
> non-Android projects (`src/App.Desktop`, `src/Cli`, `tests/...`).
|
||||
|
||||
### Desktop release (self-contained)
|
||||
|
||||
```bash
|
||||
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
|
||||
# IncludeNativeLibrariesForSelfExtract embeds Avalonia's native libs (Skia, HarfBuzz, ANGLE):
|
||||
# without it they stay as separate DLLs and the .exe alone silently fails to start.
|
||||
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true --self-contained
|
||||
|
||||
# Linux — self-contained; AppImage then produced with PupNet Deploy
|
||||
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
|
||||
dotnet publish src/App.Desktop -c Release -r linux-x64 --self-contained
|
||||
```
|
||||
|
||||
[PupNet Deploy](https://github.com/kuiperzone/PupNet-Deploy) turns the Linux publish into an AppImage.
|
||||
The executable is named `PalladiumWallet` (set via `<AssemblyName>` in the desktop head).
|
||||
|
||||
### Android apk
|
||||
|
||||
Prerequisites: the Android workload + SDK (see *Development environment → Android*). The Android
|
||||
head already sets `<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>`, so the apk is
|
||||
**self-contained** and installs/runs standalone (a Fast-Deployment debug apk crashes at launch
|
||||
with "No assemblies found" when installed without `adb`).
|
||||
|
||||
```bash
|
||||
# Default debug apk — all ABIs (arm64-v8a + x86_64): runs on phones AND the x86_64 emulator.
|
||||
# ~79 MB. Output: src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
|
||||
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
|
||||
-p:AndroidSdkDirectory=$HOME/android-sdk
|
||||
|
||||
# Smaller apk for a real phone — arm64 only (~41 MB):
|
||||
JAVA_HOME=<jdk-path> dotnet build src/App.Android -c Debug -t:SignAndroidPackage \
|
||||
-p:AndroidSdkDirectory=$HOME/android-sdk -p:AbiArm64Only=true
|
||||
```
|
||||
|
||||
The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android head's
|
||||
`<RuntimeIdentifiers>` in its csproj — do **not** pass `-p:RuntimeIdentifiers=android-arm64` on the
|
||||
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
||||
`AndroidSupportedAbis` property is deprecated and ignored.)
|
||||
|
||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) This debug apk is fine for personal
|
||||
sideloading, but debug builds are signed with a key regenerated per build machine/container, so a
|
||||
newer debug apk won't install over an older one without an uninstall first. For a stable signature
|
||||
across releases (needed to update an existing install in place), build via
|
||||
`./docker/build.sh android` instead — see [docker/keystore/README.md](docker/keystore/README.md).
|
||||
|
||||
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
|
||||
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
||||
> `arm64-v8a` only) but is meant for a physical arm64 phone — on the x86_64 emulator it only runs
|
||||
> through slow ARM translation and stalls on the splash, so **verify it on a real device**.
|
||||
|
||||
### Version
|
||||
|
||||
The application **version** is set in a single place: the `<Version>` tag in
|
||||
[`src/App/PalladiumWallet.App.csproj`](src/App/PalladiumWallet.App.csproj). It appears in the desktop
|
||||
window title, in the Help dialog, and is stamped into the published binaries (and the apk's versionName).
|
||||
|
||||
---
|
||||
|
||||
## Android emulator (developer setup)
|
||||
|
||||
How to run the apk without a physical device. Paths assume the Android SDK in `~/android-sdk`
|
||||
and a JDK at `JAVA_HOME` (JDK 17+). Tested on Linux / WSL2.
|
||||
|
||||
**1. Install the emulator, a system image and the matching platform** (once):
|
||||
```bash
|
||||
SDK=$HOME/android-sdk
|
||||
$SDK/cmdline-tools/latest/bin/sdkmanager --sdk_root=$SDK \
|
||||
"emulator" "system-images;android-34;google_apis;x86_64" "platforms;android-34"
|
||||
```
|
||||
Use an `x86_64` image so the emulator runs with hardware acceleration (KVM); the app's min SDK is 23.
|
||||
|
||||
**2. Hardware acceleration (Linux/WSL2)** — the emulator needs access to `/dev/kvm`. Add yourself
|
||||
to the `kvm` group once, then start a new shell (or prefix the launch with `sg kvm -c '…'`):
|
||||
```bash
|
||||
sudo usermod -aG kvm $USER
|
||||
```
|
||||
On WSL2, KVM must be enabled on the Windows host (nested virtualization); the emulator window is
|
||||
shown on the Windows desktop through WSLg.
|
||||
|
||||
**3. Create an AVD** (virtual device):
|
||||
```bash
|
||||
echo no | $SDK/cmdline-tools/latest/bin/avdmanager create avd \
|
||||
-n plm -k "system-images;android-34;google_apis;x86_64" -d pixel
|
||||
```
|
||||
|
||||
**4. Launch the emulator** (software GL is the most robust under WSLg):
|
||||
```bash
|
||||
$SDK/emulator/emulator -avd plm -gpu swiftshader_indirect -no-snapshot -no-audio &
|
||||
$SDK/platform-tools/adb wait-for-device
|
||||
# wait for full boot:
|
||||
until [ "$($SDK/platform-tools/adb shell getprop sys.boot_completed | tr -d '\r')" = 1 ]; do sleep 2; done
|
||||
```
|
||||
If the window won't render, add `-no-window` and rely on `adb` + screenshots
|
||||
(`adb exec-out screencap -p > shot.png`).
|
||||
|
||||
**5. Install and run the apk; capture logs to debug crashes:**
|
||||
```bash
|
||||
ADB=$SDK/platform-tools/adb
|
||||
$ADB install -r src/App.Android/bin/Debug/net10.0-android/*-Signed.apk
|
||||
$ADB shell monkey -p io.github.davide3011.palladiumwallet -c android.intent.category.LAUNCHER 1
|
||||
$ADB logcat -d | grep -iE "monodroid|exception|fatal|avalonia"
|
||||
```
|
||||
|
||||
**VS Code "Android iOS Emulator" extension** (optional, click-to-launch): it only starts an
|
||||
existing AVD, so create one first (step 3). Point it at the emulator binary:
|
||||
```jsonc
|
||||
// VS Code settings.json
|
||||
"emulator.emulatorPathLinux": "/home/<user>/android-sdk/emulator"
|
||||
// on WSL, use: "emulator.emulatorPathWSL": "/home/<user>/android-sdk/emulator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User guide (quick)
|
||||
|
||||
### First launch
|
||||
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
||||
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
||||
3. If you create a wallet, **write the seed phrase down on paper**: it will not be shown again. You can protect the file with a password.
|
||||
|
||||
> **Desktop vs Android.** The UI and features are the same on both. Differences: on Android the
|
||||
> data-location step is skipped (fixed app sandbox) and *File → Open wallet from file* (importing a
|
||||
> wallet from an arbitrary file) is hidden — open wallets from the in-app chooser instead. The
|
||||
> version is shown in the desktop window title and, on every platform, in the Help dialog. The CLI
|
||||
> is desktop/headless only.
|
||||
|
||||
### Main tabs
|
||||
- **History** — list of transactions. *Double-click* (double-tap on touch) a row to open the full detail (amount, fee, addresses, sizes, confirmations).
|
||||
- **Send** — recipient + amount (or "send all"), adjustable fee; for watch-only wallets a PSBT is produced to be signed offline.
|
||||
- **Receive** — next unused address, with a **QR code** and a **Copy** button.
|
||||
- **Addresses** — all derived addresses with balances; click for details (keys, derivation path).
|
||||
- **Contacts** — address book with labels.
|
||||
|
||||
### Connection
|
||||
- The status indicator at the bottom shows the connection to the **indexing server**; tapping it opens the server settings.
|
||||
- Sync is SPV: it downloads only what concerns your wallet and verifies every confirmed transaction with a Merkle proof.
|
||||
|
||||
### Settings and Help
|
||||
- **Settings**: language, display unit (PLM / mPLM / µPLM / sat), server.
|
||||
- **Help**: software information and version.
|
||||
|
||||
### CLI in brief
|
||||
```bash
|
||||
# Wallet
|
||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
||||
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
||||
dotnet run --project src/Cli -- info [--net ...] [--password P]
|
||||
|
||||
# Network
|
||||
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
||||
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
||||
```
|
||||
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
Technical, per-version changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## License
|
||||
|
||||
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Security
|
||||
|
||||
## Threat model
|
||||
|
||||
Palladium Wallet is a self-custody SPV wallet. It is designed to protect funds against:
|
||||
|
||||
- Theft of the wallet file at rest (AES-256-GCM encryption with PBKDF2-HMAC-SHA512)
|
||||
- Memory snooping of private keys after unlock (keys are held only in process memory, never written to disk in plaintext unless the user explicitly disables encryption)
|
||||
- Fraudulent transaction injection by a malicious server (every confirmed transaction is verified with a Merkle proof against SPV-validated block headers anchored to hardcoded checkpoints)
|
||||
|
||||
It does **not** protect against:
|
||||
|
||||
- A fully compromised operating system or process (malware with memory access can extract keys from RAM)
|
||||
- An attacker who obtains the wallet file **and** the password
|
||||
- Denial of service or eclipse attacks against the indexing server
|
||||
- Network-level traffic analysis (no Tor/proxy support in the first release)
|
||||
|
||||
---
|
||||
|
||||
## SPV trust model
|
||||
|
||||
This wallet is an SPV client, not a full node. It validates:
|
||||
|
||||
- Block headers: `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`)
|
||||
|
||||
It does **not** validate:
|
||||
|
||||
- Script execution (P2WPKH scripts are assumed valid if the server returns a confirmed transaction with a valid Merkle proof)
|
||||
- Double-spend detection beyond what the server reports (an eclipse attack on the indexing server could hide a conflicting transaction)
|
||||
- Full block validity (coinbase, consensus rules beyond the header)
|
||||
|
||||
The indexing server (ElectrumX-compatible, port 50001/50002) is a **semi-trusted** component. It can:
|
||||
|
||||
- Lie about unconfirmed (mempool) transactions — the wallet shows mempool transactions as unconfirmed and non-spendable
|
||||
- Refuse to relay a broadcast transaction
|
||||
- Delay reporting of new blocks
|
||||
|
||||
It cannot (given correct Merkle verification):
|
||||
|
||||
- Fabricate a confirmed transaction with a valid Merkle proof
|
||||
- Forge a payment to a wrong address
|
||||
|
||||
**Progressive verification (mobile-friendly sync).** On a wallet with many historical
|
||||
transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof:
|
||||
balance and history are shown as soon as transaction downloads finish (`PartialResult`,
|
||||
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
|
||||
each transaction's `Verified` flag catches up progressively. This means the UI can display a
|
||||
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
|
||||
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
|
||||
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
|
||||
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
|
||||
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
|
||||
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
|
||||
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
|
||||
a sync, never a partial one, so no unverified data survives a restart.
|
||||
|
||||
---
|
||||
|
||||
## Key and seed management
|
||||
|
||||
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock
|
||||
- Private keys are never written to disk, logged, or sent over the network
|
||||
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
|
||||
- Watch-only wallets (`restore-xpub`) hold no private keys and cannot sign transactions
|
||||
|
||||
---
|
||||
|
||||
## Encryption at rest
|
||||
|
||||
- Algorithm: AES-256-GCM
|
||||
- Key derivation: PBKDF2-HMAC-SHA512, 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 — bounded at 10 000 000 on read, since the count is attacker-controlled in a tampered file and an absurd value would hang the wallet at open)
|
||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
||||
- A malformed container (broken JSON, missing fields, bad base64, wrong nonce/tag size) always fails with a typed `InvalidDataException`, never a raw parser exception — the decrypt path is fuzz-tested (`tests/PalladiumWallet.Fuzz`)
|
||||
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
||||
|
||||
---
|
||||
|
||||
## TLS certificate pinning
|
||||
|
||||
Connections to the indexing server use TOFU (Trust On First Use): the server's TLS certificate is pinned on first connection and stored in `server-certs.json`. A certificate change triggers a hard error (`CertificatePinMismatchException`) requiring explicit reset by the user. This prevents silent MITM substitution after first connection.
|
||||
|
||||
---
|
||||
|
||||
## Backup
|
||||
|
||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device.
|
||||
|
||||
---
|
||||
|
||||
## AI-assisted testing and vulnerability discovery
|
||||
|
||||
Part of the test suite and security review for this project is produced with **Claude Fable 5** (Anthropic), used as a targeted tool rather than a blanket "AI-reviewed" stamp. Concretely:
|
||||
|
||||
- **Adversarial network simulation**: the SPV/network layer (`ElectrumClient`, `WalletSynchronizer`, `CertificatePinStore`) is tested against an in-process fake ElectrumX server that can be programmed to lie — serve a transaction with a Merkle proof that doesn't match its claimed block header, drop connections mid-request, return malformed or throttling responses. These are exactly the behaviors a malicious or compromised indexing server would exhibit; the suite asserts the wallet detects and rejects them (see `SpvVerificationException`) instead of trusting unverified server data.
|
||||
- **Property-based fuzzing** (CsCheck): parsers and cryptographic roundtrips (amount parsing, SLIP-132 key encoding, Merkle proof verification, AES-GCM encrypt/decrypt) are exercised against hundreds of generated inputs per run, checking invariants — no crash on arbitrary input, correct rejection of a wrong password, no false-negative Merkle verification — rather than a handful of hand-picked cases. This methodology has already found and fixed two real defects: a corrupted TLS pin file that permanently blocked reconnection, and a wallet-detection routine that threw on unexpected but valid JSON instead of failing safe.
|
||||
- **Targeted security-focused code review** over the areas where a bug has direct financial impact: key derivation, transaction signing, coin selection/spendability rules, and encryption at rest — cross-checked against the invariants stated in this document (e.g. "the server cannot fabricate a confirmed transaction with a valid Merkle proof").
|
||||
|
||||
This is a complement to, not a substitute for, independent human or third-party security review — which is still recommended before relying on this wallet for significant mainnet funds, particularly ahead of a 1.0 release.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations and out-of-scope for v1
|
||||
|
||||
- No Tor/proxy support (network traffic reveals which addresses are being queried)
|
||||
- No multi-server pooling (single point of failure for the indexing server)
|
||||
- No hardware wallet integration
|
||||
- No coin control (automatic UTXO selection only)
|
||||
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
||||
- No Lightning Network support
|
||||
@@ -0,0 +1,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).*
|
||||
@@ -0,0 +1,39 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
|
||||
# ── Java (required by the Android SDK tools) ─────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-17-jdk-headless \
|
||||
wget \
|
||||
unzip \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
# .NET 10 android workload compiles against API level 36 (see error XA5207
|
||||
# if this drifts: the workload's Xamarin.Android.Tooling.targets dictates it).
|
||||
ENV ANDROID_SDK_PLATFORM=36
|
||||
ENV ANDROID_SDK_BUILD_TOOLS=36.0.0
|
||||
|
||||
# ── Android command-line tools ────────────────────────────────────────────────
|
||||
# Pin the build number to a known-good release; update when Google breaks the URL.
|
||||
# Latest as of 2025: commandlinetools-linux-11076708_latest.zip (cmdline-tools 12.0)
|
||||
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" && \
|
||||
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" \
|
||||
-O /tmp/cmdtools.zip && \
|
||||
unzip -q /tmp/cmdtools.zip -d "${ANDROID_HOME}/cmdline-tools" && \
|
||||
mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" && \
|
||||
rm /tmp/cmdtools.zip
|
||||
|
||||
ENV PATH="${JAVA_HOME}/bin:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
|
||||
|
||||
# ── Android SDK packages ──────────────────────────────────────────────────────
|
||||
RUN yes | sdkmanager --licenses > /dev/null && \
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_SDK_PLATFORM}" \
|
||||
"build-tools;${ANDROID_SDK_BUILD_TOOLS}"
|
||||
|
||||
# ── dotnet android workload (~1 GB, baked into this layer) ───────────────────
|
||||
RUN dotnet workload install android
|
||||
|
||||
WORKDIR /tmp/build
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
|
||||
# clang and zlib are required by the .NET SDK for native compilation steps
|
||||
# that occur even during managed cross-publish (win-x64 / linux-x64).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
clang \
|
||||
zlib1g-dev \
|
||||
libkrb5-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /tmp/build
|
||||
@@ -0,0 +1,199 @@
|
||||
# PalladiumWallet — Reproducible Builds via Docker
|
||||
|
||||
This folder builds all three distribution targets — **Windows**, **Linux**,
|
||||
**Android** — inside Docker containers. The entire toolchain (.NET 10 SDK,
|
||||
JDK, Android SDK, android workload) lives in the container images, pinned in
|
||||
the Dockerfiles, so:
|
||||
|
||||
- **anyone can produce the official binaries** with a single command, on any
|
||||
Linux machine, without installing any SDK on the host;
|
||||
- **the toolchain never drifts**: every build uses exactly the same SDK
|
||||
versions, regardless of what is installed (or updated) on the host;
|
||||
- **the build environment is auditable**: the Dockerfiles in this folder are
|
||||
the complete, reviewable definition of how release binaries are made — which
|
||||
matters for a wallet, where users must be able to trust that the shipped
|
||||
binary comes from the published source.
|
||||
|
||||
> **Scope note:** this pins the *build environment*. Bit-for-bit identical
|
||||
> output across machines is a stronger property that .NET does not guarantee
|
||||
> by default (embedded timestamps, signing); if two builds of the same commit
|
||||
> differ, they differ only in those metadata, not in code.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A Linux host (native, WSL2, or a VM) with **Docker Engine** installed and
|
||||
running. Nothing else — no .NET SDK, no JDK, no Android SDK.
|
||||
|
||||
```bash
|
||||
# Check that Docker works:
|
||||
docker info
|
||||
```
|
||||
|
||||
If Docker is missing, install it from <https://docs.docker.com/engine/install/>
|
||||
(or `sudo apt install docker.io` on Debian/Ubuntu). If `docker info` fails
|
||||
with a permission error, add yourself to the `docker` group and start a new
|
||||
shell:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
**Disk space:** ~2 GB for the desktop image, ~7 GB for the Android image
|
||||
(SDK + emulator-less toolchain). **First-run time:** the images are built
|
||||
automatically on first use — a few minutes for desktop, 10–20 minutes for
|
||||
Android (large downloads). Subsequent builds reuse the cached images and take
|
||||
well under a minute (desktop) / a few minutes (Android).
|
||||
|
||||
**Android release signing:** before the first `android` build, generate the
|
||||
persistent signing keystore once — see [`keystore/README.md`](keystore/README.md):
|
||||
|
||||
```bash
|
||||
./docker/keystore/generate-keystore.sh
|
||||
```
|
||||
|
||||
Without it, `build_android` refuses to run — every APK must be signed with
|
||||
the same key so future releases can update a previous install in place.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# From the repository root (or from the docker/ folder — both work):
|
||||
./docker/build.sh
|
||||
```
|
||||
|
||||
Running without arguments shows an interactive menu — pick a single target or
|
||||
`all`. Non-interactive usage:
|
||||
|
||||
```
|
||||
./docker/build.sh [TARGET] [--rebuild]
|
||||
|
||||
Targets:
|
||||
windows Win x64 single-file executable (native libs embedded)
|
||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All three targets
|
||||
|
||||
Options:
|
||||
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
./docker/build.sh all # build everything
|
||||
./docker/build.sh windows # Windows only
|
||||
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output — and how to use each artifact
|
||||
|
||||
All artifacts land in `dist/` at the repository root. The version number is
|
||||
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
||||
|
||||
| Target | Path |
|
||||
|---------|--------------------------------------------------|
|
||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||
|
||||
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
||||
embedded). Copy it to any 64-bit Windows 10/11 machine and double-click.
|
||||
The first launch takes a few extra seconds (it unpacks native libraries to a
|
||||
per-user cache); later launches are normal. SmartScreen may warn because the
|
||||
binary is not code-signed — choose "Run anyway".
|
||||
|
||||
**Linux** — a single self-contained binary, already executable. Copy and run:
|
||||
|
||||
```bash
|
||||
./PalladiumWallet-{ver}-linux-x64
|
||||
```
|
||||
|
||||
Works on any desktop distro with glibc, X11/Wayland and fontconfig (i.e.
|
||||
effectively all of them); no .NET or other packages to install. If you
|
||||
transfer it through a channel that strips permissions (e.g. a web download),
|
||||
restore the execute bit with `chmod +x`.
|
||||
|
||||
**Android** — a release-signed APK for sideloading: transfer it to the phone
|
||||
and open it (enable "install from unknown sources" if prompted), or install
|
||||
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
||||
(API 23), arm64 phones and x86_64 emulators.
|
||||
|
||||
> **Signature:** every APK is signed with the persistent keystore in
|
||||
> `docker/keystore/` (see [Prerequisites](#prerequisites)), so installing a
|
||||
> newer build over an existing one updates it in place — no uninstall, no
|
||||
> data loss. This only holds as long as every release keeps using that same
|
||||
> keystore file; see `docker/keystore/README.md` for the backup story.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
### Docker images
|
||||
|
||||
| Image | Dockerfile | Used for | Size |
|
||||
|---------------------|----------------------|-----------------|---------|
|
||||
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux | ~1.5 GB |
|
||||
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
|
||||
|
||||
Images are built automatically the first time a target needs them and reused
|
||||
afterwards. Use `--rebuild` only after modifying a Dockerfile.
|
||||
|
||||
### Source isolation
|
||||
|
||||
The repository is mounted **read-only** inside the container; the build works
|
||||
on a copy at `/tmp/build`. Your working tree is never touched — no stray
|
||||
`bin/`/`obj/` directories, and a dirty working tree doesn't leak into the
|
||||
build beyond the files it contains. Artifacts are written back through a
|
||||
bind mount to `dist/` and chown'd to your user.
|
||||
|
||||
### NuGet cache
|
||||
|
||||
A Docker named volume `plm-nuget-cache` holds downloaded NuGet packages
|
||||
across builds. To reclaim the space or force a clean re-download:
|
||||
|
||||
```bash
|
||||
docker volume rm plm-nuget-cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Docker daemon is not running`** — start it (`sudo systemctl start
|
||||
docker`; on WSL2, start Docker Desktop or the docker service).
|
||||
- **Android image build fails downloading `commandlinetools`** — Google
|
||||
rotates the build number in the URL. Update the URL in
|
||||
`Dockerfile.android` to the current one from
|
||||
<https://developer.android.com/studio#command-tools> and rerun with
|
||||
`--rebuild`.
|
||||
- **`error XA5207: Could not find android.jar for API level N`** — the .NET
|
||||
android workload moved to a newer API level. Bump
|
||||
`ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in
|
||||
`Dockerfile.android` to the level the error names, then `--rebuild`.
|
||||
- **APK won't install over an existing app
|
||||
(`INSTALL_FAILED_UPDATE_INCOMPATIBLE`)** — the new build wasn't signed with
|
||||
the same keystore as the installed one. Make sure `docker/keystore/release.keystore`
|
||||
hasn't changed since the installed build; if it's genuinely a different key,
|
||||
the user must uninstall the old app first (this deletes app data — back up
|
||||
the wallet seed before doing this).
|
||||
- **`build_android` refuses to run, asks to generate a keystore** — run
|
||||
`./docker/keystore/generate-keystore.sh` once (see `docker/keystore/README.md`).
|
||||
- **Everything is broken / start from scratch** —
|
||||
`docker system prune -a && docker volume rm plm-nuget-cache`, then rerun
|
||||
the script (images and packages are re-downloaded).
|
||||
|
||||
---
|
||||
|
||||
## Linux AppImage (future)
|
||||
|
||||
The Linux target currently produces a single-file self-contained binary.
|
||||
Once a `pupnet.conf` is added to the repository, the `build_linux` function
|
||||
in `build.sh` can be extended to call PupNet Deploy inside the same
|
||||
`plm-build-desktop` image to also produce an AppImage with desktop
|
||||
integration (icon, menu entry).
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducible build script for PalladiumWallet using Docker.
|
||||
# Run from anywhere; paths are resolved relative to this script.
|
||||
set -euo pipefail
|
||||
|
||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
DIST_DIR="${PROJECT_ROOT}/dist"
|
||||
|
||||
# ── Docker image names ────────────────────────────────────────────────────────
|
||||
IMAGE_DESKTOP="plm-build-desktop"
|
||||
IMAGE_ANDROID="plm-build-android"
|
||||
|
||||
# Named volume for NuGet package cache (shared across all builds, survives reboots).
|
||||
NUGET_VOLUME="plm-nuget-cache"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
info() { printf '\033[1;34m>>>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
|
||||
die() { err "$*"; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
||||
|
||||
$(bold "Targets:")
|
||||
windows Win x64 single-file executable → dist/windows/
|
||||
linux Linux x64 single-file binary → dist/linux/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All three targets
|
||||
|
||||
$(bold "Options:")
|
||||
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
||||
-h, --help Show this help
|
||||
|
||||
$(bold "Examples:")
|
||||
$(basename "$0") # interactive menu
|
||||
$(basename "$0") all # build everything
|
||||
$(basename "$0") windows # Windows only
|
||||
$(basename "$0") android --rebuild
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
REBUILD=false
|
||||
TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
windows|linux|android|all) TARGET="$arg" ;;
|
||||
--rebuild) REBUILD=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Interactive menu if no target given ───────────────────────────────────────
|
||||
if [[ -z "$TARGET" ]]; then
|
||||
bold "PalladiumWallet — reproducible build"
|
||||
echo ""
|
||||
PS3="Select target: "
|
||||
options=("windows" "linux" "android" "all" "quit")
|
||||
select opt in "${options[@]}"; do
|
||||
case "$opt" in
|
||||
windows|linux|android|all) TARGET="$opt"; break ;;
|
||||
quit) echo "Aborted."; exit 0 ;;
|
||||
*) echo "Invalid choice, try again." ;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Preflight checks ──────────────────────────────────────────────────────────
|
||||
command -v docker &>/dev/null || die "Docker is not installed or not in PATH."
|
||||
docker info &>/dev/null || die "Docker daemon is not running."
|
||||
|
||||
# ── Read project version from csproj ─────────────────────────────────────────
|
||||
CSPROJ="${PROJECT_ROOT}/src/App/PalladiumWallet.App.csproj"
|
||||
[[ -f "$CSPROJ" ]] || die "Cannot find $CSPROJ — run this script from the repo."
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' "$CSPROJ") || die "Cannot extract <Version> from $CSPROJ."
|
||||
info "Version: ${VERSION}"
|
||||
|
||||
# ── Ensure NuGet volume exists ────────────────────────────────────────────────
|
||||
docker volume inspect "$NUGET_VOLUME" &>/dev/null || \
|
||||
docker volume create "$NUGET_VOLUME" > /dev/null
|
||||
|
||||
# ── Image builders ────────────────────────────────────────────────────────────
|
||||
ensure_desktop_image() {
|
||||
if [[ "$REBUILD" == true ]] || ! docker image inspect "$IMAGE_DESKTOP" &>/dev/null; then
|
||||
info "Building Docker image: ${IMAGE_DESKTOP}"
|
||||
docker build \
|
||||
--tag "$IMAGE_DESKTOP" \
|
||||
--file "${SCRIPT_DIR}/Dockerfile.desktop" \
|
||||
"${SCRIPT_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_android_image() {
|
||||
if [[ "$REBUILD" == true ]] || ! docker image inspect "$IMAGE_ANDROID" &>/dev/null; then
|
||||
info "Building Docker image: ${IMAGE_ANDROID}"
|
||||
docker build \
|
||||
--tag "$IMAGE_ANDROID" \
|
||||
--file "${SCRIPT_DIR}/Dockerfile.android" \
|
||||
"${SCRIPT_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Common docker run wrapper ─────────────────────────────────────────────────
|
||||
# $1 = image name, $2 = inline bash commands to execute inside the container.
|
||||
# Source is copied to /tmp/build inside the container so bin/obj never pollute
|
||||
# the repo. Output is written to /output (→ host DIST_DIR sub-folder).
|
||||
# Optional extra `docker run` args (e.g. keystore mount, signing passwords via
|
||||
# -e) can be set by the caller in the EXTRA_DOCKER_ARGS array beforehand.
|
||||
EXTRA_DOCKER_ARGS=()
|
||||
|
||||
run_build() {
|
||||
local image="$1"
|
||||
local commands="$2"
|
||||
local out_dir="$3"
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
docker run --rm \
|
||||
--volume "${PROJECT_ROOT}:/src:ro" \
|
||||
--volume "${out_dir}:/output" \
|
||||
--volume "${NUGET_VOLUME}:/root/.nuget/packages" \
|
||||
"${EXTRA_DOCKER_ARGS[@]}" \
|
||||
"$image" \
|
||||
bash -euo pipefail -c "
|
||||
cp -r /src/. /tmp/build
|
||||
cd /tmp/build
|
||||
${commands}
|
||||
chown -R $(id -u):$(id -g) /output
|
||||
"
|
||||
}
|
||||
|
||||
# ── Per-target build functions ────────────────────────────────────────────────
|
||||
build_windows() {
|
||||
ensure_desktop_image
|
||||
info "Building Windows x64 …"
|
||||
run_build "$IMAGE_DESKTOP" \
|
||||
"dotnet publish src/App.Desktop \
|
||||
-r win-x64 \
|
||||
-c Release \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
--self-contained \
|
||||
-o /tmp/win-out
|
||||
cp /tmp/win-out/PalladiumWallet.exe \
|
||||
\"/output/PalladiumWallet-${VERSION}-win-x64.exe\"" \
|
||||
"${DIST_DIR}/windows"
|
||||
ok "Windows → dist/windows/PalladiumWallet-${VERSION}-win-x64.exe"
|
||||
}
|
||||
|
||||
build_linux() {
|
||||
ensure_desktop_image
|
||||
info "Building Linux x64 …"
|
||||
run_build "$IMAGE_DESKTOP" \
|
||||
"dotnet publish src/App.Desktop \
|
||||
-r linux-x64 \
|
||||
-c Release \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
--self-contained \
|
||||
-o /tmp/linux-out
|
||||
install -m 755 /tmp/linux-out/PalladiumWallet \
|
||||
\"/output/PalladiumWallet-${VERSION}-linux-x64\"" \
|
||||
"${DIST_DIR}/linux"
|
||||
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
|
||||
}
|
||||
|
||||
build_android() {
|
||||
ensure_android_image
|
||||
|
||||
local keystore="${SCRIPT_DIR}/keystore/release.keystore"
|
||||
[[ -f "$keystore" ]] || die "No release keystore found at docker/keystore/release.keystore. Run ./docker/keystore/generate-keystore.sh once first (see docker/keystore/README.md)."
|
||||
|
||||
info "Building Android APK …"
|
||||
read -r -s -p "Keystore password: " ANDROID_KS_PASS
|
||||
echo
|
||||
read -r -s -p "Key password (press Enter to reuse the keystore password): " ANDROID_KEY_PASS
|
||||
echo
|
||||
ANDROID_KEY_PASS="${ANDROID_KEY_PASS:-$ANDROID_KS_PASS}"
|
||||
|
||||
# versionCode derived from <Version> (MAJOR.MINOR.PATCH, pre-release suffix
|
||||
# stripped) so it always increases with releases — required for some
|
||||
# installers to accept an in-place update even when the signature matches.
|
||||
local semver="${VERSION%%-*}"
|
||||
IFS='.' read -r VMAJOR VMINOR VPATCH <<< "$semver"
|
||||
local VERSION_CODE=$(( ${VMAJOR:-0} * 10000 + ${VMINOR:-0} * 100 + ${VPATCH:-0} ))
|
||||
info "versionCode: ${VERSION_CODE}"
|
||||
|
||||
EXTRA_DOCKER_ARGS=(
|
||||
--volume "${keystore}:/keystore/release.keystore:ro"
|
||||
--env "ANDROID_KS_PASS=${ANDROID_KS_PASS}"
|
||||
--env "ANDROID_KEY_PASS=${ANDROID_KEY_PASS}"
|
||||
)
|
||||
run_build "$IMAGE_ANDROID" \
|
||||
"JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||
dotnet build src/App.Android \
|
||||
-c Release \
|
||||
-t:SignAndroidPackage \
|
||||
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
|
||||
-p:ApplicationVersion=${VERSION_CODE} \
|
||||
-p:AndroidKeyStore=true \
|
||||
-p:AndroidSigningKeyStore=/keystore/release.keystore \
|
||||
-p:AndroidSigningKeyAlias=palladiumwallet \
|
||||
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
|
||||
-p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS}
|
||||
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
|
||||
\"/output/PalladiumWallet-${VERSION}.apk\"" \
|
||||
"${DIST_DIR}/android"
|
||||
EXTRA_DOCKER_ARGS=()
|
||||
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
|
||||
}
|
||||
|
||||
# ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||
START=$(date +%s)
|
||||
|
||||
case "$TARGET" in
|
||||
windows) build_windows ;;
|
||||
linux) build_linux ;;
|
||||
android) build_android ;;
|
||||
all)
|
||||
build_windows
|
||||
build_linux
|
||||
build_android
|
||||
;;
|
||||
esac
|
||||
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
bold ""
|
||||
bold "Done in ${ELAPSED}s. Artifacts in: ${DIST_DIR}/"
|
||||
@@ -0,0 +1,37 @@
|
||||
# Android release-signing keystore
|
||||
|
||||
This folder holds the persistent keystore used to sign release APKs built by
|
||||
`docker/build.sh android`. Every release must be signed with the **same**
|
||||
key, or Android refuses to install a new build over a previously installed
|
||||
one (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and forces the user to uninstall
|
||||
first — which deletes the app's data.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
./docker/keystore/generate-keystore.sh
|
||||
```
|
||||
|
||||
Run this **once**, on whichever machine will keep producing release builds.
|
||||
It creates `release.keystore` in this folder and asks for a keystore
|
||||
password (and, optionally, a separate key password). Nothing is written to
|
||||
git — `release.keystore` is ignored by `.gitignore`, and the script never
|
||||
saves the passwords anywhere.
|
||||
|
||||
`docker/build.sh android` will refuse to run without this file, and will
|
||||
prompt you for the same passwords at every build.
|
||||
|
||||
## Back it up
|
||||
|
||||
The keystore file and its passwords cannot be regenerated: if lost, no
|
||||
future release can ever update existing installs in place again — every
|
||||
user would need to uninstall and reinstall from scratch. Copy
|
||||
`release.keystore` and the passwords to a password manager or encrypted
|
||||
backup outside this repository as soon as you generate them.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do not commit `release.keystore` (or any `*.jks`/`*.keystore` file) to git.
|
||||
- Do not re-run `generate-keystore.sh` once a keystore already exists — it
|
||||
refuses on purpose; delete the file yourself only if you fully accept the
|
||||
consequences above.
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates the persistent Android release-signing keystore used by
|
||||
# docker/build.sh. Every APK built with this keystore carries the same
|
||||
# signature, which is what lets Android treat a new release as an update to
|
||||
# a previously installed one instead of a conflicting, different app.
|
||||
#
|
||||
# Run this ONCE. Re-running it after the keystore already exists is refused
|
||||
# below on purpose: regenerating it changes the signature, and every device
|
||||
# with a prior release installed would then need a full uninstall (and lose
|
||||
# any app data that isn't in the wallet file itself) to receive the next
|
||||
# update.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
KEYSTORE_PATH="${SCRIPT_DIR}/release.keystore"
|
||||
ALIAS="palladiumwallet"
|
||||
|
||||
if [[ -f "$KEYSTORE_PATH" ]]; then
|
||||
echo "A keystore already exists at $KEYSTORE_PATH — refusing to overwrite it." >&2
|
||||
echo "Delete it manually first only if you understand the consequences above." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v keytool &>/dev/null || {
|
||||
echo "keytool not found on PATH — install a JDK (e.g. openjdk-17-jdk) and retry." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Creating the Palladium Wallet Android release-signing keystore."
|
||||
echo "This file and the passwords you enter now must be kept safe OUTSIDE this"
|
||||
echo "repository (password manager, encrypted backup, ...). They are never"
|
||||
echo "committed to git. Losing them means no future release can ever update"
|
||||
echo "existing installs in place — only a fresh keystore + full user reinstall."
|
||||
echo
|
||||
|
||||
read -r -s -p "Keystore password (min 6 characters): " KS_PASS
|
||||
echo
|
||||
read -r -s -p "Confirm keystore password: " KS_PASS_CONFIRM
|
||||
echo
|
||||
if [[ "$KS_PASS" != "$KS_PASS_CONFIRM" ]]; then
|
||||
echo "Passwords do not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${#KS_PASS}" -lt 6 ]]; then
|
||||
echo "Password too short (keytool requires at least 6 characters)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -s -p "Key password (press Enter to reuse the keystore password): " KEY_PASS
|
||||
echo
|
||||
KEY_PASS="${KEY_PASS:-$KS_PASS}"
|
||||
|
||||
export KS_PASS KEY_PASS
|
||||
keytool -genkeypair -v \
|
||||
-keystore "$KEYSTORE_PATH" \
|
||||
-alias "$ALIAS" \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass:env KS_PASS -keypass:env KEY_PASS \
|
||||
-dname "CN=PalladiumWallet, OU=PalladiumWallet, O=PalladiumWallet, C=IT"
|
||||
unset KS_PASS KEY_PASS
|
||||
|
||||
echo
|
||||
echo "Keystore created at $KEYSTORE_PATH (git-ignored, alias: $ALIAS)."
|
||||
echo "Back it up now, together with the passwords — it cannot be regenerated."
|
||||
echo "./docker/build.sh android will prompt for these same passwords at build time."
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Avalonia.Android;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
[Activity(
|
||||
Label = "Palladium Wallet",
|
||||
Theme = "@style/MyTheme.NoActionBar",
|
||||
MainLauncher = true,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
public class MainActivity : AvaloniaMainActivity
|
||||
{
|
||||
internal const int ScanRequestCode = 9001;
|
||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||
internal static MainActivity? Current;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
Current = this;
|
||||
}
|
||||
|
||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||
{
|
||||
base.OnActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == ScanRequestCode)
|
||||
{
|
||||
var text = resultCode == Result.Ok ? data?.GetStringExtra(ScannerActivity.ResultKey) : null;
|
||||
ScanTcs?.TrySetResult(text);
|
||||
ScanTcs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Runtime;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
using PalladiumWallet.App.Services;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
// In Avalonia 12 the Android AppBuilder is configured in the Application subclass
|
||||
// (AvaloniaAndroidApplication<TApp>), no longer in the Activity. allowBackup=false:
|
||||
// the encrypted wallet file/seed must not end up in automatic cloud backups.
|
||||
[Application(Label = "Palladium Wallet", AllowBackup = false,
|
||||
Icon = "@mipmap/ic_launcher", RoundIcon = "@mipmap/ic_launcher_round")]
|
||||
public class MainApplication : AvaloniaAndroidApplication<global::PalladiumWallet.App.App>
|
||||
{
|
||||
public MainApplication(IntPtr javaReference, JniHandleOwnership transfer)
|
||||
: base(javaReference, transfer)
|
||||
{
|
||||
}
|
||||
|
||||
public override void OnCreate()
|
||||
{
|
||||
AppPaths.OverrideDataRoot = FilesDir?.AbsolutePath;
|
||||
|
||||
// Registers the QR scanner: opens ScannerActivity and waits for its result.
|
||||
PlatformServices.ScanQrAsync = async () =>
|
||||
{
|
||||
var activity = MainActivity.Current;
|
||||
if (activity == null) return null;
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
MainActivity.ScanTcs = tcs;
|
||||
activity.StartActivityForResult(
|
||||
new Intent(activity, typeof(ScannerActivity)),
|
||||
MainActivity.ScanRequestCode);
|
||||
return await tcs.Task;
|
||||
};
|
||||
|
||||
base.OnCreate();
|
||||
}
|
||||
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Head Android: entry-point e pacchetti specifici. La UI vera vive nella
|
||||
libreria condivisa PalladiumWallet.App (stessa di desktop). -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-android</TargetFramework>
|
||||
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<ApplicationVersion>3</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||
a mano — sideload sul telefono o `adb install` — crasha all'avvio con
|
||||
"monodroid: No assemblies found ... Exiting". Così l'apk è autosufficiente. -->
|
||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||
<!-- Default: ABI multiple (l'apk gira sia su emulatore x86_64 sia su telefoni arm64).
|
||||
Per un apk più piccolo solo-telefono: aggiungi -p:AbiArm64Only=true alla build. -->
|
||||
<RuntimeIdentifiers Condition="'$(AbiArm64Only)' == 'true'">android-arm64</RuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Android" Version="12.0.4" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Necessario per parlare col server di indicizzazione (TCP/TLS).
|
||||
L'elemento <application> (label, allowBackup) è generato dall'attributo
|
||||
[Application] su MainApplication. usesCleartextTraffic resta al default
|
||||
(bloccato su Android 9+): il wallet preferisce comunque TLS. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- DEVE derivare da Theme.AppCompat: AvaloniaActivity estende AppCompatActivity,
|
||||
che con un tema non-AppCompat lancia IllegalStateException all'avvio.
|
||||
Avalonia disegna comunque l'intera UI (NoActionBar). -->
|
||||
<style name="MyTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Graphics;
|
||||
using Android.Hardware.Camera2;
|
||||
using Android.Media;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using Android.Widget;
|
||||
using ZXing;
|
||||
using ZXing.Common;
|
||||
using AndroidResult = Android.App.Result;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
/// <summary>
|
||||
/// Full-screen activity for QR scanning via Camera2 + ZXing.Net.
|
||||
/// Returns to MainActivity via SetResult with the extra "qr" = code text.
|
||||
/// </summary>
|
||||
[Activity(Theme = "@style/MyTheme.NoActionBar",
|
||||
ScreenOrientation = Android.Content.PM.ScreenOrientation.Portrait)]
|
||||
internal sealed class ScannerActivity : Activity, TextureView.ISurfaceTextureListener
|
||||
{
|
||||
internal const string ResultKey = "qr";
|
||||
private const int PermReq = 1;
|
||||
|
||||
private HandlerThread? _bgThread;
|
||||
private Handler? _bgHandler;
|
||||
private CameraDevice? _camera;
|
||||
private CameraCaptureSession? _session;
|
||||
private ImageReader? _imageReader;
|
||||
private SurfaceTexture? _surfaceTex;
|
||||
private bool _permOk;
|
||||
private volatile bool _done;
|
||||
private long _lastDecode;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
Window!.SetFlags(WindowManagerFlags.Fullscreen, WindowManagerFlags.Fullscreen);
|
||||
|
||||
var root = new FrameLayout(this);
|
||||
|
||||
var preview = new TextureView(this) { SurfaceTextureListener = this };
|
||||
root.AddView(preview, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MatchParent, FrameLayout.LayoutParams.MatchParent));
|
||||
|
||||
var hint = new TextView(this) { Text = "Inquadra un codice QR" };
|
||||
hint.SetTextColor(Color.White);
|
||||
hint.SetBackgroundColor(Color.ParseColor("#AA000000"));
|
||||
hint.SetPadding(24, 12, 24, 12);
|
||||
root.AddView(hint, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||
GravityFlags.Top | GravityFlags.CenterHorizontal) { TopMargin = 60 });
|
||||
|
||||
var cancel = new Button(this) { Text = "Annulla" };
|
||||
cancel.Click += (_, _) => Finish();
|
||||
root.AddView(cancel, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WrapContent, FrameLayout.LayoutParams.WrapContent,
|
||||
GravityFlags.Bottom | GravityFlags.CenterHorizontal) { BottomMargin = 80 });
|
||||
|
||||
SetContentView(root);
|
||||
|
||||
_bgThread = new HandlerThread("CamBg");
|
||||
_bgThread.Start();
|
||||
_bgHandler = new Handler(_bgThread.Looper!);
|
||||
|
||||
if (CheckSelfPermission(Android.Manifest.Permission.Camera) == Android.Content.PM.Permission.Granted)
|
||||
_permOk = true;
|
||||
else
|
||||
RequestPermissions([Android.Manifest.Permission.Camera], PermReq);
|
||||
}
|
||||
|
||||
public override void OnRequestPermissionsResult(int req, string[] perms, Android.Content.PM.Permission[] grants)
|
||||
{
|
||||
_permOk = grants.Length > 0 && grants[0] == Android.Content.PM.Permission.Granted;
|
||||
if (_permOk) TryOpen(); else Finish();
|
||||
}
|
||||
|
||||
public void OnSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { _surfaceTex = st; TryOpen(); }
|
||||
public bool OnSurfaceTextureDestroyed(SurfaceTexture st) { CloseCamera(); return true; }
|
||||
public void OnSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { }
|
||||
public void OnSurfaceTextureUpdated(SurfaceTexture st) { }
|
||||
|
||||
private void TryOpen() { if (_permOk && _surfaceTex != null) OpenCamera(_surfaceTex); }
|
||||
|
||||
private void OpenCamera(SurfaceTexture st)
|
||||
{
|
||||
var mgr = (CameraManager)GetSystemService(CameraService)!;
|
||||
|
||||
var ids = mgr.GetCameraIdList()!;
|
||||
string cameraId = ids[0];
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var chars = mgr.GetCameraCharacteristics(id)!;
|
||||
if (chars.Get(CameraCharacteristics.LensFacing) is Java.Lang.Integer f
|
||||
&& f.IntValue() == (int)LensFacing.Back)
|
||||
{ cameraId = id; break; }
|
||||
}
|
||||
|
||||
var map = mgr.GetCameraCharacteristics(cameraId)!
|
||||
.Get(CameraCharacteristics.ScalerStreamConfigurationMap)
|
||||
as Android.Hardware.Camera2.Params.StreamConfigurationMap;
|
||||
var allSizes = map!.GetOutputSizes((int)ImageFormatType.Jpeg)!;
|
||||
var size = allSizes.OrderBy(s => Math.Abs(s.Width - 640)).First();
|
||||
|
||||
st.SetDefaultBufferSize(size.Width, size.Height);
|
||||
_imageReader = ImageReader.NewInstance(size.Width, size.Height, ImageFormatType.Jpeg, 2);
|
||||
_imageReader.SetOnImageAvailableListener(new ImageListener(this), _bgHandler);
|
||||
|
||||
mgr.OpenCamera(cameraId, new OpenCb(this, st), _bgHandler);
|
||||
}
|
||||
|
||||
private void CloseCamera()
|
||||
{
|
||||
_session?.Close(); _session = null;
|
||||
_camera?.Close(); _camera = null;
|
||||
_imageReader?.Close(); _imageReader = null;
|
||||
_bgThread?.QuitSafely();
|
||||
}
|
||||
|
||||
internal void OnCameraOpened(CameraDevice dev, SurfaceTexture st)
|
||||
{
|
||||
_camera = dev;
|
||||
var prevSurf = new Surface(st);
|
||||
var capSurf = _imageReader!.Surface!;
|
||||
#pragma warning disable CA1422 // CreateCaptureSession(IList,...) obsoleted on API 30; replacement needs API 28, our min is 23
|
||||
dev.CreateCaptureSession([prevSurf, capSurf], new SessionCb(this, prevSurf, capSurf), _bgHandler);
|
||||
#pragma warning restore CA1422
|
||||
}
|
||||
|
||||
internal void OnSessionReady(CameraCaptureSession session, Surface prev, Surface cap)
|
||||
{
|
||||
_session = session;
|
||||
var req = _camera!.CreateCaptureRequest(CameraTemplate.Preview);
|
||||
req.AddTarget(prev);
|
||||
req.AddTarget(cap);
|
||||
session.SetRepeatingRequest(req.Build()!, null, _bgHandler);
|
||||
}
|
||||
|
||||
internal void TryDecode(ImageReader reader)
|
||||
{
|
||||
if (_done) return;
|
||||
var image = reader.AcquireLatestImage();
|
||||
if (image == null) return;
|
||||
try
|
||||
{
|
||||
var now = SystemClock.ElapsedRealtime();
|
||||
if (now - _lastDecode < 400) return; // max ~2.5 fps
|
||||
_lastDecode = now;
|
||||
|
||||
var buf = image.GetPlanes()![0].Buffer!;
|
||||
var bytes = new byte[buf.Remaining()];
|
||||
buf.Get(bytes);
|
||||
|
||||
var bmp = BitmapFactory.DecodeByteArray(bytes, 0, bytes.Length);
|
||||
if (bmp == null) return;
|
||||
int w = bmp.Width, h = bmp.Height;
|
||||
var pixels = new int[w * h];
|
||||
bmp.GetPixels(pixels, 0, w, 0, 0, w, h);
|
||||
bmp.Recycle();
|
||||
|
||||
// ARGB int[] → RGB byte[] for ZXing.RGBLuminanceSource
|
||||
var rgb = new byte[pixels.Length * 3];
|
||||
for (int i = 0; i < pixels.Length; i++)
|
||||
{
|
||||
rgb[i * 3] = (byte)(pixels[i] >> 16);
|
||||
rgb[i * 3 + 1] = (byte)(pixels[i] >> 8);
|
||||
rgb[i * 3 + 2] = (byte) pixels[i];
|
||||
}
|
||||
|
||||
var source = new RGBLuminanceSource(rgb, w, h, RGBLuminanceSource.BitmapFormat.RGB24);
|
||||
var zreader = new BarcodeReaderGeneric { AutoRotate = true };
|
||||
zreader.Options.PossibleFormats = [BarcodeFormat.QR_CODE];
|
||||
zreader.Options.TryHarder = true;
|
||||
var result = zreader.Decode(source);
|
||||
if (result == null) return;
|
||||
|
||||
_done = true;
|
||||
RunOnUiThread(() =>
|
||||
{
|
||||
var intent = new Intent().PutExtra(ResultKey, result.Text);
|
||||
SetResult(AndroidResult.Ok, intent);
|
||||
Finish();
|
||||
});
|
||||
}
|
||||
finally { image.Close(); }
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
CloseCamera();
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
// ── Callback helpers ─────────────────────────────────────────────────
|
||||
|
||||
private sealed class OpenCb(ScannerActivity a, SurfaceTexture st) : CameraDevice.StateCallback
|
||||
{
|
||||
public override void OnOpened(CameraDevice cam) => a.OnCameraOpened(cam, st);
|
||||
public override void OnDisconnected(CameraDevice cam) { cam.Close(); a.Finish(); }
|
||||
public override void OnError(CameraDevice cam, CameraError err) { cam.Close(); a.Finish(); }
|
||||
}
|
||||
|
||||
private sealed class SessionCb(ScannerActivity a, Surface prev, Surface cap) : CameraCaptureSession.StateCallback
|
||||
{
|
||||
public override void OnConfigured(CameraCaptureSession s) => a.OnSessionReady(s, prev, cap);
|
||||
public override void OnConfigureFailed(CameraCaptureSession s) => a.Finish();
|
||||
}
|
||||
|
||||
private sealed class ImageListener(ScannerActivity a) : Java.Lang.Object, ImageReader.IOnImageAvailableListener
|
||||
{
|
||||
// Interface declares ImageReader? but reader is never null in practice
|
||||
public void OnImageAvailable(ImageReader? reader) => a.TryDecode(reader!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Head Desktop (Windows/Linux): entry-point e pacchetti specifici del
|
||||
desktop. La UI vera vive nella libreria condivisa PalladiumWallet.App. -->
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\App\Assets\logo.ico</ApplicationIcon>
|
||||
<AssemblyName>PalladiumWallet</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\App\PalladiumWallet.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -8,8 +8,20 @@
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
|
||||
<!-- Semantic design tokens (colors, radii, brand accent): defined for
|
||||
both light/dark variants. See Styles/Theme.axaml. -->
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="/Styles/Theme.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<!-- Control refinements and reusable classes (on top of Fluent) -->
|
||||
<StyleInclude Source="/Styles/Controls.axaml"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -1,31 +1,34 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.App.Views;
|
||||
|
||||
namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.App.Views;
|
||||
|
||||
namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
||||
// view. Same shared UI (MainView) and same ViewModel.
|
||||
switch (ApplicationLifetime)
|
||||
{
|
||||
case IClassicDesktopStyleApplicationLifetime desktop:
|
||||
desktop.MainWindow = new MainWindow { DataContext = vm };
|
||||
break;
|
||||
case ISingleViewApplicationLifetime singleView:
|
||||
singleView.MainView = new MainView { DataContext = vm };
|
||||
break;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
namespace PalladiumWallet.App.Localization;
|
||||
|
||||
/// <summary>
|
||||
/// Localizzazione UI: dizionario chiave → traduzioni per lingua, con
|
||||
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
|
||||
/// il ViewModel sostituisce l'istanza così Avalonia rivaluta tutte le binding.
|
||||
/// UI localisation: key → per-language translations dictionary, with an
|
||||
/// indexer bindable from XAML ({Binding Loc[key]}). On language change the
|
||||
/// ViewModel replaces the instance so Avalonia re-evaluates all bindings.
|
||||
/// </summary>
|
||||
public sealed class Loc
|
||||
{
|
||||
@@ -20,14 +20,14 @@ public sealed class Loc
|
||||
private Loc(string language) { Language = language; }
|
||||
|
||||
/// <summary>
|
||||
/// Crea una nuova istanza con la lingua specificata e aggiorna il singleton
|
||||
/// usato da <see cref="Tr"/>. Il ViewModel assegna questa istanza alla
|
||||
/// propria property Loc così Avalonia vede un riferimento diverso e
|
||||
/// rivaluta tutte le binding {Binding Loc[chiave]}.
|
||||
/// Creates a new instance for the specified language and updates the singleton
|
||||
/// used by <see cref="Tr"/>. The ViewModel assigns this instance to its Loc
|
||||
/// property so Avalonia sees a different reference and re-evaluates all
|
||||
/// {Binding Loc[key]} bindings.
|
||||
/// </summary>
|
||||
internal static Loc SwitchTo(string language)
|
||||
{
|
||||
if (System.Array.IndexOf(Languages, language) < 0) language = "it";
|
||||
if (System.Array.IndexOf(Languages, language) < 0) language = "en";
|
||||
var loc = new Loc(language);
|
||||
Instance = loc;
|
||||
return loc;
|
||||
@@ -45,19 +45,60 @@ public sealed class Loc
|
||||
// Menu it en es fr pt de
|
||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
|
||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
|
||||
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…", "Abrir wallet desde archivo…", "Ouvrir le wallet depuis un fichier…", "Abrir carteira de arquivo…", "Wallet aus Datei öffnen…"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden"],
|
||||
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
|
||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
|
||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
|
||||
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
|
||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
||||
["help.info"] = [
|
||||
"Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
|
||||
"Lightweight, non-custodial SPV wallet for the Palladium (PLM) network. Local security: seed and keys always encrypted, never exposed on the wire.",
|
||||
"Monedero SPV ligero y sin custodia para la red Palladium (PLM). Seguridad local: semilla y claves siempre cifradas, nunca expuestas en la red.",
|
||||
"Portefeuille SPV léger et non-custodial pour le réseau Palladium (PLM). Sécurité locale : graine et clés toujours chiffrées, jamais exposées sur le réseau.",
|
||||
"Carteira SPV leve e não custodial para a rede Palladium (PLM). Segurança local: semente e chaves sempre cifradas, nunca expostas na rede.",
|
||||
"Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert."],
|
||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
||||
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch"],
|
||||
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
||||
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
||||
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
||||
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen"],
|
||||
["donate.desc"] = [
|
||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
||||
"If you find this wallet useful, consider a small donation to the developer.",
|
||||
"Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.",
|
||||
"Si ce portefeuille vous est utile, envisagez un petit don au développeur.",
|
||||
"Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.",
|
||||
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler."],
|
||||
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse"],
|
||||
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag"],
|
||||
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten"],
|
||||
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden"],
|
||||
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
|
||||
|
||||
// Wizard
|
||||
["wiz.net"] = ["Rete:", "Network:", "Red:", "Réseau :", "Rede:", "Netzwerk:"],
|
||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
||||
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
|
||||
["wiz.data.info"] = [
|
||||
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
|
||||
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
|
||||
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
|
||||
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
|
||||
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
|
||||
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
|
||||
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
|
||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
|
||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
||||
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren"],
|
||||
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"],
|
||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
|
||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"],
|
||||
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
|
||||
@@ -76,24 +117,74 @@ public sealed class Loc
|
||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
|
||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
|
||||
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
|
||||
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)"],
|
||||
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen."],
|
||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
|
||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
|
||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
|
||||
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"],
|
||||
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"],
|
||||
["wiz.password.encrypt.hint"] = [
|
||||
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
|
||||
"Warning: without encryption the seed stays in plaintext on disk.",
|
||||
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
|
||||
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
|
||||
"Atenção: sem criptografia a semente fica em texto simples no disco.",
|
||||
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
|
||||
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
|
||||
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
|
||||
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen"],
|
||||
["wiz.scripttype.hint"] = [
|
||||
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
|
||||
"Determines the address format. If unsure, use Native SegWit.",
|
||||
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
|
||||
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
|
||||
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
|
||||
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit."],
|
||||
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen"],
|
||||
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen"],
|
||||
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen"],
|
||||
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen"],
|
||||
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"],
|
||||
["wiz.importxkey.hint"] = [
|
||||
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
|
||||
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
|
||||
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
|
||||
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
|
||||
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
|
||||
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt."],
|
||||
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
||||
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren"],
|
||||
["wiz.importwif.hint"] = [
|
||||
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
|
||||
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
|
||||
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
|
||||
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
|
||||
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
|
||||
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."],
|
||||
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"],
|
||||
|
||||
// Pannello wallet
|
||||
// Import error messages
|
||||
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."],
|
||||
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."],
|
||||
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."],
|
||||
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."],
|
||||
|
||||
// Wallet panel
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
||||
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
||||
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
|
||||
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
||||
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
|
||||
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
|
||||
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"],
|
||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"],
|
||||
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"],
|
||||
["receive.hint"] = [
|
||||
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
||||
"Payments received here will appear in the history at the next synchronization.",
|
||||
@@ -105,25 +196,104 @@ public sealed class Loc
|
||||
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
|
||||
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
||||
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
|
||||
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"],
|
||||
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
|
||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
|
||||
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
|
||||
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
|
||||
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen"],
|
||||
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen."],
|
||||
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||
|
||||
// History → transaction detail
|
||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"],
|
||||
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"],
|
||||
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"],
|
||||
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
|
||||
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"],
|
||||
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"],
|
||||
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
|
||||
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
|
||||
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
|
||||
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
|
||||
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"],
|
||||
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"],
|
||||
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"],
|
||||
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"],
|
||||
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
|
||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
|
||||
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
|
||||
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
|
||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
||||
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht"],
|
||||
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren"],
|
||||
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details"],
|
||||
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase"],
|
||||
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)"],
|
||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
||||
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
|
||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
||||
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
||||
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger"],
|
||||
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr"],
|
||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
|
||||
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
|
||||
|
||||
// Stato connessione
|
||||
// Wallet info overlay
|
||||
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet"],
|
||||
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen"],
|
||||
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei"],
|
||||
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk"],
|
||||
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ"],
|
||||
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)"],
|
||||
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)"],
|
||||
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel"],
|
||||
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)"],
|
||||
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script"],
|
||||
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad"],
|
||||
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel"],
|
||||
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint"],
|
||||
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)"],
|
||||
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert)."],
|
||||
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:"],
|
||||
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen"],
|
||||
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
||||
["walletinfo.seed.warning"] = [
|
||||
"Non condividere mai queste parole. Chi le possiede controlla i fondi.",
|
||||
"Never share these words. Whoever holds them controls the funds.",
|
||||
"Nunca compartas estas palabras. Quien las tenga controla los fondos.",
|
||||
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
|
||||
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
|
||||
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder."],
|
||||
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase"],
|
||||
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)"],
|
||||
|
||||
// Connection status
|
||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
||||
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
|
||||
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"],
|
||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
|
||||
["conn.connectedto"] = ["connesso a", "connected to", "conectado a", "connecté à", "conectado a", "verbunden mit"],
|
||||
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
|
||||
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
|
||||
|
||||
// Messaggi di stato principali
|
||||
// Main status messages
|
||||
["msg.welcome.existing"] = [
|
||||
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
|
||||
"Found an existing wallet on this network: open it, or create another one.",
|
||||
@@ -194,7 +364,24 @@ public sealed class Loc
|
||||
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
|
||||
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
|
||||
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
|
||||
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
|
||||
["msg.password.required"] = [
|
||||
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
|
||||
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
|
||||
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
|
||||
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
|
||||
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
|
||||
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
|
||||
["msg.password.mismatch"] = [
|
||||
"Le due password non coincidono.",
|
||||
"The two passwords do not match.",
|
||||
"Las dos contraseñas no coinciden.",
|
||||
"Les deux mots de passe ne correspondent pas.",
|
||||
"As duas senhas não coincidem.",
|
||||
"Die beiden Passwörter stimmen nicht überein."],
|
||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
|
||||
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."],
|
||||
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."],
|
||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
|
||||
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
|
||||
["msg.synced.detail"] = [
|
||||
@@ -207,6 +394,9 @@ public sealed class Loc
|
||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
||||
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft"],
|
||||
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||
["msg.certreset"] = [
|
||||
"Certificati SSL azzerati: riprova la connessione.",
|
||||
@@ -216,12 +406,47 @@ public sealed class Loc
|
||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
||||
["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."],
|
||||
|
||||
// Finestra impostazioni
|
||||
// Contacts
|
||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
|
||||
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
|
||||
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
|
||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
||||
|
||||
// Settings window
|
||||
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
|
||||
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
|
||||
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
|
||||
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
|
||||
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
|
||||
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
|
||||
|
||||
// Server window
|
||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
||||
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
|
||||
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Libreria UI condivisa: il codice Avalonia (App, Views, ViewModels, Loc,
|
||||
Assets) usato sia dall'head Desktop sia dall'head Android. Gli head
|
||||
portano l'entry-point e i pacchetti specifici di piattaforma. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>1.0.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PalladiumWallet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Seam for platform-specific services (Android/desktop).
|
||||
/// The Android head sets the delegates in OnCreate; desktop leaves them null.
|
||||
/// </summary>
|
||||
public static class PlatformServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens the native QR scanner and returns the raw code text,
|
||||
/// or null if the user cancels or the scanner is unavailable.
|
||||
/// </summary>
|
||||
public static Func<Task<string?>>? ScanQrAsync { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- ============================================================
|
||||
Palladium wallet control styles ("clean fintech").
|
||||
Applied on top of FluentTheme: refine spacing, states and
|
||||
reusable classes without re-templating controls. The "accent"
|
||||
buttons remain managed by Fluent but inherit the brand blue
|
||||
via SystemAccentColor (see Theme.axaml).
|
||||
============================================================ -->
|
||||
|
||||
<Design.PreviewWith>
|
||||
<Border Padding="20" Width="320">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="h1" Text="Saldo disponibile"/>
|
||||
<TextBlock Classes="amount" Text="1 234,56 PLM"/>
|
||||
<Button Classes="accent" Content="Invia"/>
|
||||
<Button Content="Annulla"/>
|
||||
<Border Classes="card" Padding="16">
|
||||
<TextBlock Text="Card di esempio"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Design.PreviewWith>
|
||||
|
||||
<!-- ===== Typography ===== -->
|
||||
<Style Selector="TextBlock.h1">
|
||||
<Setter Property="FontSize" Value="20"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
</Style>
|
||||
<!-- Amounts and numeric data: tabular figures to prevent layout jumps -->
|
||||
<Style Selector="TextBlock.amount">
|
||||
<Setter Property="FontSize" Value="34"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="FontFeatures" Value="+tnum"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.mono, SelectableTextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="monospace"/>
|
||||
<Setter Property="FontFeatures" Value="+tnum"/>
|
||||
</Style>
|
||||
<!-- Section heading (e.g. transaction detail) -->
|
||||
<Style Selector="TextBlock.section">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}"/>
|
||||
<Setter Property="LetterSpacing" Value="0.8"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Buttons ===== -->
|
||||
<Style Selector="Button">
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Padding" Value="14,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<!-- Accent (CTA) buttons are already blue via Fluent+SystemAccentColor.
|
||||
Ensure readable white text in both light and dark variants. -->
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||
</Style>
|
||||
<!-- "ghost" variant: subtle secondary action, no fill -->
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Inputs ===== -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Padding" Value="10,7"/>
|
||||
</Style>
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="Padding" Value="10,6"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Lists ===== -->
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="12,10"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Margin" Value="0,1"/>
|
||||
</Style>
|
||||
<!-- Hover: neutral tint; selected: subtle brand-blue tint -->
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource PrimarySoftBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Tabs (dashboard) ===== -->
|
||||
<!-- Selected item: brand blue + bold; unselected: muted -->
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
</Style>
|
||||
<!-- Push tab header content up so the 2px selection indicator (drawn at Panel bottom)
|
||||
does not overlap the text label. -->
|
||||
<Style Selector="TabItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Reusable classes ===== -->
|
||||
<!-- Card: elevated surface with a subtle border and soft corners -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="14"/>
|
||||
<Setter Property="BoxShadow" Value="0 1 3 0 #14000000"/>
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BoxShadowsTransition Property="BoxShadow" Duration="0:0:0.18"/>
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Border.card:pointerover">
|
||||
<Setter Property="BoxShadow" Value="0 6 16 -4 #26000000"/>
|
||||
</Style>
|
||||
<!-- Balance card: subtle blue accent, slightly more prominent -->
|
||||
<Style Selector="Border.balance-card">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderSubtleBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="14"/>
|
||||
<Setter Property="Padding" Value="20,16"/>
|
||||
<Setter Property="BoxShadow" Value="0 2 8 0 #1A000000"/>
|
||||
</Style>
|
||||
<!-- Hero balance card: brand gradient + blue glow. Signature dashboard
|
||||
element; overlaid text is white (on-hero class). -->
|
||||
<Style Selector="Border.balance-hero">
|
||||
<Setter Property="Background" Value="{DynamicResource BalanceGradientBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="16"/>
|
||||
<Setter Property="Padding" Value="24,20"/>
|
||||
<Setter Property="BoxShadow" Value="0 8 24 -6 #663B82F6"/>
|
||||
</Style>
|
||||
<Style Selector="Border.balance-hero TextBlock.amount">
|
||||
<Setter Property="Foreground" Value="#FFFFFF"/>
|
||||
</Style>
|
||||
<Style Selector="Border.balance-hero TextBlock.on-hero">
|
||||
<Setter Property="Foreground" Value="#D6E2FF"/>
|
||||
</Style>
|
||||
<!-- Status pill / badge -->
|
||||
<Style Selector="Border.chip">
|
||||
<Setter Property="CornerRadius" Value="20"/>
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceAltBrush}"/>
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -0,0 +1,96 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- ============================================================
|
||||
Palladium wallet design tokens.
|
||||
Identity: cobalt blue (trust) + green (money/positive),
|
||||
derived from the logo. "Clean fintech" style.
|
||||
|
||||
All colors are SEMANTIC (role, not name): components
|
||||
must never use inline hex but these brushes. Defined for
|
||||
both variants (light/dark) via ThemeDictionaries, so
|
||||
the app follows the system theme with no further configuration.
|
||||
============================================================ -->
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- ============ LIGHT THEME ============ -->
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- Surfaces -->
|
||||
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F1F5F9"/> <!-- slate-100 -->
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
||||
<SolidColorBrush x:Key="OverlayCardBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="ScrimBrush" Color="#66000000"/> <!-- 40% -->
|
||||
<!-- Borders and separators -->
|
||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#E2E8F0"/> <!-- slate-200 -->
|
||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CBD5E1"/> <!-- slate-300 -->
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#0F172A"/> <!-- slate-900 -->
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#475569"/> <!-- slate-600 -->
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
||||
<!-- Brand / state -->
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB"/> <!-- blue-600 -->
|
||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#1D4ED8"/> <!-- blue-700 -->
|
||||
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#EFF6FF"/> <!-- blue-50 -->
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#16A34A"/> <!-- green-600 -->
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#D97706"/> <!-- amber-600 -->
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626"/> <!-- red-600 -->
|
||||
<!-- Hero gradient of the balance card: blue → indigo -->
|
||||
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#2563EB"/>
|
||||
<GradientStop Offset="1" Color="#4F46E5"/>
|
||||
</LinearGradientBrush>
|
||||
</ResourceDictionary>
|
||||
|
||||
<!-- ============ DARK THEME ============ -->
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<!-- Surfaces -->
|
||||
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#0F172A"/> <!-- slate-900 -->
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E293B"/> <!-- slate-800 -->
|
||||
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#273449"/>
|
||||
<SolidColorBrush x:Key="OverlayCardBrush" Color="#1E293B"/>
|
||||
<SolidColorBrush x:Key="ScrimBrush" Color="#99000000"/> <!-- 60% -->
|
||||
<!-- Borders and separators -->
|
||||
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#334155"/> <!-- slate-700 -->
|
||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#475569"/> <!-- slate-600 -->
|
||||
<!-- Text -->
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F8FAFC"/> <!-- slate-50 -->
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#94A3B8"/> <!-- slate-400 -->
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#64748B"/> <!-- slate-500 -->
|
||||
<!-- Brand / state (brighter tones on dark background) -->
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="#3B82F6"/> <!-- blue-500 -->
|
||||
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="#60A5FA"/> <!-- blue-400 -->
|
||||
<SolidColorBrush x:Key="OnPrimaryBrush" Color="#0B1120"/>
|
||||
<SolidColorBrush x:Key="PrimarySoftBrush" Color="#1E3A8A"/> <!-- blue-900 -->
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#22C55E"/> <!-- green-500 -->
|
||||
<SolidColorBrush x:Key="WarningBrush" Color="#F59E0B"/> <!-- amber-500 -->
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#EF4444"/> <!-- red-500 -->
|
||||
<!-- Hero gradient of the balance card: blue → indigo (deeper tones) -->
|
||||
<LinearGradientBrush x:Key="BalanceGradientBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#1D4ED8"/>
|
||||
<GradientStop Offset="1" Color="#4338CA"/>
|
||||
</LinearGradientBrush>
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- ============================================================
|
||||
Override of the Fluent accent → brand blue. Recolors
|
||||
consistently the controls that read SystemAccentColor (accent
|
||||
buttons, selections, tab indicator, focus) without retemplating anything.
|
||||
Variant-independent: #2563EB works on light and dark.
|
||||
============================================================ -->
|
||||
<Color x:Key="SystemAccentColor">#2563EB</Color>
|
||||
<Color x:Key="SystemAccentColorDark1">#1D4ED8</Color>
|
||||
<Color x:Key="SystemAccentColorDark2">#1E40AF</Color>
|
||||
<Color x:Key="SystemAccentColorDark3">#1E3A8A</Color>
|
||||
<Color x:Key="SystemAccentColorLight1">#3B82F6</Color>
|
||||
<Color x:Key="SystemAccentColorLight2">#60A5FA</Color>
|
||||
<Color x:Key="SystemAccentColorLight3">#93C5FD</Color>
|
||||
|
||||
<!-- Global Fluent control corner radii: rounds buttons, inputs, combos,
|
||||
and lists uniformly without retemplating each control. -->
|
||||
<CornerRadius x:Key="ControlCornerRadius">8</CornerRadius>
|
||||
<CornerRadius x:Key="OverlayCornerRadius">12</CornerRadius>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// true (mobile) → Dock.Bottom — tab strip at the bottom, Android standard.
|
||||
/// false (desktop) → Dock.Top — Avalonia default behavior.
|
||||
/// </summary>
|
||||
public sealed class BoolToTabPlacementConverter : IValueConverter
|
||||
{
|
||||
public static readonly BoolToTabPlacementConverter Instance = new();
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true ? Dock.Bottom : Dock.Top;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
public ObservableCollection<ContactEntry> Contacts { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ContactEntry? selectedContactInList;
|
||||
|
||||
/// <summary>Contact selected in the Send panel's ComboBox: fills SendTo.</summary>
|
||||
[ObservableProperty]
|
||||
private ContactEntry? sendToContact;
|
||||
|
||||
partial void OnSendToContactChanged(ContactEntry? value)
|
||||
{
|
||||
if (value is not null)
|
||||
SendTo = value.Address;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContactName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContactAddress = "";
|
||||
|
||||
[RelayCommand]
|
||||
private void AddContact()
|
||||
{
|
||||
var name = NewContactName.Trim();
|
||||
var addr = NewContactAddress.Trim();
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return;
|
||||
Contacts.Add(new ContactEntry(name, addr));
|
||||
NewContactName = NewContactAddress = "";
|
||||
PersistContacts();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSelectedContact()
|
||||
{
|
||||
if (SelectedContactInList is { } c)
|
||||
{
|
||||
Contacts.Remove(c);
|
||||
SelectedContactInList = null;
|
||||
PersistContacts();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistContacts()
|
||||
{
|
||||
if (_doc is null || _walletPath is null) return;
|
||||
_doc.Contacts = Contacts
|
||||
.Select(c => new StoredContact { Name = c.Name, Address = c.Address })
|
||||
.ToList();
|
||||
WalletStore.Save(_doc, _walletPath, _password);
|
||||
}
|
||||
|
||||
private void LoadContacts()
|
||||
{
|
||||
Contacts.Clear();
|
||||
if (_doc is null) return;
|
||||
foreach (var c in _doc.Contacts)
|
||||
Contacts.Add(new ContactEntry(c.Name, c.Address));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
public const string DevAddress = "plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay";
|
||||
|
||||
[ObservableProperty]
|
||||
private string donateAmount = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string donatePreview = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasPendingDonate;
|
||||
|
||||
private BuiltTransaction? _pendingDonate;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PrepareDonate()
|
||||
{
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
|
||||
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
||||
{
|
||||
DonatePreview = Loc.Tr("msg.send.sync.first");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
||||
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
||||
{
|
||||
DonatePreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var feeRate) || feeRate <= 0)
|
||||
feeRate = 1m;
|
||||
|
||||
_pendingDonate = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
|
||||
|
||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||
$"({_pendingDonate.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingDonate.Signed ? "" : " · watch-only");
|
||||
HasPendingDonate = _pendingDonate.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
DonatePreview = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDonate()
|
||||
{
|
||||
if (_pendingDonate is null || _client is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
||||
DonatePreview = $"{Loc.Tr("msg.donate.thanks")}: {txid}";
|
||||
DonateAmount = "";
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DonatePreview = $"{Loc.Tr("msg.broadcast.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetDonate()
|
||||
{
|
||||
DonateAmount = "";
|
||||
DonatePreview = "";
|
||||
HasPendingDonate = false;
|
||||
_pendingDonate = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
using QRCoder;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- balance and wallet info ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string balanceText = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string unconfirmedText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string immatureText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string verifyingText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
// ---- receive address and QR ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string receiveAddress = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private Bitmap? receiveQr;
|
||||
|
||||
partial void OnReceiveAddressChanged(string value)
|
||||
{
|
||||
var previous = ReceiveQr;
|
||||
ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
public void NotifyAddressCopied() => StatusMessage = Loc.Tr("addr.copied");
|
||||
|
||||
private static Bitmap? GenerateQr(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
|
||||
var png = new PngByteQRCode(data).GetGraphic(8);
|
||||
return new Bitmap(new MemoryStream(png));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- addresses tab ----
|
||||
|
||||
[ObservableProperty]
|
||||
private AddressRow? selectedAddressRow;
|
||||
|
||||
[ObservableProperty]
|
||||
private AddressInfo? addressInfo;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isAddressPrivKeyRevealed;
|
||||
|
||||
// ---- private key password prompt ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isPrivKeyPromptOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string privKeyPromptPassword = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string privKeyPromptError = "";
|
||||
|
||||
partial void OnAddressInfoChanged(AddressInfo? value)
|
||||
{
|
||||
IsAddressPrivKeyRevealed = false;
|
||||
ClosePrivKeyPromptInternal();
|
||||
}
|
||||
|
||||
private void ClosePrivKeyPromptInternal()
|
||||
{
|
||||
IsPrivKeyPromptOpen = false;
|
||||
PrivKeyPromptPassword = "";
|
||||
PrivKeyPromptError = "";
|
||||
}
|
||||
|
||||
public void ShowAddressInfo(AddressRow row) =>
|
||||
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseAddressInfo()
|
||||
{
|
||||
ClosePrivKeyPromptInternal();
|
||||
AddressInfo = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RequestPrivKeyReveal()
|
||||
{
|
||||
if (AddressInfo is null || !AddressInfo.HasPrivKey) return;
|
||||
if (string.IsNullOrEmpty(_password))
|
||||
{
|
||||
// No encryption: reveal directly
|
||||
IsAddressPrivKeyRevealed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Encrypted: open password prompt
|
||||
PrivKeyPromptPassword = "";
|
||||
PrivKeyPromptError = "";
|
||||
IsPrivKeyPromptOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ConfirmPrivKeyPassword()
|
||||
{
|
||||
if (PrivKeyPromptPassword != _password)
|
||||
{
|
||||
PrivKeyPromptError = Loc.Tr("msg.wrongpassword");
|
||||
return;
|
||||
}
|
||||
IsAddressPrivKeyRevealed = true;
|
||||
ClosePrivKeyPromptInternal();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelPrivKeyPrompt() => ClosePrivKeyPromptInternal();
|
||||
|
||||
[RelayCommand]
|
||||
private void HideAddressPrivKey()
|
||||
{
|
||||
IsAddressPrivKeyRevealed = false;
|
||||
}
|
||||
|
||||
// ---- transaction detail overlay ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isTxDetailsOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isTxDetailsLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private TransactionDetailsViewModel? txDetails;
|
||||
|
||||
public async Task ShowTransactionDetailsAsync(string txid)
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
StatusMessage = Loc.Tr("tx.needconnection");
|
||||
return;
|
||||
}
|
||||
|
||||
_txDetailsCts?.Cancel();
|
||||
_txDetailsCts = new CancellationTokenSource();
|
||||
var ct = _txDetailsCts.Token;
|
||||
|
||||
TxDetails = null;
|
||||
IsTxDetailsLoading = true;
|
||||
IsTxDetailsOpen = true;
|
||||
|
||||
var details = await BuildTransactionDetailsAsync(txid, ct);
|
||||
|
||||
if (ct.IsCancellationRequested || !IsTxDetailsOpen)
|
||||
return;
|
||||
|
||||
if (details is null)
|
||||
{
|
||||
IsTxDetailsOpen = false;
|
||||
IsTxDetailsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
TxDetails = details;
|
||||
IsTxDetailsLoading = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseTransactionDetails()
|
||||
{
|
||||
_txDetailsCts?.Cancel();
|
||||
IsTxDetailsOpen = false;
|
||||
IsTxDetailsLoading = false;
|
||||
TxDetails = null;
|
||||
}
|
||||
|
||||
public async Task<TransactionDetailsViewModel?> BuildTransactionDetailsAsync(
|
||||
string txid, CancellationToken ct = default)
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
StatusMessage = Loc.Tr("tx.needconnection");
|
||||
return null;
|
||||
}
|
||||
if (_doc?.Cache is not { } cache)
|
||||
return null;
|
||||
|
||||
var client = _client;
|
||||
var network = PalladiumNetworks.For(Net);
|
||||
var row = cache.History.FirstOrDefault(t => t.Txid == txid);
|
||||
var owned = cache.Addresses.Select(a => a.Address).ToHashSet();
|
||||
var tipHeight = cache.TipHeight;
|
||||
var height = row?.Height ?? 0;
|
||||
var delta = row?.DeltaSats ?? 0;
|
||||
var verified = row?.Verified ?? false;
|
||||
var transactions = _lastTransactions;
|
||||
var loc = _loc;
|
||||
var unit = _config.Unit;
|
||||
|
||||
try
|
||||
{
|
||||
return await Task.Run(async () =>
|
||||
{
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
|
||||
return new TransactionDetailsViewModel(details, loc, unit);
|
||||
}, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- update display from the sync result ----
|
||||
|
||||
private void ApplyCache(SyncCache? cache)
|
||||
{
|
||||
if (_account is null)
|
||||
return;
|
||||
if (cache is null)
|
||||
{
|
||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||
UnconfirmedText = "";
|
||||
ImmatureText = "";
|
||||
VerifyingText = "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||
History.Clear();
|
||||
Addresses.Clear();
|
||||
for (var i = 0; i < 10; i++)
|
||||
Addresses.Add(new AddressRow(_loc["addr.receive"], i,
|
||||
_account.GetReceiveAddress(i).ToString(), "—", "—",
|
||||
false,
|
||||
_account.GetPublicKey(false, i)?.ToHex() ?? "",
|
||||
KeyWif(false, i),
|
||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||
return;
|
||||
}
|
||||
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
|
||||
// immature coinbase can also be unverified), which double-subtracts and can go
|
||||
// negative. SpendableSats is computed directly from the same IsSpendable gate
|
||||
// coin selection uses, so it's always correct.
|
||||
BalanceText = Fmt(cache.SpendableSats);
|
||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||
UnconfirmedText = pending != 0
|
||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
ImmatureText = cache.ImmatureSats != 0
|
||||
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
|
||||
// proof background verification hasn't reached yet — same "not spendable" treatment
|
||||
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
|
||||
VerifyingText = cache.PendingVerificationSats != 0
|
||||
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||
History.Clear();
|
||||
foreach (var tx in cache.History)
|
||||
History.Add(new HistoryRow(
|
||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||
tx.Txid,
|
||||
tx.Verified));
|
||||
|
||||
Addresses.Clear();
|
||||
foreach (var a in cache.Addresses)
|
||||
Addresses.Add(new AddressRow(
|
||||
a.IsChange ? _loc["addr.change"] : _loc["addr.receive"],
|
||||
a.Index,
|
||||
a.Address,
|
||||
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
||||
a.TxCount > 0 ? a.TxCount.ToString() : "—",
|
||||
a.IsChange,
|
||||
_account.GetPublicKey(a.IsChange, a.Index)?.ToHex() ?? "",
|
||||
KeyWif(a.IsChange, a.Index),
|
||||
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.App.Services;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string sendTo = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendAmount = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendFeeRate = "1";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool sendAll;
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendPreview = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasPendingSend;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ScanQr()
|
||||
{
|
||||
if (PlatformServices.ScanQrAsync is not { } scan) return;
|
||||
var raw = await scan();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return;
|
||||
// Handle URIs like "palladium:ADDRESS?amount=X" — extract address only
|
||||
var address = raw.Contains(':') ? raw.Split(':')[1] : raw;
|
||||
if (address.Contains('?')) address = address.Split('?')[0];
|
||||
SendTo = address.Trim();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PrepareSend()
|
||||
{
|
||||
if (_account is null || _doc?.Cache is null)
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.send.sync.first");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (_lastTransactions is null)
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.send.connect.first");
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
|
||||
long amount = 0;
|
||||
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.feerate.invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingSend = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||
|
||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
||||
HasPendingSend = _pendingSend.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmSend()
|
||||
{
|
||||
if (_pendingSend is null || _client is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
|
||||
SendPreview = $"{Loc.Tr("msg.broadcasted")}: {txid}";
|
||||
SendTo = SendAmount = "";
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendPreview = $"{Loc.Tr("msg.broadcast.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- spunte del menu Impostazioni (ToggleType Radio) ----
|
||||
|
||||
public bool IsLangIt => _config.Language == "it";
|
||||
public bool IsLangEn => _config.Language == "en";
|
||||
public bool IsLangEs => _config.Language == "es";
|
||||
public bool IsLangFr => _config.Language == "fr";
|
||||
public bool IsLangPt => _config.Language == "pt";
|
||||
public bool IsLangDe => _config.Language == "de";
|
||||
public bool IsUnitPlm => _config.Unit == "PLM";
|
||||
public bool IsUnitMilli => _config.Unit == "mPLM";
|
||||
public bool IsUnitMicro => _config.Unit == "µPLM";
|
||||
public bool IsUnitSat => _config.Unit == "sat";
|
||||
|
||||
[RelayCommand]
|
||||
private void SetLanguage(string language)
|
||||
{
|
||||
_config.Language = language;
|
||||
ApplySettings(_config);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SetUnit(string unit)
|
||||
{
|
||||
_config.Unit = unit;
|
||||
ApplySettings(_config);
|
||||
}
|
||||
|
||||
public void ApplySettings(PalladiumWallet.Core.Storage.AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_config.Save();
|
||||
_loc = Localization.Loc.SwitchTo(config.Language);
|
||||
OnPropertyChanged(nameof(Loc));
|
||||
OnPropertyChanged(nameof(UnitLabel));
|
||||
OnPropertyChanged(nameof(IsLangIt));
|
||||
OnPropertyChanged(nameof(IsLangEn));
|
||||
OnPropertyChanged(nameof(IsLangEs));
|
||||
OnPropertyChanged(nameof(IsLangFr));
|
||||
OnPropertyChanged(nameof(IsLangPt));
|
||||
OnPropertyChanged(nameof(IsLangDe));
|
||||
OnPropertyChanged(nameof(IsUnitPlm));
|
||||
OnPropertyChanged(nameof(IsUnitMilli));
|
||||
OnPropertyChanged(nameof(IsUnitMicro));
|
||||
OnPropertyChanged(nameof(IsUnitSat));
|
||||
ApplyCache(_doc?.Cache);
|
||||
StatusMessage = Localization.Loc.Tr("msg.settings.saved");
|
||||
}
|
||||
|
||||
// ---- overlay impostazioni, server, help ----
|
||||
|
||||
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
|
||||
private bool isSettingsOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenSettings() => IsSettingsOpen = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseSettings() => IsSettingsOpen = false;
|
||||
|
||||
[CommunityToolkit.Mvvm.ComponentModel.ObservableProperty]
|
||||
private bool isHelpOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenHelp() => IsHelpOpen = true;
|
||||
|
||||
[RelayCommand]
|
||||
private static void QuitApp() =>
|
||||
(Avalonia.Application.Current?.ApplicationLifetime
|
||||
as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)
|
||||
?.Shutdown();
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseHelp() { IsHelpOpen = false; ResetDonate(); }
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- server and connection ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string serverHost = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string serverPort = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool useSsl = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isServerSettingsOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenServerSettings()
|
||||
{
|
||||
IsSettingsOpen = false;
|
||||
RefreshServers();
|
||||
IsServerSettingsOpen = true;
|
||||
if (_client is { IsConnected: true })
|
||||
await DiscoverServers();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseServerSettings() => IsServerSettingsOpen = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private string connectionStatus = Loc.Tr("conn.none");
|
||||
|
||||
[ObservableProperty]
|
||||
private string connectionStatusShort = Loc.Tr("conn.none");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSyncing;
|
||||
|
||||
private CancellationTokenSource _syncCts = new();
|
||||
|
||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private KnownServer? selectedKnownServer;
|
||||
|
||||
partial void OnSelectedKnownServerChanged(KnownServer? value)
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
_syncingServerFields = true;
|
||||
ServerHost = value.Host;
|
||||
ServerPort = value.PortFor(UseSsl).ToString();
|
||||
_syncingServerFields = false;
|
||||
}
|
||||
|
||||
partial void OnUseSslChanged(bool value)
|
||||
{
|
||||
if (_syncingServerFields)
|
||||
return;
|
||||
_syncingServerFields = true;
|
||||
ServerPort = SelectedKnownServer is { } server
|
||||
? server.PortFor(value).ToString()
|
||||
: (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
|
||||
_syncingServerFields = false;
|
||||
}
|
||||
|
||||
partial void OnServerPortChanged(string value)
|
||||
{
|
||||
if (_syncingServerFields)
|
||||
return;
|
||||
if (!int.TryParse(value.Trim(), out var port))
|
||||
return;
|
||||
bool? wantSsl =
|
||||
SelectedKnownServer is { } s && port == s.SslPort ? true :
|
||||
SelectedKnownServer is { } t && port == t.TcpPort ? false :
|
||||
port == Profile.DefaultSslPort ? true :
|
||||
port == Profile.DefaultTcpPort ? false :
|
||||
null;
|
||||
if (wantSsl is bool b && b != UseSsl)
|
||||
{
|
||||
_syncingServerFields = true;
|
||||
UseSsl = b;
|
||||
_syncingServerFields = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshServers()
|
||||
{
|
||||
KnownServers.Clear();
|
||||
foreach (var server in Registry.All)
|
||||
KnownServers.Add(server);
|
||||
|
||||
if (_config.LastServerHost is { Length: > 0 } savedHost && _config.LastServerPort is { } savedPort)
|
||||
{
|
||||
_syncingServerFields = true;
|
||||
UseSsl = _config.LastServerUseSsl;
|
||||
ServerHost = savedHost;
|
||||
ServerPort = savedPort.ToString();
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == savedHost);
|
||||
_syncingServerFields = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault();
|
||||
if (SelectedKnownServer is null)
|
||||
{
|
||||
ServerHost = "127.0.0.1";
|
||||
ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (string Host, int Port) ParseServer()
|
||||
{
|
||||
var host = ServerHost.Trim();
|
||||
var port = int.TryParse(ServerPort.Trim(), out var p)
|
||||
? p
|
||||
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
|
||||
return (host, port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns servers to try in order: first the one selected in the UI
|
||||
/// (or manually typed), then the remaining KnownServers, with duplicates skipped.
|
||||
/// </summary>
|
||||
private IEnumerable<(string Host, int Port)> BuildServerCandidates(string selectedHost, int selectedPort)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// 1. Current server (selected or manually typed).
|
||||
if (!string.IsNullOrWhiteSpace(selectedHost))
|
||||
{
|
||||
var key = $"{selectedHost}:{selectedPort}";
|
||||
if (seen.Add(key))
|
||||
yield return (selectedHost, selectedPort);
|
||||
}
|
||||
// 2. Other known servers, in list order.
|
||||
foreach (var s in KnownServers)
|
||||
{
|
||||
var p = s.PortFor(UseSsl);
|
||||
var key = $"{s.Host}:{p}";
|
||||
if (seen.Add(key))
|
||||
yield return (s.Host, p);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConnectAndSync()
|
||||
{
|
||||
if (IsSyncing)
|
||||
{
|
||||
var (newHost, newPort) = ParseServer();
|
||||
bool serverChanged = _client is null
|
||||
|| _client.Host != newHost
|
||||
|| _client.Port != newPort
|
||||
|| _client.UseSsl != UseSsl;
|
||||
if (serverChanged)
|
||||
_syncCts.Cancel();
|
||||
else
|
||||
_resyncRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_syncCts = new CancellationTokenSource();
|
||||
var ct = _syncCts.Token;
|
||||
IsSyncing = true;
|
||||
StatusMessage = "";
|
||||
bool cancelled = false;
|
||||
try
|
||||
{
|
||||
var (host, port) = ParseServer();
|
||||
|
||||
if (_client is { } current &&
|
||||
(current.Host != host || current.Port != port || current.UseSsl != UseSsl))
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
// Persist caches before destroying the synchronizer: the new
|
||||
// synchronizer will use a different client and must be recreated,
|
||||
// but already-downloaded data is preserved via _doc.Cache.
|
||||
PersistPartialTxCache();
|
||||
_synchronizer = null;
|
||||
|
||||
// Try all known servers in order; starts with the selected one
|
||||
// and walks the list until the first that responds.
|
||||
var candidates = BuildServerCandidates(host, port);
|
||||
Exception? lastError = null;
|
||||
foreach (var (h, p) in candidates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||
_client.NotificationReceived += OnServerNotification;
|
||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
});
|
||||
IsConnected = true;
|
||||
_autoReconnect = true;
|
||||
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {h}:{p}";
|
||||
ConnectionStatusShort = Loc.Tr("conn.connectedto");
|
||||
// Update the UI to reflect the server actually connected.
|
||||
_syncingServerFields = true;
|
||||
ServerHost = h;
|
||||
ServerPort = p.ToString();
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == h)
|
||||
?? SelectedKnownServer;
|
||||
_syncingServerFields = false;
|
||||
// Persist the last-used server so it is restored on next launch.
|
||||
_config.LastServerHost = h;
|
||||
_config.LastServerPort = p;
|
||||
_config.LastServerUseSsl = UseSsl;
|
||||
_config.Save();
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
if (lastError is not null)
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (_account is null || _doc is null)
|
||||
return;
|
||||
|
||||
if (_synchronizer is null)
|
||||
{
|
||||
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
|
||||
// Reload from disk cache: avoids re-downloading already known transactions
|
||||
// (essential for wallets with thousands of historical txs — prevents -101).
|
||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||
_synchronizer.PreloadCaches(
|
||||
_doc.Cache?.RawTxHex ?? [],
|
||||
_doc.Cache?.VerifiedAt ?? [],
|
||||
_doc.Cache?.BlockHeaders,
|
||||
_doc.Cache?.NextReceiveIndex ?? 0,
|
||||
_doc.Cache?.NextChangeIndex ?? 0,
|
||||
net);
|
||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||
// Progressive verification (§7.4): the wallet becomes usable as soon as
|
||||
// transaction downloads finish, without waiting for every historical Merkle
|
||||
// proof — critical on mobile, where verifying thousands of proofs can take far
|
||||
// longer than the download itself. Never persisted to disk on its own (only the
|
||||
// final, fully-verified snapshot below is saved); coin selection still refuses
|
||||
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
|
||||
// early can't be exploited to spend a server-fabricated balance.
|
||||
_synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r));
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
_resyncRequested = false;
|
||||
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
|
||||
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
|
||||
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
|
||||
var (result, rawHex, verifiedAt, blockHeaders) = await Task.Run(async () =>
|
||||
{
|
||||
var r = await _synchronizer.SyncOnceAsync(ct);
|
||||
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
|
||||
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders);
|
||||
}, ct);
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
var cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
|
||||
FillDisplayFields(cache, result);
|
||||
_doc.Cache = cache;
|
||||
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
||||
ApplyCache(_doc.Cache);
|
||||
_syncFailed = false;
|
||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||
} while (_resyncRequested && !ct.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Intentional cancellation due to server change request — not an error.
|
||||
cancelled = true;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
StatusMessage = "";
|
||||
}
|
||||
catch (CertificatePinMismatchException ex)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
StatusMessage = DescribeError(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsConnected = _client?.IsConnected == true;
|
||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
if (_account is not null)
|
||||
{
|
||||
_syncFailed = true;
|
||||
// Persist already-downloaded transactions: on retry the synchronizer
|
||||
// resumes from here instead of starting from scratch (e.g. after -101).
|
||||
PersistPartialTxCache();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSyncing = false;
|
||||
}
|
||||
|
||||
// If cancelled due to a server change, restart immediately with the new server.
|
||||
if (cancelled)
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
|
||||
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
|
||||
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
|
||||
/// resume without re-downloading) were never written.
|
||||
/// </summary>
|
||||
private void ApplyPartialResult(SyncResult result)
|
||||
{
|
||||
if (_doc is null)
|
||||
return;
|
||||
var previous = _doc.Cache;
|
||||
var cache = new SyncCache
|
||||
{
|
||||
RawTxHex = previous?.RawTxHex,
|
||||
VerifiedAt = previous?.VerifiedAt,
|
||||
BlockHeaders = previous?.BlockHeaders,
|
||||
};
|
||||
FillDisplayFields(cache, result);
|
||||
_doc.Cache = cache;
|
||||
_lastTransactions = result.Transactions;
|
||||
ApplyCache(cache);
|
||||
}
|
||||
|
||||
private static void FillDisplayFields(SyncCache cache, SyncResult result)
|
||||
{
|
||||
cache.TipHeight = result.TipHeight;
|
||||
cache.ConfirmedSats = result.ConfirmedSats;
|
||||
cache.UnconfirmedSats = result.UnconfirmedSats;
|
||||
cache.ImmatureSats = result.ImmatureSats;
|
||||
cache.PendingVerificationSats = result.PendingVerificationSats;
|
||||
cache.SpendableSats = result.SpendableSats;
|
||||
cache.NextReceiveIndex = result.NextReceiveIndex;
|
||||
cache.NextChangeIndex = result.NextChangeIndex;
|
||||
cache.History = [.. result.History];
|
||||
cache.Utxos = [.. result.Utxos];
|
||||
cache.Addresses = [.. result.AddressRows];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
||||
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||
/// just to query its peer list, without touching the wallet's connection state.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscoverServers()
|
||||
{
|
||||
if (_client is { IsConnected: true } connected)
|
||||
{
|
||||
await DiscoverServersUsing(connected);
|
||||
return;
|
||||
}
|
||||
|
||||
var (host, port) = ParseServer();
|
||||
ElectrumClient? temp = null;
|
||||
Exception? lastError = null;
|
||||
foreach (var (h, p) in BuildServerCandidates(host, port))
|
||||
{
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
temp = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
if (temp is null)
|
||||
{
|
||||
StatusMessage = $"{Loc.Tr("msg.peer.discovery.error")}: " +
|
||||
(lastError is null ? Loc.Tr("conn.none") : DescribeError(lastError));
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await DiscoverServersUsing(temp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await temp.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DiscoverServersUsing(ElectrumClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
var added = await Registry.DiscoverAsync(client);
|
||||
var selected = SelectedKnownServer;
|
||||
RefreshServers();
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||
?? KnownServers.FirstOrDefault();
|
||||
StatusMessage = added > 0
|
||||
? string.Format(Loc.Tr("msg.peer.discovery.found"), added, KnownServers.Count)
|
||||
: string.Format(Loc.Tr("msg.peer.discovery.none"), KnownServers.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (IsSyncing)
|
||||
_resyncRequested = true;
|
||||
else
|
||||
_ = ConnectAndSync();
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ResetCertificates()
|
||||
{
|
||||
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
|
||||
StatusMessage = Loc.Tr("msg.certreset");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the transactions and Merkle proofs already accumulated by the synchronizer
|
||||
/// into SyncCache, even if sync is not yet complete. Allows the next retry
|
||||
/// (or app restart) to resume without re-downloading from scratch.
|
||||
/// </summary>
|
||||
private void PersistPartialTxCache()
|
||||
{
|
||||
if (_synchronizer is null || _doc is null || _walletPath is null || _account is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(net);
|
||||
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||
return;
|
||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||
_doc.Cache.VerifiedAt = verifiedAt;
|
||||
_doc.Cache.BlockHeaders = blockHeaders;
|
||||
WalletStore.Save(_doc, _walletPath, _password);
|
||||
}
|
||||
catch { /* non-fatal: the next full save will recover */ }
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (_client is { } client)
|
||||
{
|
||||
_client = null;
|
||||
_synchronizer = null;
|
||||
try { await client.DisposeAsync(); } catch { }
|
||||
}
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- update-available overlay ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isUpdateAvailableOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string updateAvailableTag = "";
|
||||
|
||||
public string UpdateReleaseUrl { get; private set; } = "";
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
||||
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
||||
/// </summary>
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
var latest = await UpdateChecker.CheckAsync(AppVersion);
|
||||
if (latest is null) return;
|
||||
|
||||
UpdateReleaseUrl = latest.HtmlUrl;
|
||||
UpdateAvailableTag = latest.Tag;
|
||||
IsUpdateAvailableOpen = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.IO;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.App.Localization;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool isWalletInfoOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string walletInfoSeedPasswordInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isWalletInfoSeedRevealed;
|
||||
|
||||
[ObservableProperty]
|
||||
private string walletInfoSeedError = "";
|
||||
|
||||
// ---- computed wallet info (populated when overlay opens) ----
|
||||
|
||||
public string WalletInfoFileName =>
|
||||
_walletPath is null ? "" :
|
||||
Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(_walletPath));
|
||||
|
||||
public string WalletInfoNetwork => _doc?.Network ?? "";
|
||||
|
||||
public string WalletInfoType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_doc is null) return "";
|
||||
if (_doc.Mnemonic is not null) return Loc.Tr("walletinfo.type.seed");
|
||||
if (_doc.AccountXprv is not null) return Loc.Tr("walletinfo.type.xprv");
|
||||
if (_doc.WifKeys is { Count: > 0 }) return Loc.Tr("walletinfo.type.wif");
|
||||
return Loc.Tr("walletinfo.type.watchonly");
|
||||
}
|
||||
}
|
||||
|
||||
public string WalletInfoScriptKind => _doc?.ScriptKind ?? "";
|
||||
|
||||
public string WalletInfoDerivPath => _doc?.AccountPath ?? "";
|
||||
|
||||
public string WalletInfoXpub => _doc?.AccountXpub ?? "";
|
||||
|
||||
public string WalletInfoFingerprint => _doc?.MasterFingerprint ?? "";
|
||||
|
||||
public bool WalletInfoHasSeed => _doc?.Mnemonic is not null;
|
||||
|
||||
public bool WalletInfoSeedNeedsPassword => !string.IsNullOrEmpty(_password);
|
||||
|
||||
public string WalletInfoSeedText => IsWalletInfoSeedRevealed ? (_doc?.Mnemonic ?? "") : "";
|
||||
|
||||
public bool WalletInfoHasPassphrase => !string.IsNullOrEmpty(_doc?.Passphrase);
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenWalletInfo()
|
||||
{
|
||||
WalletInfoSeedPasswordInput = "";
|
||||
IsWalletInfoSeedRevealed = false;
|
||||
WalletInfoSeedError = "";
|
||||
OnPropertyChanged(nameof(WalletInfoFileName));
|
||||
OnPropertyChanged(nameof(WalletInfoNetwork));
|
||||
OnPropertyChanged(nameof(WalletInfoType));
|
||||
OnPropertyChanged(nameof(WalletInfoScriptKind));
|
||||
OnPropertyChanged(nameof(WalletInfoDerivPath));
|
||||
OnPropertyChanged(nameof(WalletInfoXpub));
|
||||
OnPropertyChanged(nameof(WalletInfoFingerprint));
|
||||
OnPropertyChanged(nameof(WalletInfoHasSeed));
|
||||
OnPropertyChanged(nameof(WalletInfoSeedNeedsPassword));
|
||||
OnPropertyChanged(nameof(WalletInfoHasPassphrase));
|
||||
IsWalletInfoOpen = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseWalletInfo()
|
||||
{
|
||||
IsWalletInfoOpen = false;
|
||||
WalletInfoSeedPasswordInput = "";
|
||||
IsWalletInfoSeedRevealed = false;
|
||||
WalletInfoSeedError = "";
|
||||
OnPropertyChanged(nameof(WalletInfoSeedText));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RevealSeed()
|
||||
{
|
||||
if (_doc?.Mnemonic is null) return;
|
||||
|
||||
if (WalletInfoSeedNeedsPassword)
|
||||
{
|
||||
if (WalletInfoSeedPasswordInput != _password)
|
||||
{
|
||||
WalletInfoSeedError = Loc.Tr("msg.wrongpassword");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
WalletInfoSeedError = "";
|
||||
IsWalletInfoSeedRevealed = true;
|
||||
OnPropertyChanged(nameof(WalletInfoSeedText));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void HideSeed()
|
||||
{
|
||||
IsWalletInfoSeedRevealed = false;
|
||||
WalletInfoSeedPasswordInput = "";
|
||||
WalletInfoSeedError = "";
|
||||
OnPropertyChanged(nameof(WalletInfoSeedText));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
// ---- setup wizard
|
||||
|
||||
public const string StepDataLocation = "data-location";
|
||||
public const string StepStart = "start";
|
||||
public const string StepChooseWallet = "choose-wallet";
|
||||
public const string StepOpen = "open";
|
||||
public const string StepShowSeed = "show-seed";
|
||||
public const string StepConfirmSeed = "confirm-seed";
|
||||
public const string StepWords = "words";
|
||||
public const string StepPassphrase = "passphrase";
|
||||
public const string StepScriptType = "script-type";
|
||||
public const string StepImportXkey = "import-xkey";
|
||||
public const string StepImportWif = "import-wif";
|
||||
public const string StepPassword = "password";
|
||||
|
||||
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
|
||||
private WizardFlowKind _wizardFlow;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepStart))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepChooseWallet))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
||||
private string setupStep = StepStart;
|
||||
|
||||
public bool IsStepDataLocation => SetupStep == StepDataLocation;
|
||||
public bool IsStepStart => SetupStep == StepStart;
|
||||
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
|
||||
public bool IsStepOpen => SetupStep == StepOpen;
|
||||
public bool IsStepShowSeed => SetupStep == StepShowSeed;
|
||||
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
|
||||
public bool IsStepWords => SetupStep == StepWords;
|
||||
public bool IsStepPassphrase => SetupStep == StepPassphrase;
|
||||
public bool IsStepScriptType => SetupStep == StepScriptType;
|
||||
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
||||
public bool IsStepImportWif => SetupStep == StepImportWif;
|
||||
public bool IsStepPassword => SetupStep == StepPassword;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsLegacySelected))]
|
||||
[NotifyPropertyChangedFor(nameof(IsWrappedSegwitSelected))]
|
||||
[NotifyPropertyChangedFor(nameof(IsNativeSegwitSelected))]
|
||||
[NotifyPropertyChangedFor(nameof(IsTaprootSelected))]
|
||||
private ScriptKind selectedScriptKind = ScriptKind.NativeSegwit;
|
||||
|
||||
public bool IsLegacySelected => SelectedScriptKind == ScriptKind.Legacy;
|
||||
public bool IsWrappedSegwitSelected => SelectedScriptKind == ScriptKind.WrappedSegwit;
|
||||
public bool IsNativeSegwitSelected => SelectedScriptKind == ScriptKind.NativeSegwit;
|
||||
public bool IsTaprootSelected => SelectedScriptKind == ScriptKind.Taproot;
|
||||
|
||||
[RelayCommand] private void SelectLegacy() => SelectedScriptKind = ScriptKind.Legacy;
|
||||
[RelayCommand] private void SelectWrappedSegwit() => SelectedScriptKind = ScriptKind.WrappedSegwit;
|
||||
[RelayCommand] private void SelectNativeSegwit() => SelectedScriptKind = ScriptKind.NativeSegwit;
|
||||
[RelayCommand] private void SelectTaproot() => SelectedScriptKind = ScriptKind.Taproot;
|
||||
|
||||
public string DefaultDataPath => AppPaths.DefaultDataRoot();
|
||||
|
||||
[ObservableProperty]
|
||||
private string importXkeyInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string importWifInput = "";
|
||||
|
||||
// Script type detected during xkey decoding (to display to the user)
|
||||
[ObservableProperty]
|
||||
private string importXkeyDetectedKind = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string mnemonicInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string confirmMnemonicInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string passphraseInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string walletNameInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string passwordInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string confirmPasswordInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool encryptWallet = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool walletFileExists;
|
||||
|
||||
public ObservableCollection<WalletFileEntry> WalletList { get; } = [];
|
||||
|
||||
[RelayCommand]
|
||||
private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot());
|
||||
|
||||
public void ApplyDataLocation(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
AppPaths.ConfigureDataLocation(root);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
_config = AppConfig.Load();
|
||||
_loc = Loc.SwitchTo(_config.Language);
|
||||
OnPropertyChanged(nameof(Loc));
|
||||
OnPropertyChanged(nameof(UnitLabel));
|
||||
RefreshSetupState();
|
||||
}
|
||||
|
||||
private void RefreshSetupState()
|
||||
{
|
||||
SetupStep = StepStart;
|
||||
SelectedScriptKind = ScriptKind.NativeSegwit;
|
||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
|
||||
WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
|
||||
StatusMessage = "";
|
||||
RefreshServers();
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartOpen()
|
||||
{
|
||||
var files = AppPaths.WalletFiles(Net);
|
||||
if (files.Count > 1)
|
||||
{
|
||||
WalletList.Clear();
|
||||
foreach (var path in files)
|
||||
WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path));
|
||||
SetupStep = StepChooseWallet;
|
||||
StatusMessage = "";
|
||||
return;
|
||||
}
|
||||
_pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net);
|
||||
PasswordInput = "";
|
||||
SetupStep = StepOpen;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ChooseWallet(WalletFileEntry? entry)
|
||||
{
|
||||
if (entry is null)
|
||||
return;
|
||||
_pendingOpenPath = entry.Path;
|
||||
PasswordInput = "";
|
||||
SetupStep = StepOpen;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartNew()
|
||||
{
|
||||
_wizardFlow = WizardFlowKind.New;
|
||||
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
||||
SetupStep = StepShowSeed;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartRestore()
|
||||
{
|
||||
_wizardFlow = WizardFlowKind.Restore;
|
||||
MnemonicInput = "";
|
||||
SetupStep = StepWords;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartImportXkey()
|
||||
{
|
||||
_wizardFlow = WizardFlowKind.ImportXkey;
|
||||
ImportXkeyInput = "";
|
||||
ImportXkeyDetectedKind = "";
|
||||
SetupStep = StepImportXkey;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartImportWif()
|
||||
{
|
||||
_wizardFlow = WizardFlowKind.ImportWif;
|
||||
ImportWifInput = "";
|
||||
SetupStep = StepImportWif;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromShowSeed()
|
||||
{
|
||||
ConfirmMnemonicInput = "";
|
||||
SetupStep = StepConfirmSeed;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromConfirmSeed()
|
||||
{
|
||||
var normalized = string.Join(' ',
|
||||
ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.seed.mismatch");
|
||||
return;
|
||||
}
|
||||
GoToPassphraseStep();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromWords()
|
||||
{
|
||||
if (!Bip39.TryParse(MnemonicInput, out _))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.words.invalid");
|
||||
return;
|
||||
}
|
||||
GoToPassphraseStep();
|
||||
}
|
||||
|
||||
private void GoToPassphraseStep()
|
||||
{
|
||||
PassphraseInput = "";
|
||||
SetupStep = StepPassphrase;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromPassphrase()
|
||||
{
|
||||
SetupStep = StepScriptType;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromImportXkey()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ImportXkeyInput))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.xkey.required");
|
||||
return;
|
||||
}
|
||||
// Validate and detect the type: try as xpub first, then as xprv.
|
||||
if (Slip132.TryDecodePublic(ImportXkeyInput.Trim(), Profile, out _, out var pubKind))
|
||||
{
|
||||
SelectedScriptKind = pubKind;
|
||||
ImportXkeyDetectedKind = $"{pubKind} (watch-only)";
|
||||
}
|
||||
else if (Slip132.TryDecodePrivate(ImportXkeyInput.Trim(), Profile, out _, out var prvKind))
|
||||
{
|
||||
SelectedScriptKind = prvKind;
|
||||
ImportXkeyDetectedKind = prvKind.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.xkey.invalid");
|
||||
return;
|
||||
}
|
||||
PasswordInput = ConfirmPasswordInput = "";
|
||||
EncryptWallet = true;
|
||||
SetupStep = StepPassword;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromImportWif()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ImportWifInput))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.wif.required");
|
||||
return;
|
||||
}
|
||||
// Validate the WIF with an early parse.
|
||||
try
|
||||
{
|
||||
_ = new NBitcoin.BitcoinSecret(ImportWifInput.Trim(), PalladiumNetworks.For(Net));
|
||||
}
|
||||
catch
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.wif.invalid");
|
||||
return;
|
||||
}
|
||||
SetupStep = StepScriptType;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromScriptType()
|
||||
{
|
||||
PasswordInput = ConfirmPasswordInput = "";
|
||||
EncryptWallet = true;
|
||||
SetupStep = StepPassword;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardBack()
|
||||
{
|
||||
SetupStep = SetupStep switch
|
||||
{
|
||||
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
||||
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart,
|
||||
StepConfirmSeed => StepShowSeed,
|
||||
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
|
||||
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
|
||||
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
|
||||
_ => StepStart,
|
||||
};
|
||||
if (SetupStep == StepStart)
|
||||
RefreshSetupState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateOrRestore()
|
||||
{
|
||||
string? password;
|
||||
if (EncryptWallet)
|
||||
{
|
||||
if (string.IsNullOrEmpty(PasswordInput))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.password.required");
|
||||
return;
|
||||
}
|
||||
if (PasswordInput != ConfirmPasswordInput)
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.password.mismatch");
|
||||
return;
|
||||
}
|
||||
password = PasswordInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
password = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WalletDocument doc;
|
||||
IWalletAccount account;
|
||||
|
||||
switch (_wizardFlow)
|
||||
{
|
||||
case WizardFlowKind.ImportXkey:
|
||||
{
|
||||
var input = ImportXkeyInput.Trim();
|
||||
if (Slip132.TryDecodePublic(input, Profile, out _, out _))
|
||||
{
|
||||
var (d, a) = WalletLoader.NewFromXpub(input, Profile, SelectedScriptKind);
|
||||
(doc, account) = (d, a);
|
||||
}
|
||||
else
|
||||
{
|
||||
var (d, a) = WalletLoader.NewFromXprv(input, Profile, SelectedScriptKind);
|
||||
(doc, account) = (d, a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WizardFlowKind.ImportWif:
|
||||
{
|
||||
var wifLines = ImportWifInput.Split('\n',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var (d, a) = WalletLoader.NewFromWif(wifLines, SelectedScriptKind, Profile);
|
||||
(doc, account) = (d, a);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
var (d, a) = WalletLoader.NewFromMnemonic(
|
||||
MnemonicInput,
|
||||
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
|
||||
SelectedScriptKind,
|
||||
Profile);
|
||||
(doc, account) = (d, a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var path = WalletPathFromName(WalletNameInput);
|
||||
if (WalletStore.Exists(path))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.wallet.exists");
|
||||
return;
|
||||
}
|
||||
WalletStore.Save(doc, path, password);
|
||||
var newLock = WalletLock.TryAcquire(path);
|
||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
||||
OpenLoaded(doc, account, path, password, newLock);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the file path from the name entered by the user.
|
||||
/// If the name is empty, generates an automatic name (default, wallet-2, …).
|
||||
/// Removes characters that are invalid for the filesystem.
|
||||
/// </summary>
|
||||
private string WalletPathFromName(string name)
|
||||
{
|
||||
var clean = string.IsNullOrWhiteSpace(name)
|
||||
? ""
|
||||
: string.Concat(name.Trim()
|
||||
.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c))
|
||||
.Trim('.');
|
||||
|
||||
if (string.IsNullOrEmpty(clean))
|
||||
{
|
||||
// No name provided → auto-generated name
|
||||
var def = AppPaths.DefaultWalletPath(Net);
|
||||
for (var n = 2; WalletStore.Exists(def); n++)
|
||||
def = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
||||
return def;
|
||||
}
|
||||
|
||||
return Path.Combine(AppPaths.WalletsDir(Net), $"{clean}.wallet.json");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenExisting()
|
||||
{
|
||||
var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net);
|
||||
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
|
||||
|
||||
var newLock = WalletLock.TryAcquire(path);
|
||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
||||
|
||||
try
|
||||
{
|
||||
var doc = WalletStore.Load(path, password);
|
||||
_pendingOpenPath = null;
|
||||
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password, newLock);
|
||||
}
|
||||
catch (WrongPasswordException)
|
||||
{
|
||||
newLock.Dispose();
|
||||
StatusMessage = Loc.Tr("msg.wrongpassword");
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
newLock.Dispose();
|
||||
StatusMessage = Loc.Tr("msg.wallet.noaccess");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
newLock.Dispose();
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NewWallet()
|
||||
{
|
||||
if (IsWalletOpen)
|
||||
CloseWallet();
|
||||
_pendingOpenPath = null;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
private void OpenLoaded(
|
||||
WalletDocument doc, IWalletAccount account, string path, string? password, WalletLock walletLock)
|
||||
{
|
||||
_walletLock?.Dispose();
|
||||
_walletLock = walletLock;
|
||||
|
||||
_doc = doc;
|
||||
_account = account;
|
||||
_walletPath = path;
|
||||
_password = password;
|
||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
|
||||
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
|
||||
SetupStep = StepStart;
|
||||
|
||||
var walletKindTag = account switch
|
||||
{
|
||||
ImportedKeyAccount => " · imported",
|
||||
{ IsWatchOnly: true } => " · watch-only",
|
||||
_ => ""
|
||||
};
|
||||
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
|
||||
NetworkInfo = $"{doc.Network} · {doc.ScriptKind}{pathTag}{walletKindTag}";
|
||||
LoadContacts();
|
||||
ApplyCache(doc.Cache);
|
||||
IsWalletOpen = true;
|
||||
StatusMessage = Loc.Tr("msg.opened");
|
||||
_autoReconnect = true; // keepalive retries the connection even if the first attempt fails
|
||||
_syncFailed = true; // force the first automatic sync
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -18,110 +16,95 @@ using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>Riga dello storico transazioni per la vista.</summary>
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
||||
/// <summary>Transaction history row for the view.</summary>
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
|
||||
|
||||
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary>
|
||||
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx);
|
||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||
public sealed record AddressRow(
|
||||
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
|
||||
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
|
||||
{
|
||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||
}
|
||||
|
||||
/// <summary>Full address data passed to the address detail overlay.</summary>
|
||||
public sealed record AddressInfo(
|
||||
Loc Loc,
|
||||
string Address, string DerivPath, string PubKey, string PrivKey)
|
||||
{
|
||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||
}
|
||||
|
||||
/// <summary>Address book contact: name + blockchain address.</summary>
|
||||
public sealed record ContactEntry(string Name, string Address);
|
||||
|
||||
/// <summary>Wallet list entry: display name + full file path.</summary>
|
||||
public sealed record WalletFileEntry(string Name, string Path);
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
|
||||
/// pannello di setup (crea/ripristina/apri) e pannello wallet
|
||||
/// (saldo, ricevi, storico, invia, server).
|
||||
/// Single application ViewModel (wizard + dashboard). Split into partial files
|
||||
/// by area: Wizard, Settings, Sync, Send, Contacts, Receive.
|
||||
/// </summary>
|
||||
public partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
// ---- wallet session state ----
|
||||
private WalletDocument? _doc;
|
||||
private HdAccount? _account;
|
||||
private IWalletAccount? _account;
|
||||
private string? _walletPath;
|
||||
private string? _password;
|
||||
private WalletLock? _walletLock;
|
||||
|
||||
// ---- network ----
|
||||
private ElectrumClient? _client;
|
||||
private WalletSynchronizer? _synchronizer;
|
||||
private IReadOnlyDictionary<string, Transaction>? _lastTransactions;
|
||||
private BuiltTransaction? _pendingSend;
|
||||
|
||||
/// <summary>Notifica arrivata durante una sync: si risincronizza appena finita.</summary>
|
||||
private bool _resyncRequested;
|
||||
|
||||
/// <summary>Configurazione globale (§8): lingua e unità.</summary>
|
||||
private AppConfig _config = AppConfig.Load();
|
||||
// ---- send ----
|
||||
private BuiltTransaction? _pendingSend;
|
||||
|
||||
/// <summary>Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua
|
||||
/// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}.</summary>
|
||||
// ---- transaction detail ----
|
||||
private CancellationTokenSource? _txDetailsCts;
|
||||
|
||||
// ---- configuration and localisation ----
|
||||
private AppConfig _config = AppConfig.Load();
|
||||
private Loc _loc = Loc.Instance;
|
||||
public Loc Loc => _loc;
|
||||
|
||||
/// <summary>Unità corrente per il campo importo del pannello Invia.</summary>
|
||||
public string UnitLabel => _config.Unit;
|
||||
|
||||
public AppConfig CurrentConfig => _config;
|
||||
|
||||
// Spunte del menu Impostazioni (ToggleType Radio).
|
||||
public bool IsLangIt => _config.Language == "it";
|
||||
public bool IsLangEn => _config.Language == "en";
|
||||
public bool IsLangEs => _config.Language == "es";
|
||||
public bool IsLangFr => _config.Language == "fr";
|
||||
public bool IsLangPt => _config.Language == "pt";
|
||||
public bool IsLangDe => _config.Language == "de";
|
||||
public bool IsUnitPlm => _config.Unit == "PLM";
|
||||
public bool IsUnitMilli => _config.Unit == "mPLM";
|
||||
public bool IsUnitMicro => _config.Unit == "µPLM";
|
||||
public bool IsUnitSat => _config.Unit == "sat";
|
||||
|
||||
[RelayCommand]
|
||||
private void SetLanguage(string language)
|
||||
{
|
||||
_config.Language = language;
|
||||
ApplySettings(_config);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SetUnit(string unit)
|
||||
{
|
||||
_config.Unit = unit;
|
||||
ApplySettings(_config);
|
||||
}
|
||||
|
||||
/// <summary>Applica e persiste le impostazioni (§8).</summary>
|
||||
public void ApplySettings(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_config.Save();
|
||||
_loc = Loc.SwitchTo(config.Language);
|
||||
OnPropertyChanged(nameof(Loc));
|
||||
OnPropertyChanged(nameof(UnitLabel));
|
||||
OnPropertyChanged(nameof(IsLangIt));
|
||||
OnPropertyChanged(nameof(IsLangEn));
|
||||
OnPropertyChanged(nameof(IsLangEs));
|
||||
OnPropertyChanged(nameof(IsLangFr));
|
||||
OnPropertyChanged(nameof(IsLangPt));
|
||||
OnPropertyChanged(nameof(IsLangDe));
|
||||
OnPropertyChanged(nameof(IsUnitPlm));
|
||||
OnPropertyChanged(nameof(IsUnitMilli));
|
||||
OnPropertyChanged(nameof(IsUnitMicro));
|
||||
OnPropertyChanged(nameof(IsUnitSat));
|
||||
ApplyCache(_doc?.Cache);
|
||||
StatusMessage = Loc.Tr("msg.settings.saved");
|
||||
}
|
||||
|
||||
/// <summary>Formatta un importo nell'unità scelta nelle impostazioni.</summary>
|
||||
private string Fmt(long sats, bool withLabel = true) =>
|
||||
CoinAmount.FormatIn(sats, _config.Unit, withLabel);
|
||||
|
||||
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
|
||||
// ---- wizard ----
|
||||
private string? _pendingOpenPath;
|
||||
|
||||
/// <summary>Dopo la prima connessione riuscita il timer riconnette da solo.</summary>
|
||||
// ---- keep-alive ----
|
||||
private bool _autoReconnect;
|
||||
|
||||
private bool _syncFailed;
|
||||
private readonly DispatcherTimer _keepAliveTimer;
|
||||
|
||||
// ---- selezione rete e stato pannelli ----
|
||||
// ---- server UI sync ----
|
||||
private bool _syncingServerFields;
|
||||
|
||||
public string[] Networks { get; } = ["mainnet", "testnet", "regtest"];
|
||||
// ---- app properties ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string selectedNetwork = "mainnet";
|
||||
public static string AppVersion =>
|
||||
typeof(MainWindowViewModel).Assembly.GetName().Version is { } v
|
||||
? $"{v.Major}.{v.Minor}.{v.Build}"
|
||||
: "";
|
||||
|
||||
public string WindowTitle => $"Palladium Wallet {AppVersion}";
|
||||
|
||||
/// <summary>True on desktop; false on Android/iOS — hides filesystem-dependent features.</summary>
|
||||
public bool IsDesktop => !OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS();
|
||||
|
||||
public bool IsMobile => !IsDesktop;
|
||||
|
||||
// Tab bar sizing: compact on mobile so all 5 tabs fit in one row.
|
||||
public double TabIconSize => IsMobile ? 20 : 24;
|
||||
public double TabFontSize => IsMobile ? 10 : 13;
|
||||
public double TabSpacing => IsMobile ? 4 : 4;
|
||||
|
||||
public string UnitLabel => _config.Unit;
|
||||
public AppConfig CurrentConfig => _config;
|
||||
|
||||
// ---- panel state ----
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsSetupVisible))]
|
||||
@@ -129,139 +112,69 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
|
||||
public bool IsSetupVisible => !IsWalletOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool walletFileExists;
|
||||
|
||||
[ObservableProperty]
|
||||
private string statusMessage = "";
|
||||
|
||||
// ---- wizard di setup (§15): un passo alla volta ----
|
||||
|
||||
public const string StepStart = "start";
|
||||
public const string StepOpen = "open";
|
||||
public const string StepShowSeed = "show-seed";
|
||||
public const string StepConfirmSeed = "confirm-seed";
|
||||
public const string StepWords = "words";
|
||||
public const string StepPassphrase = "passphrase";
|
||||
public const string StepPassword = "password";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepStart))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepWords))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassphrase))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
||||
private string setupStep = StepStart;
|
||||
private int selectedTabIndex;
|
||||
|
||||
public bool IsStepStart => SetupStep == StepStart;
|
||||
public bool IsStepOpen => SetupStep == StepOpen;
|
||||
public bool IsStepShowSeed => SetupStep == StepShowSeed;
|
||||
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
|
||||
public bool IsStepWords => SetupStep == StepWords;
|
||||
public bool IsStepPassphrase => SetupStep == StepPassphrase;
|
||||
public bool IsStepPassword => SetupStep == StepPassword;
|
||||
[RelayCommand]
|
||||
private void SelectTab(string index) => SelectedTabIndex = int.Parse(index);
|
||||
|
||||
/// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary>
|
||||
private bool _isRestoreFlow;
|
||||
|
||||
[ObservableProperty]
|
||||
private string mnemonicInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string confirmMnemonicInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string passphraseInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string passwordInput = "";
|
||||
|
||||
// ---- pannello wallet ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string balanceText = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
private string unconfirmedText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string receiveAddress = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string serverInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool useSsl;
|
||||
|
||||
[ObservableProperty]
|
||||
private string connectionStatus = Loc.Tr("conn.none");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSyncing;
|
||||
|
||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private KnownServer? selectedKnownServer;
|
||||
// ---- dashboard collections ----
|
||||
|
||||
public ObservableCollection<HistoryRow> History { get; } = [];
|
||||
|
||||
public ObservableCollection<AddressRow> Addresses { get; } = [];
|
||||
|
||||
// ---- pannello invia ----
|
||||
// ---- helpers ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendTo = "";
|
||||
private NetKind Net => NetKind.Mainnet;
|
||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendAmount = "";
|
||||
private string Fmt(long sats, bool withLabel = true) =>
|
||||
CoinAmount.FormatIn(sats, _config.Unit, withLabel);
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendFeeRate = "1";
|
||||
private string KeyWif(bool isChange, int index)
|
||||
{
|
||||
if (_account is null or { IsWatchOnly: true }) return "";
|
||||
try
|
||||
{
|
||||
return _account.GetPrivateKey(isChange, index)
|
||||
?.GetWif(PalladiumNetworks.For(Net)).ToString() ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool sendAll;
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendPreview = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasPendingSend;
|
||||
// ---- constructor ----
|
||||
|
||||
public MainWindowViewModel()
|
||||
{
|
||||
_loc = Loc.SwitchTo(_config.Language);
|
||||
RefreshSetupState();
|
||||
// Aggiornamenti continui (§9): ping periodico per tenere viva la
|
||||
// connessione e accorgersi subito della caduta; se cade, riconnette
|
||||
// e risincronizza da solo. Le notifiche push restano la via principale.
|
||||
_keepAliveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(20) };
|
||||
if (!AppPaths.IsDataLocationConfigured())
|
||||
SetupStep = StepDataLocation;
|
||||
else
|
||||
RefreshSetupState();
|
||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||
_keepAliveTimer.Start();
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
private async Task KeepAliveTickAsync()
|
||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||
{
|
||||
if (!IsWalletOpen || IsSyncing)
|
||||
if (IsSyncing)
|
||||
return;
|
||||
if (_client is { IsConnected: true })
|
||||
{
|
||||
try
|
||||
// If the wallet is open and the last sync failed, retry automatically.
|
||||
if (_syncFailed && _account is not null)
|
||||
{
|
||||
await _client.PingAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// La caduta viene gestita dall'evento Disconnected.
|
||||
await ConnectAndSync();
|
||||
return;
|
||||
}
|
||||
try { await _client.PingAsync(); }
|
||||
catch { }
|
||||
}
|
||||
else if (_autoReconnect)
|
||||
{
|
||||
@@ -270,506 +183,32 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private NetKind Net => Enum.Parse<NetKind>(SelectedNetwork, ignoreCase: true);
|
||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
||||
|
||||
partial void OnSelectedNetworkChanged(string value) => RefreshSetupState();
|
||||
|
||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
||||
|
||||
private void RefreshSetupState()
|
||||
{
|
||||
SetupStep = StepStart;
|
||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
|
||||
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
|
||||
StatusMessage = WalletFileExists
|
||||
? Loc.Tr("msg.welcome.existing")
|
||||
: Loc.Tr("msg.welcome.new");
|
||||
RefreshServers();
|
||||
}
|
||||
|
||||
private void RefreshServers()
|
||||
{
|
||||
KnownServers.Clear();
|
||||
foreach (var server in Registry.All)
|
||||
KnownServers.Add(server);
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault();
|
||||
if (SelectedKnownServer is null)
|
||||
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}";
|
||||
}
|
||||
|
||||
partial void OnSelectedKnownServerChanged(KnownServer? value)
|
||||
{
|
||||
if (value is not null)
|
||||
ServerInput = $"{value.Host}:{value.PortFor(UseSsl)}";
|
||||
}
|
||||
|
||||
partial void OnUseSslChanged(bool value)
|
||||
{
|
||||
if (SelectedKnownServer is { } server)
|
||||
ServerInput = $"{server.Host}:{server.PortFor(value)}";
|
||||
}
|
||||
|
||||
// ---------- comandi del wizard (§15): un passo alla volta ----------
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartOpen()
|
||||
{
|
||||
PasswordInput = "";
|
||||
SetupStep = StepOpen;
|
||||
StatusMessage = Loc.Tr("msg.open.password");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartNew()
|
||||
{
|
||||
_isRestoreFlow = false;
|
||||
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
|
||||
SetupStep = StepShowSeed;
|
||||
StatusMessage = Loc.Tr("msg.seed.write");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartRestore()
|
||||
{
|
||||
_isRestoreFlow = true;
|
||||
MnemonicInput = "";
|
||||
SetupStep = StepWords;
|
||||
StatusMessage = Loc.Tr("msg.words.enter");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromShowSeed()
|
||||
{
|
||||
ConfirmMnemonicInput = "";
|
||||
SetupStep = StepConfirmSeed;
|
||||
StatusMessage = Loc.Tr("msg.seed.retype");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromConfirmSeed()
|
||||
{
|
||||
var normalized = string.Join(' ',
|
||||
ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.seed.mismatch");
|
||||
return;
|
||||
}
|
||||
GoToPassphraseStep();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromWords()
|
||||
{
|
||||
if (!Bip39.TryParse(MnemonicInput, out _))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.words.invalid");
|
||||
return;
|
||||
}
|
||||
GoToPassphraseStep();
|
||||
}
|
||||
|
||||
private void GoToPassphraseStep()
|
||||
{
|
||||
PassphraseInput = "";
|
||||
SetupStep = StepPassphrase;
|
||||
StatusMessage = Loc.Tr("msg.passphrase.info");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromPassphrase()
|
||||
{
|
||||
PasswordInput = "";
|
||||
SetupStep = StepPassword;
|
||||
StatusMessage = Loc.Tr("msg.password.info");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardBack()
|
||||
{
|
||||
SetupStep = SetupStep switch
|
||||
{
|
||||
StepOpen or StepShowSeed or StepWords => StepStart,
|
||||
StepConfirmSeed => StepShowSeed,
|
||||
StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed,
|
||||
StepPassword => StepPassphrase,
|
||||
_ => StepStart,
|
||||
};
|
||||
if (SetupStep == StepStart)
|
||||
RefreshSetupState();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CreateOrRestore()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (doc, account) = WalletLoader.NewFromMnemonic(
|
||||
MnemonicInput,
|
||||
string.IsNullOrEmpty(PassphraseInput) ? null : PassphraseInput,
|
||||
ScriptKind.NativeSegwit,
|
||||
Profile);
|
||||
// Mai sovrascrivere un wallet esistente: si cerca il primo nome libero.
|
||||
var path = AppPaths.DefaultWalletPath(Net);
|
||||
for (var n = 2; WalletStore.Exists(path); n++)
|
||||
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
|
||||
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
|
||||
WalletStore.Save(doc, path, password);
|
||||
OpenLoaded(doc, account, path, password);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenExisting()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = _pendingOpenPath ?? AppPaths.DefaultWalletPath(Net);
|
||||
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
|
||||
var doc = WalletStore.Load(path, password);
|
||||
_pendingOpenPath = null;
|
||||
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password);
|
||||
}
|
||||
catch (WrongPasswordException)
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.wrongpassword");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apertura di un file wallet qualunque (menu File → Apri, multi-wallet §8).</summary>
|
||||
public void OpenFromPath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsWalletOpen)
|
||||
CloseWallet();
|
||||
var doc = WalletStore.Load(path);
|
||||
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null);
|
||||
}
|
||||
catch (WrongPasswordException)
|
||||
{
|
||||
// Cifrato: si chiede la password nel passo di apertura del wizard.
|
||||
if (IsWalletOpen)
|
||||
CloseWallet();
|
||||
_pendingOpenPath = path;
|
||||
WalletFileExists = true;
|
||||
PasswordInput = "";
|
||||
SetupStep = StepOpen;
|
||||
StatusMessage = $"Il wallet \"{Path.GetFileName(path)}\" è cifrato: inserisci la password e premi Apri.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Torna al pannello di setup per creare/ripristinare un altro wallet.</summary>
|
||||
[RelayCommand]
|
||||
private void NewWallet()
|
||||
{
|
||||
if (IsWalletOpen)
|
||||
CloseWallet();
|
||||
_pendingOpenPath = null;
|
||||
StatusMessage = Loc.Tr("msg.welcome.new");
|
||||
}
|
||||
|
||||
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
|
||||
{
|
||||
// La rete del wallet comanda (registry, pin TLS, indirizzi).
|
||||
SelectedNetwork = doc.Network;
|
||||
_doc = doc;
|
||||
_account = account;
|
||||
_walletPath = path;
|
||||
_password = password;
|
||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
|
||||
SetupStep = StepStart;
|
||||
|
||||
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
|
||||
+ (doc.IsWatchOnly ? " · watch-only" : "");
|
||||
ApplyCache(doc.Cache);
|
||||
IsWalletOpen = true;
|
||||
StatusMessage = Loc.Tr("msg.opened");
|
||||
// Come Electrum: ci si connette da soli al server selezionato,
|
||||
// senza aspettare un click.
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
|
||||
private void ApplyCache(SyncCache? cache)
|
||||
{
|
||||
if (_account is null)
|
||||
return;
|
||||
if (cache is null)
|
||||
{
|
||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||
UnconfirmedText = "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||
History.Clear();
|
||||
// Prima della sincronizzazione si mostrano i primi indirizzi derivati.
|
||||
Addresses.Clear();
|
||||
for (var i = 0; i < 10; i++)
|
||||
Addresses.Add(new AddressRow("ricezione", i,
|
||||
_account.GetReceiveAddress(i).ToString(), "—", "—"));
|
||||
return;
|
||||
}
|
||||
BalanceText = Fmt(cache.ConfirmedSats);
|
||||
// Saldo in attesa: somma delle tx in mempool (può essere negativo per
|
||||
// gli invii in uscita non ancora confermati). Non è spendibile finché
|
||||
// non conferma: la TransactionFactory usa solo UTXO confermati.
|
||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||
UnconfirmedText = pending != 0
|
||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||
History.Clear();
|
||||
foreach (var tx in cache.History)
|
||||
History.Add(new HistoryRow(
|
||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||
tx.Txid,
|
||||
tx.Verified ? "✓ SPV" : "—"));
|
||||
|
||||
Addresses.Clear();
|
||||
foreach (var a in cache.Addresses)
|
||||
Addresses.Add(new AddressRow(
|
||||
a.IsChange ? "change" : "ricezione",
|
||||
a.Index,
|
||||
a.Address,
|
||||
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
|
||||
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
|
||||
}
|
||||
|
||||
// ---------- comandi wallet ----------
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConnectAndSync()
|
||||
{
|
||||
if (_account is null || _doc is null)
|
||||
return;
|
||||
if (IsSyncing)
|
||||
{
|
||||
_resyncRequested = true;
|
||||
return;
|
||||
}
|
||||
IsSyncing = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
var (host, port) = ParseServer();
|
||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
|
||||
_client.NotificationReceived += OnServerNotification;
|
||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.disconnected");
|
||||
});
|
||||
IsConnected = true;
|
||||
_autoReconnect = true;
|
||||
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}";
|
||||
// Synchronizer per connessione: conserva la cache di tx e
|
||||
// prove verificate, così le risincronizzazioni sono incrementali.
|
||||
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
|
||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||
}
|
||||
|
||||
// Se durante la sync arrivano notifiche, si ripete subito: nessun
|
||||
// aggiornamento del server va perso (modello Electrum).
|
||||
do
|
||||
{
|
||||
_resyncRequested = false;
|
||||
var result = await _synchronizer!.SyncOnceAsync();
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
_doc.Cache = new SyncCache
|
||||
{
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
Utxos = [.. result.Utxos],
|
||||
Addresses = [.. result.AddressRows],
|
||||
};
|
||||
WalletStore.Save(_doc, _walletPath!, _password);
|
||||
ApplyCache(_doc.Cache);
|
||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||
} while (_resyncRequested);
|
||||
}
|
||||
catch (CertificatePinMismatchException ex)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.certchanged");
|
||||
StatusMessage = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsConnected = _client?.IsConnected == true;
|
||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error");
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scopre nuovi server dai peer annunciati dal server connesso (§9).</summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscoverServers()
|
||||
{
|
||||
if (_client is null || !_client.IsConnected)
|
||||
{
|
||||
StatusMessage = Loc.Tr("conn.none") + ".";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var added = await Registry.DiscoverAsync(_client);
|
||||
var selected = SelectedKnownServer;
|
||||
RefreshServers();
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||
?? KnownServers.FirstOrDefault();
|
||||
StatusMessage = added > 0
|
||||
? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})."
|
||||
: $"Nessun nuovo server annunciato (totale {KnownServers.Count}).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore nella scoperta peer: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
||||
{
|
||||
// Cambiamento su un nostro indirizzo o nuovo blocco: risincronizza.
|
||||
// Se una sync è già in corso, si accoda (il loop in ConnectAndSync la
|
||||
// ripete subito dopo): nessuna notifica viene persa.
|
||||
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (IsSyncing)
|
||||
_resyncRequested = true;
|
||||
else
|
||||
_ = ConnectAndSync();
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ResetCertificates()
|
||||
{
|
||||
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
|
||||
StatusMessage = Loc.Tr("msg.certreset");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PrepareSend()
|
||||
{
|
||||
if (_account is null || _doc?.Cache is null)
|
||||
{
|
||||
SendPreview = "Sincronizza prima di inviare.";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (_lastTransactions is null)
|
||||
{
|
||||
SendPreview = "Connettiti al server e sincronizza prima di inviare.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
|
||||
long amount = 0;
|
||||
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
||||
{
|
||||
SendPreview = "Importo non valido.";
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
|
||||
{
|
||||
SendPreview = "Fee rate non valido.";
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingSend = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, SendAll);
|
||||
|
||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
|
||||
HasPendingSend = _pendingSend.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
SendPreview = $"Errore: {ex.Message}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmSend()
|
||||
{
|
||||
if (_pendingSend is null || _client is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
|
||||
SendPreview = $"Trasmessa: {txid}";
|
||||
SendTo = SendAmount = "";
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendPreview = $"Errore broadcast: {ex.Message}";
|
||||
}
|
||||
}
|
||||
// ---- wallet lifecycle ----
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseWallet()
|
||||
{
|
||||
_walletLock?.Dispose();
|
||||
_walletLock = null;
|
||||
_ = _client?.DisposeAsync().AsTask();
|
||||
_client = null;
|
||||
_synchronizer = null;
|
||||
_autoReconnect = false;
|
||||
_resyncRequested = false;
|
||||
_syncFailed = false;
|
||||
_doc = null;
|
||||
_account = null;
|
||||
_lastTransactions = null;
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
History.Clear();
|
||||
Contacts.Clear();
|
||||
SelectedContactInList = null;
|
||||
SendToContact = null;
|
||||
SelectedAddressRow = null;
|
||||
IsWalletOpen = false;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
RefreshSetupState();
|
||||
}
|
||||
|
||||
private (string Host, int Port) ParseServer()
|
||||
{
|
||||
var parts = ServerInput.Trim().Split(':');
|
||||
var host = parts[0];
|
||||
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
|
||||
? p
|
||||
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
|
||||
return (host, port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// bool → brush: the wallet's own addresses ("our" inputs/outputs) are
|
||||
/// highlighted in green, the others use the default text color.
|
||||
/// </summary>
|
||||
public sealed class MineColorConverter : IValueConverter
|
||||
{
|
||||
public static readonly MineColorConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true ? Brushes.MediumSeaGreen : AvaloniaProperty.UnsetValue;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>Input/output row for the transaction detail tables.</summary>
|
||||
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the transaction detail view: takes a
|
||||
/// <see cref="TransactionDetails"/> (assembled from server data) and produces
|
||||
/// all strings already formatted and localised for the view. Read-only.
|
||||
/// </summary>
|
||||
public sealed class TransactionDetailsViewModel
|
||||
{
|
||||
private readonly string _unit;
|
||||
|
||||
public Loc Loc { get; }
|
||||
|
||||
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
|
||||
{
|
||||
Loc = loc;
|
||||
_unit = unit;
|
||||
|
||||
Txid = d.Txid;
|
||||
IsCoinbase = d.IsCoinbase;
|
||||
StatusText = BuildStatus(d, loc);
|
||||
DateText = d.BlockTime is { } t
|
||||
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
||||
: loc["tx.mempool"];
|
||||
|
||||
var counterparties = d.CounterpartyAddresses;
|
||||
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
|
||||
// Coinbase has no sender: coins are newly generated by mining.
|
||||
CounterpartyText = d.IsCoinbase
|
||||
? loc["tx.coinbase.newcoins"]
|
||||
: counterparties.Count > 0
|
||||
? string.Join(Environment.NewLine, counterparties)
|
||||
: "—";
|
||||
|
||||
// Debit (outflow to third parties) or Credit (net inflow on our outputs).
|
||||
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
|
||||
AmountText = d.IsIncoming
|
||||
? Signed(d.ReceivedSats)
|
||||
: Signed(-d.SentToOthersSats);
|
||||
|
||||
FeeText = d.FeeSats is { } fee
|
||||
? (d.IsIncoming ? Abs(fee) : Signed(-fee))
|
||||
: "—";
|
||||
|
||||
NetText = Signed(d.NetSats);
|
||||
|
||||
TotalSizeText = $"{d.TotalSize} byte";
|
||||
VirtualSizeText = $"{d.VirtualSize} byte";
|
||||
FeeRateText = d.FeeRateSatPerVb is { } r
|
||||
? r.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " sat/vB"
|
||||
: "—";
|
||||
VersionText = d.Version.ToString();
|
||||
LockTimeText = d.LockTime.ToString();
|
||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
||||
i.IsCoinbase
|
||||
? i.CoinbaseTag is { Length: > 0 } tag
|
||||
? $"{loc["tx.coinbase.newcoins"]} — {tag}"
|
||||
: loc["tx.coinbase.newcoins"]
|
||||
: i.Address ?? "—",
|
||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||
i.IsMine)));
|
||||
|
||||
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
||||
$"#{o.Index}",
|
||||
o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"),
|
||||
CoinAmount.FormatIn(o.AmountSats, unit),
|
||||
o.IsMine)));
|
||||
}
|
||||
|
||||
public string Txid { get; }
|
||||
public bool IsCoinbase { get; }
|
||||
public string StatusText { get; }
|
||||
public string DateText { get; }
|
||||
public string CounterpartyHeader { get; }
|
||||
public string CounterpartyText { get; }
|
||||
public string AmountHeader { get; }
|
||||
public string AmountText { get; }
|
||||
public string FeeText { get; }
|
||||
public string NetText { get; }
|
||||
public string TotalSizeText { get; }
|
||||
public string VirtualSizeText { get; }
|
||||
public string FeeRateText { get; }
|
||||
public string VersionText { get; }
|
||||
public string LockTimeText { get; }
|
||||
public string RbfText { get; }
|
||||
public ObservableCollection<TxIoRow> Inputs { get; }
|
||||
public ObservableCollection<TxIoRow> Outputs { get; }
|
||||
|
||||
private static string BuildStatus(TransactionDetails d, Loc loc)
|
||||
{
|
||||
if (d.Confirmations <= 0)
|
||||
return loc["tx.status.mempool"];
|
||||
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
|
||||
}
|
||||
|
||||
private string Signed(long sats)
|
||||
{
|
||||
var sign = sats > 0 ? "+" : sats < 0 ? "-" : "";
|
||||
return sign + CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
||||
}
|
||||
|
||||
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
|
||||
namespace PalladiumWallet.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// App root view, shared between desktop (hosted in <see cref="MainWindow"/>)
|
||||
/// and mobile (single-view root). All overlays are in-app so no separate windows
|
||||
/// are needed. Top-level APIs (file picker, clipboard) are reached via
|
||||
/// <see cref="TopLevel.GetTopLevel"/> because a UserControl does not expose them.
|
||||
/// </summary>
|
||||
public partial class MainView : UserControl
|
||||
{
|
||||
public MainView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
||||
if (lb.SelectedItem is not HistoryRow row) return;
|
||||
|
||||
// In-app overlay: appears immediately with a spinner; data arrives from
|
||||
// the server in the background. No top-level window (slow to open/close).
|
||||
_ = vm.ShowTransactionDetailsAsync(row.Txid);
|
||||
}
|
||||
|
||||
private void OnTxDetailsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.CloseTransactionDetailsCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void OnAddressListTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
|
||||
return;
|
||||
vm.ShowAddressInfo(row);
|
||||
}
|
||||
|
||||
private void OnAddressListPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(null).Properties.IsRightButtonPressed) return;
|
||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
||||
|
||||
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>();
|
||||
if (item is not { DataContext: AddressRow row }) return;
|
||||
|
||||
lb.SelectedItem = row;
|
||||
vm.ShowAddressInfo(row);
|
||||
}
|
||||
|
||||
// Close the address-detail overlay: click on the dark backdrop
|
||||
// (backdrop only, not the card itself) or press Esc.
|
||||
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.AddressInfo = null;
|
||||
}
|
||||
|
||||
private void OnServerOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsServerSettingsOpen = false;
|
||||
}
|
||||
|
||||
private async void OnChooseDataFolderClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm) return;
|
||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
|
||||
|
||||
var folders = await storage.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Cartella dati Palladium Wallet",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
||||
vm.ApplyDataLocation(path);
|
||||
}
|
||||
|
||||
private async void OnCopyReceiveAddressClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.ReceiveAddress))
|
||||
return;
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||
{
|
||||
await clipboard.SetTextAsync(vm.ReceiveAddress);
|
||||
vm.NotifyAddressCopied();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsServerSettingsOpen = true;
|
||||
}
|
||||
|
||||
private void OnWalletInfoOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.CloseWalletInfoCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsSettingsOpen = false;
|
||||
}
|
||||
|
||||
private void OnHelpOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsHelpOpen = false;
|
||||
}
|
||||
|
||||
private void OnUpdateAvailableOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, sender)) return;
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
vm.IsUpdateAvailableOpen = false;
|
||||
}
|
||||
|
||||
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||
}
|
||||
|
||||
private async void OnOpenUserGuideClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
const string userGuideUrl = "https://github.com/davide3011/PalladiumWallet/blob/main/USERGUIDE.md";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(userGuideUrl));
|
||||
}
|
||||
|
||||
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(vm.UpdateReleaseUrl));
|
||||
vm.IsUpdateAvailableOpen = false;
|
||||
}
|
||||
|
||||
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
|
||||
{
|
||||
if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.IsPrivKeyPromptOpen) { vm.CancelPrivKeyPromptCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.AddressInfo is not null) { vm.CloseAddressInfoCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsUpdateAvailableOpen) { vm.IsUpdateAvailableOpen = false; e.Handled = true; return; }
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
}
|
||||
@@ -1,314 +1,22 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PalladiumWallet.App.ViewModels"
|
||||
xmlns:views="using:PalladiumWallet.App.Views"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
|
||||
mc:Ignorable="d" d:DesignWidth="1020" d:DesignHeight="680"
|
||||
x:Class="PalladiumWallet.App.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Width="900" Height="620"
|
||||
Title="Palladium Wallet">
|
||||
Icon="/Assets/logo.ico"
|
||||
Width="1020" Height="680"
|
||||
MinWidth="1020" MinHeight="680"
|
||||
MaxWidth="1020" MaxHeight="680"
|
||||
CanResize="False"
|
||||
Title="{Binding WindowTitle}">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ MENU (stile Electrum) ============ -->
|
||||
<Menu Grid.Row="0">
|
||||
<MenuItem Header="{Binding Loc[menu.file]}">
|
||||
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
|
||||
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
|
||||
IsEnabled="{Binding IsWalletOpen}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{Binding Loc[menu.net]}">
|
||||
<MenuItem Header="{Binding Loc[menu.net.discover]}" Command="{Binding DiscoverServersCommand}"/>
|
||||
<MenuItem Header="{Binding Loc[menu.net.resetcerts]}" Command="{Binding ResetCertificatesCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{Binding Loc[menu.settings]}">
|
||||
<MenuItem Header="{Binding Loc[settings.language]}">
|
||||
<MenuItem Header="Italiano" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangIt, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
|
||||
<MenuItem Header="English" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangEn, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
|
||||
<MenuItem Header="Español" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangEs, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="es"/>
|
||||
<MenuItem Header="Français" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangFr, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="fr"/>
|
||||
<MenuItem Header="Português" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangPt, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="pt"/>
|
||||
<MenuItem Header="Deutsch" ToggleType="Radio"
|
||||
IsChecked="{Binding IsLangDe, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="{Binding Loc[settings.unit.short]}">
|
||||
<MenuItem Header="PLM" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
|
||||
<MenuItem Header="mPLM" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
|
||||
<MenuItem Header="µPLM" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
|
||||
<MenuItem Header="sat" ToggleType="Radio"
|
||||
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
|
||||
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
<!-- Passo 1: scelta iniziale -->
|
||||
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding Loc[wiz.net]}" VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding Networks}"
|
||||
SelectedItem="{Binding SelectedNetwork}" MinWidth="140"/>
|
||||
</StackPanel>
|
||||
<Button Content="{Binding Loc[wiz.open.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
IsVisible="{Binding WalletFileExists}"
|
||||
Command="{Binding WizardStartOpenCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.new.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding WizardStartNewCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.restore.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding WizardStartRestoreCommand}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: password del wallet esistente -->
|
||||
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.open.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.open.placeholder]}"
|
||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.open.ok]}" Classes="accent"
|
||||
Command="{Binding OpenExistingCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: mostra il nuovo seed -->
|
||||
<StackPanel IsVisible="{Binding IsStepShowSeed}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.seed.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<Border BorderBrush="{DynamicResource SystemAccentColor}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Text="{Binding MnemonicInput}"
|
||||
FontFamily="monospace" FontSize="16" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
<TextBlock Foreground="Orange" TextWrapping="Wrap"
|
||||
Text="{Binding Loc[wiz.seed.warning]}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.seed.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromShowSeedCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: conferma del seed -->
|
||||
<StackPanel IsVisible="{Binding IsStepConfirmSeed}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.confirm.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.confirm.placeholder]}"
|
||||
AcceptsReturn="False" Text="{Binding ConfirmMnemonicInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromConfirmSeedCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: inserimento seed (ripristino) -->
|
||||
<StackPanel IsVisible="{Binding IsStepWords}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.words.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.words.placeholder]}"
|
||||
AcceptsReturn="False" Text="{Binding MnemonicInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromWordsCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo: passphrase opzionale -->
|
||||
<StackPanel IsVisible="{Binding IsStepPassphrase}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.passphrase.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.passphrase.placeholder]}"
|
||||
Text="{Binding PassphraseInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromPassphraseCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Passo finale: password del file -->
|
||||
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
|
||||
PasswordChar="●" Text="{Binding PasswordInput}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
|
||||
Command="{Binding CreateOrRestoreCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ PANNELLO WALLET ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
|
||||
|
||||
<!-- Testata: saldo + rete + chiudi -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
|
||||
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="{Binding Loc[wallet.close]}" VerticalAlignment="Top"
|
||||
Command="{Binding CloseWalletCommand}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Server -->
|
||||
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10"
|
||||
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[wallet.server]}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<ComboBox Grid.Row="0" Grid.Column="1"
|
||||
ItemsSource="{Binding KnownServers}"
|
||||
SelectedItem="{Binding SelectedKnownServer}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
|
||||
IsChecked="{Binding UseSsl}" Margin="8,0"/>
|
||||
<Button Grid.Row="0" Grid.Column="3" Content="{Binding Loc[wallet.connect]}"
|
||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
|
||||
Spacing="8" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding ServerInput}" MinWidth="220"
|
||||
PlaceholderText="{Binding Loc[wallet.manual]}"/>
|
||||
<Button Content="{Binding Loc[wallet.discover]}"
|
||||
Command="{Binding DiscoverServersCommand}"/>
|
||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||
Command="{Binding ResetCertificatesCommand}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
|
||||
Orientation="Horizontal" Spacing="6" Margin="8,8,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<Ellipse Width="10" Height="10" Fill="LimeGreen"
|
||||
IsVisible="{Binding IsConnected}"/>
|
||||
<Ellipse Width="10" Height="10" Fill="IndianRed"
|
||||
IsVisible="{Binding !IsConnected}"/>
|
||||
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Tab: Ricevi / Storico / Indirizzi / Invia -->
|
||||
<TabControl Grid.Row="2">
|
||||
<TabItem Header="{Binding Loc[tab.receive]}">
|
||||
<StackPanel Spacing="10" Margin="8">
|
||||
<TextBlock Text="{Binding Loc[receive.next]}"/>
|
||||
<SelectableTextBlock Text="{Binding ReceiveAddress}"
|
||||
FontFamily="monospace" FontSize="16"/>
|
||||
<TextBlock Text="{Binding Loc[receive.hint]}"
|
||||
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{Binding Loc[tab.history]}">
|
||||
<ListBox ItemsSource="{Binding History}" Margin="4">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HistoryRow">
|
||||
<Grid ColumnDefinitions="90,160,*,70">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||
FontFamily="monospace" FontSize="12"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{Binding Loc[tab.addresses]}">
|
||||
<Grid RowDefinitions="Auto,*" Margin="4">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
|
||||
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
|
||||
</Grid>
|
||||
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AddressRow">
|
||||
<Grid ColumnDefinitions="90,60,*,140,60">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
|
||||
<SelectableTextBlock Grid.Column="2" Text="{Binding Indirizzo}"
|
||||
FontFamily="monospace" FontSize="13"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{Binding Loc[tab.send]}">
|
||||
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
|
||||
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
|
||||
FontFamily="monospace"/>
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[send.amount]}"
|
||||
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
|
||||
VerticalAlignment="Center" Margin="6,0" Foreground="Gray"/>
|
||||
<CheckBox Grid.Column="2" Content="{Binding Loc[send.all]}" Margin="10,0"
|
||||
IsChecked="{Binding SendAll}"/>
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center"/>
|
||||
<TextBox Text="{Binding SendFeeRate}" MinWidth="60"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[send.prepare]}" Command="{Binding PrepareSendCommand}"/>
|
||||
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||
Command="{Binding ConfirmSendCommand}"
|
||||
IsEnabled="{Binding HasPendingSend}"/>
|
||||
</StackPanel>
|
||||
<SelectableTextBlock Text="{Binding SendPreview}" TextWrapping="Wrap"
|
||||
FontSize="13"/>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
|
||||
<!-- Barra di stato -->
|
||||
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
Padding="10,6">
|
||||
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<views:MainView/>
|
||||
</Window>
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
|
||||
namespace PalladiumWallet.App.Views;
|
||||
|
||||
/// <summary>Finestra desktop: ospita <see cref="MainView"/> (la UI condivisa con mobile).</summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
|
||||
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm)
|
||||
return;
|
||||
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Apri file wallet",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
||||
vm.OpenFromPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -6,8 +6,8 @@ using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
// CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di
|
||||
// comando, per scripting, test e confronto col wallet di riferimento.
|
||||
// Wallet CLI (blueprint §13): Core use cases exposed via command line,
|
||||
// for scripting, testing and comparison against the reference wallet.
|
||||
|
||||
try
|
||||
{
|
||||
@@ -29,7 +29,7 @@ try
|
||||
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
|
||||
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
|
||||
{
|
||||
Console.Error.WriteLine($"Errore: {ex.Message}");
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ static int Addresses(string words, string[] o)
|
||||
if (account is null)
|
||||
return 1;
|
||||
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
|
||||
Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}");
|
||||
Console.WriteLine($"network: {account.Profile.NetName} kind: {account.Kind} path: m/{account.AccountPath}");
|
||||
Console.WriteLine($"account: {account.ToSlip132()}");
|
||||
for (var i = 0; i < count; i++)
|
||||
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
|
||||
@@ -58,7 +58,7 @@ static int Addresses(string words, string[] o)
|
||||
static int Create(string[] o)
|
||||
{
|
||||
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
|
||||
Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):");
|
||||
Console.WriteLine("New mnemonic (write it on paper, it is NOT shown again):");
|
||||
Console.WriteLine($" {mnemonic}");
|
||||
return SaveWallet(mnemonic.ToString(), o);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ static int Restore(string words, string[] o)
|
||||
{
|
||||
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 SaveWallet(words.Trim(), o);
|
||||
@@ -78,7 +78,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
||||
var profile = Profile(o);
|
||||
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
|
||||
{
|
||||
Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete.");
|
||||
Console.Error.WriteLine("Extended key not recognised for this network.");
|
||||
return 1;
|
||||
}
|
||||
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
||||
@@ -91,7 +91,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
||||
};
|
||||
var path = WalletPath(o, profile);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -99,26 +99,28 @@ static int Info(string[] o)
|
||||
{
|
||||
var (doc, account, path) = OpenWallet(o);
|
||||
Console.WriteLine($"file: {path}");
|
||||
Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
|
||||
Console.WriteLine($"network: {doc.Network} kind: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
|
||||
Console.WriteLine($"path: m/{doc.AccountPath}");
|
||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||
if (doc.Cache is { } cache)
|
||||
{
|
||||
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
|
||||
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||
Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
|
||||
+ (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
||||
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
||||
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||
}
|
||||
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)
|
||||
{
|
||||
Console.WriteLine("indirizzi:");
|
||||
Console.WriteLine("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)");
|
||||
}
|
||||
return 0;
|
||||
@@ -131,28 +133,45 @@ static async Task<int> Sync(string[] o)
|
||||
|
||||
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
|
||||
sync.Progress += msg => Console.WriteLine($" {msg}");
|
||||
var net = PalladiumNetworks.For(account.Profile.Kind);
|
||||
sync.PreloadCaches(
|
||||
doc.Cache?.RawTxHex ?? [],
|
||||
doc.Cache?.VerifiedAt ?? [],
|
||||
doc.Cache?.BlockHeaders,
|
||||
doc.Cache?.NextReceiveIndex ?? 0,
|
||||
doc.Cache?.NextChangeIndex ?? 0,
|
||||
net);
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
|
||||
doc.Cache = new SyncCache
|
||||
{
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
ImmatureSats = result.ImmatureSats,
|
||||
PendingVerificationSats = result.PendingVerificationSats,
|
||||
SpendableSats = result.SpendableSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
Utxos = [.. result.Utxos],
|
||||
Addresses = [.. result.AddressRows],
|
||||
RawTxHex = rawHex,
|
||||
VerifiedAt = verifiedAt,
|
||||
BlockHeaders = blockHeaders,
|
||||
};
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
|
||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
||||
Console.WriteLine($"Storico ({result.History.Count}):");
|
||||
Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
||||
+ (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
||||
Console.WriteLine($"History ({result.History.Count}):");
|
||||
foreach (var tx in result.History)
|
||||
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
|
||||
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
|
||||
(tx.Verified ? "" : " (non verificata)"));
|
||||
(tx.Verified ? "" : " (unverified)"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -161,18 +180,18 @@ static async Task<int> Send(string[] o)
|
||||
var (doc, account, path) = OpenWallet(o);
|
||||
if (doc.Cache is null)
|
||||
{
|
||||
Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'.");
|
||||
Console.Error.WriteLine("Wallet never synchronized: run 'sync' first.");
|
||||
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 sendAll = o.Contains("--all");
|
||||
long amount = 0;
|
||||
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
|
||||
throw new WalletSpendException("--amount mancante o non valido (oppure usa --all).");
|
||||
throw new WalletSpendException("--amount missing or invalid (or use --all).");
|
||||
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);
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var transactions = new Dictionary<string, Transaction>();
|
||||
@@ -181,7 +200,7 @@ static async Task<int> Send(string[] o)
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
||||
doc.Cache.NextChangeIndex, sendAll);
|
||||
doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll);
|
||||
|
||||
Console.WriteLine($"txid: {built.Txid}");
|
||||
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
||||
@@ -189,20 +208,20 @@ static async Task<int> Send(string[] o)
|
||||
|
||||
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());
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!o.Contains("--broadcast"))
|
||||
{
|
||||
Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):");
|
||||
Console.WriteLine("Signed transaction (NOT broadcast, add --broadcast):");
|
||||
Console.WriteLine(built.ToHex());
|
||||
return 0;
|
||||
}
|
||||
|
||||
var txid2 = await client.BroadcastAsync(built.ToHex());
|
||||
Console.WriteLine($"Trasmessa: {txid2}");
|
||||
Console.WriteLine($"Broadcast: {txid2}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -210,7 +229,7 @@ static int ResetCerts(string[] o)
|
||||
{
|
||||
var profile = Profile(o);
|
||||
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
|
||||
Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati.");
|
||||
Console.WriteLine($"Saved SSL certificates for {profile.NetName} cleared.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -223,16 +242,16 @@ static async Task<int> Servers(string[] o)
|
||||
{
|
||||
await using var client = await Connect(o, profile);
|
||||
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)
|
||||
Console.WriteLine($" {server}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ----- helper comuni -----
|
||||
// ----- shared helpers -----
|
||||
|
||||
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
|
||||
{
|
||||
@@ -255,7 +274,7 @@ static HdAccount? AccountFromWords(string words, string[] o)
|
||||
{
|
||||
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;
|
||||
}
|
||||
var profile = Profile(o);
|
||||
@@ -274,19 +293,19 @@ static int SaveWallet(string words, string[] o)
|
||||
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
|
||||
var password = Opt(o, "--password");
|
||||
if (string.IsNullOrEmpty(password))
|
||||
Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17).");
|
||||
Console.WriteLine("WARNING: no --password, the seed will be stored in plaintext on disk (§17).");
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, password);
|
||||
Console.WriteLine($"Wallet salvato in {path}");
|
||||
Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}");
|
||||
Console.WriteLine($"Wallet saved to {path}");
|
||||
Console.WriteLine($"First address: {account.GetReceiveAddress(0)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static (WalletDocument, HdAccount, string) OpenWallet(string[] o)
|
||||
static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
||||
{
|
||||
var path = WalletPath(o, Profile(o));
|
||||
if (!WalletStore.Exists(path))
|
||||
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"));
|
||||
return (doc, WalletLoader.ToAccount(doc), path);
|
||||
}
|
||||
@@ -305,15 +324,15 @@ static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
|
||||
}
|
||||
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
|
||||
?? 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;
|
||||
port = known.PortFor(useSsl);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -331,20 +350,20 @@ static int Usage()
|
||||
Wallet:
|
||||
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
|
||||
[--passphrase W] [--password P] [--file PATH]
|
||||
restore "<mnemonica>" [stesse opzioni di create] [--path m/...]
|
||||
restore-xpub <xpub slip132> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||
restore "<mnemonic>" [same options as create] [--path m/...]
|
||||
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||
info [--net ...] [--password P] [--file PATH]
|
||||
|
||||
Rete (server di indicizzazione; senza --server usa il primo server noto):
|
||||
sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH]
|
||||
send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB]
|
||||
[--server host[:porta]] [--ssl] [--broadcast] [...]
|
||||
servers [--discover] [--server host[:porta]] [--ssl] [--net ...]
|
||||
Network (indexing server; without --server the first known server is used):
|
||||
sync [--server host[:port]] [--ssl] [--net ...] [--password P] [--file PATH]
|
||||
send --to ADDRESS (--amount X | --all) [--feerate sat/vB]
|
||||
[--server host[:port]] [--ssl] [--broadcast] [...]
|
||||
servers [--discover] [--server host[:port]] [--ssl] [--net ...]
|
||||
reset-certs [--net ...]
|
||||
|
||||
Strumenti:
|
||||
Tools:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace PalladiumWallet.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Tipo di rete supportato (selettore unico per tutto il wallet, blueprint §3).
|
||||
/// Supported network type (single selector for the whole wallet, blueprint §3).
|
||||
/// </summary>
|
||||
public enum NetKind
|
||||
{
|
||||
@@ -11,7 +11,7 @@ public enum NetKind
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tipo di script/indirizzo supportato (blueprint §4.3).
|
||||
/// Supported script/address type (blueprint §4.3).
|
||||
/// </summary>
|
||||
public enum ScriptKind
|
||||
{
|
||||
@@ -21,7 +21,7 @@ public enum ScriptKind
|
||||
/// <summary>P2SH-P2WPKH (segwit wrapped).</summary>
|
||||
WrappedSegwit,
|
||||
|
||||
/// <summary>P2WPKH (native segwit) — default consigliato.</summary>
|
||||
/// <summary>P2WPKH (native segwit) — recommended default.</summary>
|
||||
NativeSegwit,
|
||||
|
||||
/// <summary>P2SH-P2WSH (multisig wrapped).</summary>
|
||||
@@ -29,18 +29,21 @@ public enum ScriptKind
|
||||
|
||||
/// <summary>P2WSH (multisig native).</summary>
|
||||
NativeSegwitMultisig,
|
||||
|
||||
/// <summary>P2TR key-path only (Taproot, BIP86) — witness v1, bech32m.</summary>
|
||||
Taproot,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coppia di header di versione BIP32 (4 byte big-endian) per serializzare
|
||||
/// xprv/xpub e varianti SLIP-132 (y/z/Y/Z) — blueprint §3.
|
||||
/// Pair of BIP32 version headers (4-byte big-endian) to serialize
|
||||
/// xprv/xpub and SLIP-132 variants (y/z/Y/Z) — blueprint §3.
|
||||
/// </summary>
|
||||
public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
||||
{
|
||||
/// <summary>Header privato come 4 byte big-endian (da anteporre al payload BIP32).</summary>
|
||||
/// <summary>Private header as 4 big-endian bytes (to prepend to the BIP32 payload).</summary>
|
||||
public byte[] PrivateBytes() => ToBytes(Private);
|
||||
|
||||
/// <summary>Header pubblico come 4 byte big-endian.</summary>
|
||||
/// <summary>Public header as 4 big-endian bytes.</summary>
|
||||
public byte[] PublicBytes() => ToBytes(Public);
|
||||
|
||||
internal static byte[] ToBytes(uint header) =>
|
||||
@@ -53,82 +56,88 @@ public readonly record struct ExtKeyHeaders(uint Private, uint Public)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server di indicizzazione di bootstrap: host + porta TCP + porta SSL (blueprint §3/§9).
|
||||
/// Bootstrap indexing server: host + TCP port + SSL port (blueprint §3/§9).
|
||||
/// </summary>
|
||||
public readonly record struct ServerEndpoint(string Host, int TcpPort, int SslPort);
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint di catena: ancora la fiducia quando la verifica PoW è disattivata
|
||||
/// (blueprint §7.3). Target serializzato come "bits" compatti.
|
||||
/// Chain checkpoint: anchors trust when PoW validation is disabled
|
||||
/// (blueprint §7.3). Target serialized as compact "bits".
|
||||
/// </summary>
|
||||
public readonly record struct Checkpoint(int Height, string BlockHash, uint Bits);
|
||||
|
||||
/// <summary>
|
||||
/// Profilo di rete/catena (blueprint §3): tutte le costanti specifiche della catena,
|
||||
/// centralizzate in un solo punto. Nessun magic number altrove nel codice.
|
||||
/// Network/chain profile (blueprint §3): all chain-specific constants,
|
||||
/// centralized in one place. No magic numbers elsewhere in the code.
|
||||
/// </summary>
|
||||
public sealed record ChainProfile
|
||||
{
|
||||
public required NetKind Kind { get; init; }
|
||||
|
||||
/// <summary>Nome rete, usato anche come sottocartella dati.</summary>
|
||||
/// <summary>Network name, also used as the data subfolder.</summary>
|
||||
public required string NetName { get; init; }
|
||||
|
||||
/// <summary>Simbolo dell'unità (es. PLM).</summary>
|
||||
/// <summary>Unit symbol (e.g. PLM).</summary>
|
||||
public required string CoinUnit { get; init; }
|
||||
|
||||
/// <summary>Prefisso WIF delle chiavi private.</summary>
|
||||
/// <summary>WIF prefix for private keys.</summary>
|
||||
public required byte WifPrefix { get; init; }
|
||||
|
||||
/// <summary>Byte di versione indirizzi P2PKH.</summary>
|
||||
/// <summary>Version byte for P2PKH addresses.</summary>
|
||||
public required byte AddrP2pkh { get; init; }
|
||||
|
||||
/// <summary>Byte di versione indirizzi P2SH.</summary>
|
||||
/// <summary>Version byte for P2SH addresses.</summary>
|
||||
public required byte AddrP2sh { get; init; }
|
||||
|
||||
/// <summary>Human-readable part bech32/bech32m.</summary>
|
||||
public required string SegwitHrp { get; init; }
|
||||
|
||||
/// <summary>HRP fatture BOLT11 (Lightning, fase successiva — §11).</summary>
|
||||
/// <summary>HRP for BOLT11 invoices (Lightning, later phase — §11).</summary>
|
||||
public required string Bolt11Hrp { get; init; }
|
||||
|
||||
/// <summary>Hash del blocco genesi (hex, byte order da explorer).</summary>
|
||||
/// <summary>Genesis block hash (hex, explorer byte order).</summary>
|
||||
public required string GenesisHash { get; init; }
|
||||
|
||||
/// <summary>Porta TCP del server di indicizzazione (non è la porta P2P del nodo).</summary>
|
||||
/// <summary>TCP port of the indexing server (not the node's P2P port).</summary>
|
||||
public required int DefaultTcpPort { get; init; }
|
||||
|
||||
/// <summary>Porta SSL del server di indicizzazione.</summary>
|
||||
/// <summary>SSL port of the indexing server.</summary>
|
||||
public required int DefaultSslPort { get; init; }
|
||||
|
||||
/// <summary>Porta P2P del nodo — solo informativa: il wallet SPV non la usa.</summary>
|
||||
/// <summary>Node's P2P port — informational only: the SPV wallet does not use it.</summary>
|
||||
public required int NodeP2pPort { get; init; }
|
||||
|
||||
/// <summary>Coin type SLIP-0044 nei derivation path (convenzione wallet).</summary>
|
||||
/// <summary>SLIP-0044 coin type in derivation paths (wallet convention).</summary>
|
||||
public required int Bip44CoinType { get; init; }
|
||||
|
||||
/// <summary>Schema URI pagamenti BIP21 (senza i due punti).</summary>
|
||||
/// <summary>BIP21 payment URI scheme (without the colon).</summary>
|
||||
public required string UriScheme { get; init; }
|
||||
|
||||
/// <summary>Block explorer di default.</summary>
|
||||
/// <summary>Default block explorer.</summary>
|
||||
public required string ExplorerUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// La catena usa LWMA (retargeting per-blocco, 2 minuti): un client SPV non può
|
||||
/// ricalcolarla, quindi la verifica bits/target va saltata e la fiducia ancorata
|
||||
/// ai checkpoint (§3/§7). Per la catena di riferimento è sempre true.
|
||||
/// The chain uses LWMA (per-block retargeting, 2 minutes): an SPV client cannot
|
||||
/// recompute it, so bits/target validation must be skipped and trust anchored
|
||||
/// to checkpoints (§3/§7). For the reference chain it is always true.
|
||||
/// </summary>
|
||||
public required bool SkipPowValidation { get; init; }
|
||||
|
||||
/// <summary>Tempo di blocco target in secondi (LWMA v2: 120s).</summary>
|
||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||
public required int BlockTimeSeconds { get; init; }
|
||||
|
||||
/// <summary>Header BIP32/SLIP-132 per ciascun tipo di script.</summary>
|
||||
/// <summary>Blocks a coinbase output must wait before it can be spent.</summary>
|
||||
public required int CoinbaseMaturity { get; init; }
|
||||
|
||||
/// <summary>Minimum confirmations required for a regular UTXO to be spendable.</summary>
|
||||
public required int MinConfirmations { get; init; }
|
||||
|
||||
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||
|
||||
/// <summary>Server di indicizzazione per il primo contatto (bootstrap).</summary>
|
||||
/// <summary>Indexing servers for first contact (bootstrap).</summary>
|
||||
public required IReadOnlyList<ServerEndpoint> BootstrapServers { get; init; }
|
||||
|
||||
/// <summary>Checkpoint hardcoded, aggiornati a ogni release (§7.3).</summary>
|
||||
/// <summary>Hardcoded checkpoints, updated at every release (§7.3).</summary>
|
||||
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace PalladiumWallet.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// I tre profili di rete del wallet (blueprint §3). Valori mainnet verificati
|
||||
/// contro il sorgente del nodo (chainparams.cpp / pow.cpp) secondo il blueprint.
|
||||
/// The wallet's three network profiles (blueprint §3). Mainnet values verified
|
||||
/// against the node source (chainparams.cpp / pow.cpp) per the blueprint.
|
||||
/// </summary>
|
||||
public static class ChainProfiles
|
||||
{
|
||||
@@ -12,11 +12,11 @@ public static class ChainProfiles
|
||||
NetName = "mainnet",
|
||||
CoinUnit = "PLM",
|
||||
WifPrefix = 0x80,
|
||||
AddrP2pkh = 55, // indirizzi che iniziano con 'P'
|
||||
AddrP2sh = 5, // indirizzi che iniziano con '3'
|
||||
AddrP2pkh = 55, // addresses starting with 'P'
|
||||
AddrP2sh = 5, // addresses starting with '3'
|
||||
SegwitHrp = "plm",
|
||||
Bolt11Hrp = "plm",
|
||||
// La mainnet riusa la genesi di Bitcoin (blueprint §3).
|
||||
// Mainnet reuses Bitcoin's genesis (blueprint §3).
|
||||
GenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
||||
DefaultTcpPort = 50001,
|
||||
DefaultSslPort = 50002,
|
||||
@@ -26,6 +26,8 @@ public static class ChainProfiles
|
||||
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
||||
SkipPowValidation = true,
|
||||
BlockTimeSeconds = 120,
|
||||
CoinbaseMaturity = 120,
|
||||
MinConfirmations = 6,
|
||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||
{
|
||||
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
||||
@@ -33,9 +35,11 @@ public static class ChainProfiles
|
||||
[ScriptKind.WrappedSegwitMultisig] = new(0x0295b005, 0x0295b43f), // Yprv / Ypub
|
||||
[ScriptKind.NativeSegwit] = new(0x04b2430c, 0x04b24746), // zprv / zpub
|
||||
[ScriptKind.NativeSegwitMultisig] = new(0x02aa7a99, 0x02aa7ed3), // Zprv / Zpub
|
||||
// No SLIP-132 for P2TR: the context is given by the m/86'/… path
|
||||
[ScriptKind.Taproot] = new(0x0488ade4, 0x0488b21e), // xprv / xpub (BIP32 standard)
|
||||
},
|
||||
// Server di indicizzazione noti per il primo contatto (§3/§9); altri
|
||||
// peer vengono scoperti via server.peers.subscribe.
|
||||
// Known indexing servers for first contact (§3/§9); other
|
||||
// peers are discovered via server.peers.subscribe.
|
||||
BootstrapServers =
|
||||
[
|
||||
new ServerEndpoint("173.212.224.67", 50001, 50002),
|
||||
@@ -43,35 +47,72 @@ public static class ChainProfiles
|
||||
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||
],
|
||||
// TODO: popolare con [hash, bits] reali della catena (§7.3) prima della release.
|
||||
Checkpoints = [],
|
||||
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||
// RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
|
||||
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
|
||||
// the header-anchoring walk short. 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),
|
||||
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
|
||||
],
|
||||
};
|
||||
|
||||
public static ChainProfile Testnet { get; } = Mainnet with
|
||||
{
|
||||
Kind = NetKind.Testnet,
|
||||
NetName = "testnet",
|
||||
MinConfirmations = 1,
|
||||
WifPrefix = 0xff,
|
||||
AddrP2pkh = 127,
|
||||
AddrP2sh = 115,
|
||||
SegwitHrp = "tplm",
|
||||
Bolt11Hrp = "tplm",
|
||||
// TODO: verificare contro chainparams.cpp del nodo (il blueprint non la riporta;
|
||||
// qui si assume la genesi di Bitcoin testnet3, da confermare).
|
||||
// TODO: verify against the node's chainparams.cpp (the blueprint does not report it;
|
||||
// here Bitcoin testnet3 genesis is assumed, to be confirmed).
|
||||
GenesisHash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943",
|
||||
NodeP2pPort = 12333,
|
||||
Bip44CoinType = 1,
|
||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||
{
|
||||
// tprv/tpub dal blueprint; le varianti SLIP-132 testnet (u/v/U/V) sono
|
||||
// i valori standard Electrum.
|
||||
// tprv/tpub from the blueprint; the testnet SLIP-132 variants (u/v/U/V) are
|
||||
// the standard Electrum values.
|
||||
[ScriptKind.Legacy] = new(0x04358394, 0x043587cf), // tprv / tpub
|
||||
[ScriptKind.WrappedSegwit] = new(0x044a4e28, 0x044a5262), // uprv / upub
|
||||
[ScriptKind.WrappedSegwitMultisig] = new(0x024285b5, 0x024289ef), // Uprv / Upub
|
||||
[ScriptKind.NativeSegwit] = new(0x045f18bc, 0x045f1cf6), // vprv / vpub
|
||||
[ScriptKind.NativeSegwitMultisig] = new(0x02575048, 0x02575483), // Vprv / Vpub
|
||||
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
||||
},
|
||||
BootstrapServers = [],
|
||||
// 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 = [],
|
||||
};
|
||||
|
||||
@@ -81,9 +122,10 @@ public static class ChainProfiles
|
||||
NetName = "regtest",
|
||||
SegwitHrp = "rplm",
|
||||
Bolt11Hrp = "rplm",
|
||||
// TODO: verificare contro chainparams.cpp del nodo (assunta genesi regtest Bitcoin).
|
||||
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
||||
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
||||
NodeP2pPort = 28444,
|
||||
// Regtest is regenerated locally on demand: hardcoded checkpoints make no sense here.
|
||||
Checkpoints = [],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ using NBitcoin.DataEncoders;
|
||||
namespace PalladiumWallet.Core.Chain;
|
||||
|
||||
/// <summary>
|
||||
/// Costruisce e registra le <see cref="Network"/> NBitcoin di Palladium a partire dai
|
||||
/// <see cref="ChainProfiles"/> (blueprint §19.3). Tutto il resto del codice ottiene la
|
||||
/// rete da qui, mai da costanti sparse.
|
||||
/// Builds and registers the Palladium NBitcoin <see cref="Network"/> instances from the
|
||||
/// <see cref="ChainProfiles"/> (blueprint §19.3). All other code obtains the network
|
||||
/// from here — never from scattered constants.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Identità della moneta per NBitcoin: raggruppa le tre reti sotto il codice PLM.
|
||||
/// I getter sono lazy, quindi nessuna ricorsione durante la costruzione.
|
||||
/// Coin identity for NBitcoin: groups the three networks under the PLM code.
|
||||
/// Getters are lazy, so no recursion occurs during construction.
|
||||
/// </summary>
|
||||
public sealed class PalladiumNetworkSet : INetworkSet
|
||||
{
|
||||
@@ -34,13 +34,13 @@ public sealed class PalladiumNetworkSet : INetworkSet
|
||||
|
||||
public static class PalladiumNetworks
|
||||
{
|
||||
// Blocco genesi di Bitcoin (la mainnet Palladium lo riusa, blueprint §3).
|
||||
// Bitcoin genesis block (Palladium mainnet reuses it, blueprint §3).
|
||||
private const string MainGenesisHex =
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c" +
|
||||
Coinbase;
|
||||
|
||||
// Genesi testnet3/regtest di Bitcoin: stessa coinbase, cambiano time/bits/nonce.
|
||||
// TODO: confermare contro chainparams.cpp del nodo (vedi ChainProfiles).
|
||||
// Bitcoin testnet3/regtest genesis: same coinbase, different time/bits/nonce.
|
||||
// TODO: confirm against the node's chainparams.cpp (see ChainProfiles).
|
||||
private const string TestGenesisHex =
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4adae5494dffff001d1aa4ae18" +
|
||||
Coinbase;
|
||||
@@ -81,9 +81,9 @@ public static class PalladiumNetworks
|
||||
NetKind.Testnet => ChainName.Testnet,
|
||||
_ => ChainName.Regtest,
|
||||
})
|
||||
// Magic P2P solo segnaposto: il wallet SPV non apre mai connessioni P2P
|
||||
// (parla solo col server di indicizzazione, §3). Serve a NBitcoin per
|
||||
// distinguere le reti registrate.
|
||||
// P2P magic is a placeholder only: the SPV wallet never opens P2P connections
|
||||
// (it only talks to the indexing server, §3). Required by NBitcoin to
|
||||
// distinguish registered networks.
|
||||
.SetMagic(magic)
|
||||
.SetPort(p.NodeP2pPort)
|
||||
.SetRPCPort(p.NodeP2pPort + 1)
|
||||
@@ -91,9 +91,9 @@ public static class PalladiumNetworks
|
||||
.SetBase58Bytes(Base58Type.PUBKEY_ADDRESS, [p.AddrP2pkh])
|
||||
.SetBase58Bytes(Base58Type.SCRIPT_ADDRESS, [p.AddrP2sh])
|
||||
.SetBase58Bytes(Base58Type.SECRET_KEY, [p.WifPrefix])
|
||||
// NBitcoin gestisce una sola coppia di header BIP32 per rete: quella
|
||||
// standard xprv/xpub. Le varianti SLIP-132 (y/z/Y/Z) si serializzano
|
||||
// a mano con ChainProfile.ExtKeyHeaders quando servono.
|
||||
// NBitcoin handles only one BIP32 header pair per network: the standard
|
||||
// xprv/xpub. SLIP-132 variants (y/z/Y/Z) are serialised manually via
|
||||
// ChainProfile.ExtKeyHeaders when needed.
|
||||
.SetBase58Bytes(Base58Type.EXT_SECRET_KEY, legacy.PrivateBytes())
|
||||
.SetBase58Bytes(Base58Type.EXT_PUBLIC_KEY, legacy.PublicBytes())
|
||||
.SetBech32(Bech32Type.WITNESS_PUBKEY_ADDRESS, Encoders.Bech32(p.SegwitHrp))
|
||||
@@ -102,8 +102,8 @@ public static class PalladiumNetworks
|
||||
.SetUriScheme(p.UriScheme)
|
||||
.SetConsensus(new Consensus
|
||||
{
|
||||
// Valori usati solo dalle API NBitcoin che li richiedono: la validazione
|
||||
// header reale è custom (Core/Spv) con skip PoW + checkpoint (§7).
|
||||
// Values used only by NBitcoin APIs that require them: real header
|
||||
// validation is custom (Core/Spv) with PoW skip + checkpoints (§7).
|
||||
PowLimit = new Target(new uint256("00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff")),
|
||||
PowTargetSpacing = TimeSpan.FromSeconds(p.BlockTimeSeconds),
|
||||
PowTargetTimespan = TimeSpan.FromDays(14),
|
||||
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
|
||||
MajorityRejectBlockOutdated = 950,
|
||||
MajorityWindow = 1000,
|
||||
MinimumChainWork = uint256.Zero,
|
||||
CoinbaseMaturity = 100,
|
||||
CoinbaseMaturity = 120,
|
||||
SupportSegwit = true,
|
||||
SupportTaproot = true,
|
||||
ConsensusFactory = new ConsensusFactory(),
|
||||
|
||||
@@ -3,8 +3,8 @@ using NBitcoin;
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate
|
||||
/// in NBitcoin: nessun accesso a rete o filesystem.
|
||||
/// Supported BIP39 wordlist languages (blueprint §4.1). The lists are embedded
|
||||
/// in NBitcoin: no network or filesystem access required.
|
||||
/// </summary>
|
||||
public enum MnemonicLanguage
|
||||
{
|
||||
@@ -18,7 +18,7 @@ public enum MnemonicLanguage
|
||||
Czech,
|
||||
}
|
||||
|
||||
/// <summary>Numero di parole supportato in creazione (blueprint §4.1: 12 o 24).</summary>
|
||||
/// <summary>Supported word counts for mnemonic creation (blueprint §4.1: 12 or 24).</summary>
|
||||
public enum MnemonicLength
|
||||
{
|
||||
Twelve = 12,
|
||||
@@ -26,16 +26,16 @@ public enum MnemonicLength
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Facciata BIP39 su NBitcoin.Mnemonic (blueprint §4.1). NBitcoin copre già
|
||||
/// entropia→parole, checksum, normalizzazione NFKD e PBKDF2-HMAC-SHA512 a 2048
|
||||
/// round con salt "mnemonic"+passphrase: qui si restringe l'API ai casi del
|
||||
/// blueprint e si centralizza la mappa delle lingue. Quando arriverà il seed
|
||||
/// nativo versionato (§4.1 punto 1), il riconoscimento multi-schema sarà una
|
||||
/// catena di TryParse per schema.
|
||||
/// BIP39 facade over NBitcoin.Mnemonic (blueprint §4.1). NBitcoin already handles
|
||||
/// entropy→words, checksum, NFKD normalisation, and PBKDF2-HMAC-SHA512 with 2048
|
||||
/// rounds using salt "mnemonic"+passphrase. This class narrows the API to the
|
||||
/// blueprint use-cases and centralises the language map. When the native versioned
|
||||
/// seed arrives (§4.1 point 1), multi-scheme recognition will be a chain of
|
||||
/// TryParse calls per scheme.
|
||||
/// </summary>
|
||||
public static class Bip39
|
||||
{
|
||||
/// <summary>Genera una nuova mnemonica con entropia dal CSPRNG di sistema.</summary>
|
||||
/// <summary>Generates a new mnemonic using entropy from the system CSPRNG.</summary>
|
||||
public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English)
|
||||
{
|
||||
var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour;
|
||||
@@ -43,10 +43,10 @@ public static class Bip39
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla
|
||||
/// wordlist e checksum. Se <paramref name="language"/> è indicata viene provata
|
||||
/// per prima; altrimenti la lingua è auto-rilevata (parole condivise tra liste
|
||||
/// possono rendere ambiguo l'autodetect: in import l'utente può forzarla).
|
||||
/// Recognises and validates a BIP39 mnemonic: word count, wordlist membership,
|
||||
/// and checksum. If <paramref name="language"/> is specified it is tried first;
|
||||
/// otherwise the language is auto-detected (words shared between lists can make
|
||||
/// autodetect ambiguous — the user can override it on import).
|
||||
/// </summary>
|
||||
public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null)
|
||||
{
|
||||
@@ -54,39 +54,56 @@ public static class Bip39
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
|
||||
// Solo trim esterno: la normalizzazione NFKD e lo spazio ideografico
|
||||
// giapponese li gestisce NBitcoin, il testo non va alterato (§4.1).
|
||||
// Outer trim only: NBitcoin handles NFKD normalisation and Japanese
|
||||
// ideographic spaces — the text must not be altered further (§4.1).
|
||||
text = text.Trim();
|
||||
|
||||
IEnumerable<Wordlist> candidates = language is not null
|
||||
? [ToWordlist(language.Value)]
|
||||
: [Wordlist.AutoDetect(text)];
|
||||
|
||||
foreach (var wordlist in candidates)
|
||||
Wordlist wordlist;
|
||||
if (language is not null)
|
||||
{
|
||||
wordlist = ToWordlist(language.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = new Mnemonic(text, wordlist);
|
||||
// Il costruttore NON verifica il checksum: controllo esplicito obbligatorio.
|
||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||
{
|
||||
mnemonic = parsed;
|
||||
return true;
|
||||
}
|
||||
wordlist = Wordlist.AutoDetect(text);
|
||||
}
|
||||
catch (FormatException)
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Parole fuori wordlist o conteggio non valido: si prova oltre.
|
||||
// AutoDetect lands on NBitcoin's internal "Unknown" language for
|
||||
// text resembling no wordlist and throws instead of returning a
|
||||
// default (found by fuzzing) — not a valid mnemonic, plain and simple.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = new Mnemonic(text, wordlist);
|
||||
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||
{
|
||||
mnemonic = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Words outside the wordlist or invalid count.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Defensive: same NBitcoin failure mode surfacing from the constructor.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mnemonica → seed di 64 byte (PBKDF2-HMAC-SHA512, 2048 round, salt
|
||||
/// "mnemonic"+passphrase). La passphrase cambia completamente il wallet
|
||||
/// derivato: avvisi UI obbligatori (§4.1).
|
||||
/// Mnemonic → 64-byte seed (PBKDF2-HMAC-SHA512, 2048 rounds, salt
|
||||
/// "mnemonic"+passphrase). The passphrase completely changes the derived
|
||||
/// wallet — mandatory UI warnings required (§4.1).
|
||||
/// </summary>
|
||||
public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) =>
|
||||
mnemonic.DeriveSeed(passphrase);
|
||||
|
||||
@@ -4,52 +4,57 @@ using PalladiumWallet.Core.Chain;
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Costruzione dei derivation path BIP44/49/84 (blueprint §4.2) e mappatura
|
||||
/// ScriptKind → purpose / ScriptPubKeyType. I purpose sono costanti di protocollo
|
||||
/// BIP (chain-independent); il coin_type viene sempre dal ChainProfile.
|
||||
/// BIP44/49/84 derivation path construction (blueprint §4.2) and mapping of
|
||||
/// ScriptKind → purpose / ScriptPubKeyType. Purposes are BIP protocol constants
|
||||
/// (chain-independent); coin_type always comes from the ChainProfile.
|
||||
/// </summary>
|
||||
public static class DerivationPaths
|
||||
{
|
||||
/// <summary>Purpose BIP44 (P2PKH legacy).</summary>
|
||||
/// <summary>BIP44 purpose (P2PKH legacy).</summary>
|
||||
public const int PurposeLegacy = 44;
|
||||
|
||||
/// <summary>Purpose BIP49 (P2SH-P2WPKH).</summary>
|
||||
/// <summary>BIP49 purpose (P2SH-P2WPKH).</summary>
|
||||
public const int PurposeWrappedSegwit = 49;
|
||||
|
||||
/// <summary>Purpose BIP84 (P2WPKH nativo).</summary>
|
||||
/// <summary>BIP84 purpose (native P2WPKH).</summary>
|
||||
public const int PurposeNativeSegwit = 84;
|
||||
|
||||
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
|
||||
/// <summary>BIP48 purpose (multisig — next phase, §16 step 8).</summary>
|
||||
public const int PurposeMultisig = 48;
|
||||
|
||||
/// <summary>BIP86 purpose (P2TR key-path, Taproot).</summary>
|
||||
public const int PurposeTaproot = 86;
|
||||
|
||||
public static int PurposeFor(ScriptKind kind) => kind switch
|
||||
{
|
||||
ScriptKind.Legacy => PurposeLegacy,
|
||||
ScriptKind.WrappedSegwit => PurposeWrappedSegwit,
|
||||
ScriptKind.NativeSegwit => PurposeNativeSegwit,
|
||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig,
|
||||
ScriptKind.Taproot => PurposeTaproot,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tipo di scriptPubKey NBitcoin per la derivazione da pubkey singola.
|
||||
/// I tipi multisig derivano da redeem script, non da una pubkey: arriveranno
|
||||
/// con i wallet M-di-N (§4.5).
|
||||
/// NBitcoin scriptPubKey type for single-pubkey derivation.
|
||||
/// Multisig types are derived from a redeem script, not a pubkey — they will
|
||||
/// arrive with M-of-N wallet support (§4.5).
|
||||
/// </summary>
|
||||
public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch
|
||||
{
|
||||
ScriptKind.Legacy => ScriptPubKeyType.Legacy,
|
||||
ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH,
|
||||
ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit,
|
||||
ScriptKind.Taproot => ScriptPubKeyType.TaprootBIP86,
|
||||
ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig =>
|
||||
throw new NotSupportedException(
|
||||
"I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."),
|
||||
"Multisig types are derived from a redeem script: support planned with M-of-N wallets (§4.5)."),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Path di account relativo alla root: purpose'/coin'/account' (§4.2).
|
||||
/// Il coin_type è quello del profilo (746 mainnet, 1 testnet).
|
||||
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
||||
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
||||
/// </summary>
|
||||
public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0)
|
||||
{
|
||||
@@ -59,7 +64,7 @@ public static class DerivationPaths
|
||||
$"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'");
|
||||
}
|
||||
|
||||
/// <summary>Sottopath non-hardened change/index sotto l'account (change=0 receiving, change=1 change).</summary>
|
||||
/// <summary>Non-hardened change/index sub-path under the account (change=0 receiving, change=1 change).</summary>
|
||||
public static KeyPath AddressSubPath(bool isChange, int index)
|
||||
{
|
||||
if (index < 0)
|
||||
@@ -68,8 +73,8 @@ public static class DerivationPaths
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2):
|
||||
/// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h.
|
||||
/// Parsing of custom derivation paths on import (Sparrow-like, §4.2):
|
||||
/// accepts the optional "m/" prefix and hardened markers ' or h.
|
||||
/// </summary>
|
||||
public static bool TryParse(string path, out KeyPath? keyPath)
|
||||
{
|
||||
|
||||
@@ -4,39 +4,39 @@ using PalladiumWallet.Core.Chain;
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Account HD single-sig (blueprint §4.2/§4.4): chiave estesa di account (xprv,
|
||||
/// oppure solo xpub = watch-only) + tipo di script + profilo di rete. Deriva
|
||||
/// indirizzi receiving (change=0) e change (change=1) on-demand per indice.
|
||||
/// Gli indirizzi si derivano sempre dall'xpub di account (sottopath non-hardened),
|
||||
/// quindi il watch-only funziona per costruzione. Il keystore completo
|
||||
/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8).
|
||||
/// Single-sig HD account (blueprint §4.2/§4.4): extended account key (xprv,
|
||||
/// or xpub-only = watch-only) + script type + network profile. Derives receiving
|
||||
/// (change=0) and change (change=1) addresses on-demand by index.
|
||||
/// Addresses are always derived from the account xpub (non-hardened sub-path),
|
||||
/// so watch-only works by construction. The full keystore
|
||||
/// (encryption, factory from wallet file, §4.5) arrives with persistence (§8).
|
||||
/// </summary>
|
||||
public sealed class HdAccount
|
||||
public sealed class HdAccount : IWalletAccount
|
||||
{
|
||||
private readonly ExtKey? _accountXprv;
|
||||
|
||||
public ScriptKind Kind { get; }
|
||||
public ChainProfile Profile { get; }
|
||||
|
||||
/// <summary>Path dell'account relativo alla root (es. 84'/746'/0', o personalizzato).</summary>
|
||||
/// <summary>Account path relative to the root (e.g. 84'/746'/0', or custom).</summary>
|
||||
public KeyPath AccountPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint della chiave master: con <see cref="AccountPath"/> forma le
|
||||
/// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata
|
||||
/// senza metadati).
|
||||
/// Master key fingerprint: together with <see cref="AccountPath"/> forms the
|
||||
/// origin info required by PSBTs (§6.5). Zero if unknown (imported xpub
|
||||
/// without metadata).
|
||||
/// </summary>
|
||||
public HDFingerprint MasterFingerprint { get; }
|
||||
|
||||
public ExtPubKey AccountXpub { get; }
|
||||
|
||||
/// <summary>True se l'account conosce solo la xpub: costruisce ma non firma (§4.5).</summary>
|
||||
/// <summary>True if the account only knows the xpub: can build but not sign (§4.5).</summary>
|
||||
public bool IsWatchOnly => _accountXprv is null;
|
||||
|
||||
private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind,
|
||||
ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint)
|
||||
{
|
||||
// Valida subito la mappatura (i tipi multisig non sono supportati qui).
|
||||
// Validate the mapping immediately (multisig types are not supported here).
|
||||
DerivationPaths.ScriptPubKeyTypeFor(kind);
|
||||
_accountXprv = accountXprv;
|
||||
AccountXpub = accountXpub;
|
||||
@@ -46,7 +46,7 @@ public sealed class HdAccount
|
||||
MasterFingerprint = masterFingerprint;
|
||||
}
|
||||
|
||||
/// <summary>Caso principale: mnemonica BIP39 (+ passphrase opzionale) → account standard.</summary>
|
||||
/// <summary>Main case: BIP39 mnemonic (+ optional passphrase) → standard account.</summary>
|
||||
public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase,
|
||||
ScriptKind kind, ChainProfile profile, int account = 0) =>
|
||||
FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account);
|
||||
@@ -55,22 +55,22 @@ public sealed class HdAccount
|
||||
FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account));
|
||||
|
||||
/// <summary>
|
||||
/// Import con derivation path personalizzato (§4.2, Sparrow-like):
|
||||
/// <paramref name="kind"/> determina solo il tipo di indirizzo generato.
|
||||
/// Import with a custom derivation path (§4.2, Sparrow-like):
|
||||
/// <paramref name="kind"/> only determines the generated address type.
|
||||
/// </summary>
|
||||
public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath)
|
||||
{
|
||||
var root = ExtKey.CreateFromSeed(seed);
|
||||
// L'account si deriva sempre dalla xprv: i livelli hardened del path
|
||||
// non sono derivabili da una xpub.
|
||||
// The account is always derived from the xprv: hardened path levels
|
||||
// cannot be derived from an xpub alone.
|
||||
var accountXprv = root.Derive(accountPath);
|
||||
return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile,
|
||||
accountPath, root.Neuter().PubKey.GetHDFingerPrint());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watch-only da xpub di account (§4.4): fingerprint e path sono noti solo
|
||||
/// se forniti da chi importa (servono per le origin info PSBT).
|
||||
/// Watch-only from account xpub (§4.4): fingerprint and path are known only
|
||||
/// if supplied by the importer (required for PSBT origin info).
|
||||
/// </summary>
|
||||
public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind,
|
||||
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
||||
@@ -78,7 +78,7 @@ public sealed class HdAccount
|
||||
accountPath ?? DerivationPaths.AccountPath(kind, profile),
|
||||
masterFingerprint ?? default);
|
||||
|
||||
/// <summary>Import spendibile da xprv di account.</summary>
|
||||
/// <summary>Spendable import from account xprv.</summary>
|
||||
public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind,
|
||||
ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) =>
|
||||
new(accountXprv, accountXprv.Neuter(), kind, profile,
|
||||
@@ -86,7 +86,7 @@ public sealed class HdAccount
|
||||
masterFingerprint ?? default);
|
||||
|
||||
public BitcoinAddress GetAddress(bool isChange, int index) =>
|
||||
GetPublicKey(isChange, index).GetAddress(
|
||||
GetPublicKey(isChange, index)!.GetAddress(
|
||||
DerivationPaths.ScriptPubKeyTypeFor(Kind),
|
||||
PalladiumNetworks.For(Profile.Kind));
|
||||
|
||||
@@ -94,23 +94,30 @@ public sealed class HdAccount
|
||||
|
||||
public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index);
|
||||
|
||||
public PubKey GetPublicKey(bool isChange, int index) =>
|
||||
public PubKey? GetPublicKey(bool isChange, int index) =>
|
||||
AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey;
|
||||
|
||||
/// <summary>Private key for an address; null if watch-only (§17).</summary>
|
||||
public Key? GetPrivateKey(bool isChange, int index) =>
|
||||
IsWatchOnly ? null : GetExtPrivateKey(isChange, index).PrivateKey;
|
||||
|
||||
/// <summary>HD accounts use the gap limit: no fixed address list.</summary>
|
||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses => null;
|
||||
|
||||
/// <summary>
|
||||
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
|
||||
/// chiave privata è derivabile dalle sole pubbliche (§17).
|
||||
/// Extended private key for an address. Throws if watch-only: no private key
|
||||
/// can be derived from public keys alone (§17).
|
||||
/// </summary>
|
||||
public ExtKey GetExtPrivateKey(bool isChange, int index) =>
|
||||
(_accountXprv ?? throw new InvalidOperationException("Account watch-only: non può firmare."))
|
||||
(_accountXprv ?? throw new InvalidOperationException("Watch-only account: cannot sign."))
|
||||
.Derive(DerivationPaths.AddressSubPath(isChange, index));
|
||||
|
||||
/// <summary>Xpub di account in formato SLIP-132 (xpub/ypub/zpub secondo Kind).</summary>
|
||||
/// <summary>Account xpub in SLIP-132 format (xpub/ypub/zpub according to Kind).</summary>
|
||||
public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
|
||||
|
||||
/// <summary>Xprv di account in formato SLIP-132. Lancia se watch-only.</summary>
|
||||
/// <summary>Account xprv in SLIP-132 format. Throws if watch-only.</summary>
|
||||
public string ToSlip132Private() =>
|
||||
Slip132.Encode(
|
||||
_accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."),
|
||||
_accountXprv ?? throw new InvalidOperationException("Watch-only account: no private key."),
|
||||
Kind, Profile);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over all wallet account types (HD from seed, HD from imported xpub/xprv,
|
||||
/// imported WIF keys). Allows WalletSynchronizer and TransactionFactory to operate
|
||||
/// independently of the underlying keystore type (blueprint §4.4–§4.5).
|
||||
/// </summary>
|
||||
public interface IWalletAccount
|
||||
{
|
||||
ScriptKind Kind { get; }
|
||||
ChainProfile Profile { get; }
|
||||
|
||||
/// <summary>True if the account cannot sign (no private keys present).</summary>
|
||||
bool IsWatchOnly { get; }
|
||||
|
||||
BitcoinAddress GetAddress(bool isChange, int index);
|
||||
BitcoinAddress GetReceiveAddress(int index);
|
||||
BitcoinAddress GetChangeAddress(int index);
|
||||
|
||||
/// <summary>Null if the public key cannot be derived from the account (pure watch-only addresses).</summary>
|
||||
PubKey? GetPublicKey(bool isChange, int index);
|
||||
|
||||
/// <summary>Null if the account is watch-only or the index is out of range.</summary>
|
||||
Key? GetPrivateKey(bool isChange, int index);
|
||||
|
||||
/// <summary>
|
||||
/// For accounts with fixed addresses (imported WIF keys) returns the full list
|
||||
/// to scan; null for HD accounts that use the gap limit.
|
||||
/// </summary>
|
||||
IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses { get; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Account built from individually imported WIF keys (blueprint §4.4 — "Imported"):
|
||||
/// fixed list of addresses, no HD derivation, no change chain.
|
||||
/// Change always goes back to the first imported address.
|
||||
/// </summary>
|
||||
public sealed class ImportedKeyAccount : IWalletAccount
|
||||
{
|
||||
private readonly (BitcoinAddress Address, Key? PrivateKey)[] _entries;
|
||||
|
||||
public ScriptKind Kind { get; }
|
||||
public ChainProfile Profile { get; }
|
||||
public bool IsWatchOnly => _entries.All(e => e.PrivateKey is null);
|
||||
|
||||
public IReadOnlyList<(BitcoinAddress Address, bool IsChange, int Index)>? FixedAddresses =>
|
||||
_entries.Select((e, i) => (e.Address, false, i)).ToList();
|
||||
|
||||
public ImportedKeyAccount(
|
||||
IReadOnlyList<(BitcoinAddress Address, Key? PrivateKey)> entries,
|
||||
ScriptKind kind, ChainProfile profile)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
throw new ArgumentException("At least one address is required.", nameof(entries));
|
||||
_entries = [.. entries];
|
||||
Kind = kind;
|
||||
Profile = profile;
|
||||
}
|
||||
|
||||
public BitcoinAddress GetAddress(bool isChange, int index)
|
||||
{
|
||||
if (isChange || index < 0 || index >= _entries.Length)
|
||||
return _entries[0].Address;
|
||||
return _entries[index].Address;
|
||||
}
|
||||
|
||||
public BitcoinAddress GetReceiveAddress(int index) => GetAddress(false, index);
|
||||
|
||||
/// <summary>Change always returns to the first imported address.</summary>
|
||||
public BitcoinAddress GetChangeAddress(int index) => _entries[0].Address;
|
||||
|
||||
public PubKey? GetPublicKey(bool isChange, int index)
|
||||
{
|
||||
if (isChange || index < 0 || index >= _entries.Length)
|
||||
return null;
|
||||
return _entries[index].PrivateKey?.PubKey;
|
||||
}
|
||||
|
||||
public Key? GetPrivateKey(bool isChange, int index)
|
||||
{
|
||||
if (isChange || index < 0 || index >= _entries.Length)
|
||||
return null;
|
||||
return _entries[index].PrivateKey;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ public static class Slip132
|
||||
Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]);
|
||||
|
||||
/// <summary>
|
||||
/// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa:
|
||||
/// è la via dell'import watch-only (§4.4).
|
||||
/// Recognises the header (→ ScriptKind) and decodes an extended public key:
|
||||
/// this is the watch-only import path (§4.4).
|
||||
/// </summary>
|
||||
public static bool TryDecodePublic(string encoded, ChainProfile profile,
|
||||
out ExtPubKey? key, out ScriptKind kind)
|
||||
|
||||
@@ -5,10 +5,10 @@ using System.Text.Json;
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Pinning TLS "trust on first use" (blueprint §9): al primo contatto la
|
||||
/// fingerprint SHA-256 del certificato del server viene salvata; ai successivi
|
||||
/// viene confrontata. Se cambia, la connessione va rifiutata e l'utente può
|
||||
/// sbloccare con un reset esplicito (caso tipico: server self-signed rinnovato).
|
||||
/// TLS "trust on first use" pinning (blueprint §9): on the first contact the
|
||||
/// server certificate's SHA-256 fingerprint is saved; on subsequent ones it is
|
||||
/// compared. If it changes, the connection must be rejected and the user can
|
||||
/// unlock with an explicit reset (typical case: renewed self-signed server).
|
||||
/// </summary>
|
||||
public sealed class CertificatePinStore(string filePath)
|
||||
{
|
||||
@@ -18,8 +18,8 @@ public sealed class CertificatePinStore(string filePath)
|
||||
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Verifica TOFU: true se il certificato è quello già visto (o se è il primo
|
||||
/// contatto, nel qual caso viene salvato). False = certificato cambiato.
|
||||
/// TOFU check: true if the certificate is the one already seen (or if it is the
|
||||
/// first contact, in which case it is saved). False = certificate changed.
|
||||
/// </summary>
|
||||
public bool VerifyOrPin(string host, int port, X509Certificate certificate)
|
||||
{
|
||||
@@ -36,7 +36,7 @@ public sealed class CertificatePinStore(string filePath)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reset del certificato salvato per un server ("reset certificati SSL", §9).</summary>
|
||||
/// <summary>Reset the saved certificate for a server ("reset SSL certificates", §9).</summary>
|
||||
public bool Reset(string host, int port)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Load() =>
|
||||
File.Exists(filePath)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
|
||||
: [];
|
||||
private Dictionary<string, string> Load()
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A corrupt pin file must not make every SSL connection fail forever:
|
||||
// fall back to first-contact TOFU (same trust level as the bootstrap).
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(Dictionary<string, string> pins)
|
||||
{
|
||||
|
||||
@@ -2,24 +2,27 @@ using System.Text.Json;
|
||||
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>Voce di storico di uno scripthash (blockchain.scripthash.get_history).</summary>
|
||||
/// <summary>History entry for a scripthash (blockchain.scripthash.get_history).</summary>
|
||||
public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
|
||||
|
||||
/// <summary>UTXO riportato dal server (blockchain.scripthash.listunspent).</summary>
|
||||
/// <summary>UTXO reported by the server (blockchain.scripthash.listunspent).</summary>
|
||||
public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
|
||||
|
||||
/// <summary>Prova di Merkle (blockchain.transaction.get_merkle).</summary>
|
||||
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||
|
||||
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
|
||||
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
|
||||
public readonly record struct HeaderRangeResponse(int Count, string Hex);
|
||||
|
||||
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
||||
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||
|
||||
/// <summary>Peer annunciato da server.peers.subscribe (porte null = non offerte).</summary>
|
||||
/// <summary>Peer announced by server.peers.subscribe (null ports = not offered).</summary>
|
||||
public sealed record PeerInfo(string Host, int? TcpPort, int? SslPort, string? Version);
|
||||
|
||||
/// <summary>
|
||||
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
|
||||
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
|
||||
/// Typed helpers over the protocol methods (blueprint §10), on top of the
|
||||
/// generic JSON-RPC transport of <see cref="ElectrumClient"/>.
|
||||
/// </summary>
|
||||
public static class ElectrumApi
|
||||
{
|
||||
@@ -29,7 +32,7 @@ public static class ElectrumApi
|
||||
return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!);
|
||||
}
|
||||
|
||||
/// <summary>Status corrente dello scripthash (null = mai usato).</summary>
|
||||
/// <summary>Current status of the scripthash (null = never used).</summary>
|
||||
public static async Task<string?> SubscribeScripthashAsync(this ElectrumClient c, string scripthash,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -82,6 +85,23 @@ public static class ElectrumApi
|
||||
return r.GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
|
||||
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
|
||||
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
|
||||
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
|
||||
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
|
||||
/// callers must loop until the full range is covered.
|
||||
/// </summary>
|
||||
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
|
||||
int startHeight, int count, CancellationToken ct = default)
|
||||
{
|
||||
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
|
||||
return new HeaderRangeResponse(
|
||||
r.GetProperty("count").GetInt32(),
|
||||
r.GetProperty("hex").GetString()!);
|
||||
}
|
||||
|
||||
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -89,7 +109,7 @@ public static class ElectrumApi
|
||||
return r.GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>Fee stimata in coin/kB per il target dato; -1 se il server non sa stimare.</summary>
|
||||
/// <summary>Estimated fee in coin/kB for the given target; -1 if the server cannot estimate.</summary>
|
||||
public static async Task<decimal> EstimateFeeAsync(this ElectrumClient c, int targetBlocks,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -112,7 +132,7 @@ public static class ElectrumApi
|
||||
public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) =>
|
||||
c.RequestAsync("server.ping", ct);
|
||||
|
||||
/// <summary>Peer annunciati dal server (scoperta di altri server, §9).</summary>
|
||||
/// <summary>Peers announced by the server (discovery of other servers, §9).</summary>
|
||||
public static async Task<IReadOnlyList<PeerInfo>> GetPeersAsync(this ElectrumClient c,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -121,20 +141,25 @@ public static class ElectrumApi
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsa la risposta di server.peers.subscribe: lista di
|
||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORTA", "sPORTA", ...]];
|
||||
/// "t"/"s" senza numero = porta di default della rete (risolta dal chiamante).
|
||||
/// Parses the server.peers.subscribe response: a list of
|
||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
||||
/// "t"/"s" without a number = network default port (resolved by the caller).
|
||||
/// The response is untrusted server data: any unexpected shape (non-array,
|
||||
/// wrong element types) is skipped, never thrown on.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
||||
{
|
||||
var peers = new List<PeerInfo>();
|
||||
if (response.ValueKind != JsonValueKind.Array)
|
||||
return peers;
|
||||
foreach (var entry in response.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3)
|
||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3
|
||||
|| entry[2].ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
var host = entry[1].GetString();
|
||||
var host = AsString(entry[1]);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
host = entry[0].GetString();
|
||||
host = AsString(entry[0]);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
continue;
|
||||
|
||||
@@ -142,7 +167,7 @@ public static class ElectrumApi
|
||||
string? version = null;
|
||||
foreach (var feature in entry[2].EnumerateArray())
|
||||
{
|
||||
var f = feature.GetString();
|
||||
var f = AsString(feature);
|
||||
if (string.IsNullOrEmpty(f))
|
||||
continue;
|
||||
switch (f[0])
|
||||
@@ -157,4 +182,21 @@ public static class ElectrumApi
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return element.GetString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Syntactically valid JSON string that cannot be transcoded to UTF-16
|
||||
// (invalid UTF-8 bytes / lone surrogates) — untrusted server data,
|
||||
// treat as absent (found by fuzzing).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Client del protocollo del server di indicizzazione (blueprint §10):
|
||||
/// JSON-RPC 2.0 newline-delimited su TCP, opzionalmente TLS con pinning TOFU.
|
||||
/// Le notifiche (subscription) arrivano sull'evento <see cref="NotificationReceived"/>.
|
||||
/// Client for the indexing server protocol (blueprint §10):
|
||||
/// newline-delimited JSON-RPC 2.0 over TCP, optionally TLS with TOFU pinning.
|
||||
/// Notifications (subscriptions) arrive on the <see cref="NotificationReceived"/> event.
|
||||
/// </summary>
|
||||
public sealed class ElectrumClient : IAsyncDisposable
|
||||
{
|
||||
@@ -18,10 +20,25 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
|
||||
private readonly TcpClient _tcp;
|
||||
private readonly Stream _stream;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _readLoop;
|
||||
private readonly Task _writeLoop;
|
||||
|
||||
// Single-reader channel: tasks write the payloads without a lock;
|
||||
// the write loop drains everything in a single WriteAsync+FlushAsync —
|
||||
// identical to Electrum's asyncio buffered writer.
|
||||
private readonly Channel<byte[]> _outgoing = Channel.CreateUnbounded<byte[]>(
|
||||
new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false });
|
||||
|
||||
// Cap on in-flight requests (not in the write queue, but awaiting a
|
||||
// response from the server). The write loop already batches the send into a
|
||||
// single segment; this gate avoids flooding the server with thousands of
|
||||
// simultaneous requests on large wallets → no bursts of -101/-102 nor
|
||||
// connection drops. Writes still stay pipelined up to this degree.
|
||||
private const int MaxInFlight = 32;
|
||||
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight);
|
||||
|
||||
private long _nextId;
|
||||
|
||||
public string Host { get; }
|
||||
@@ -29,10 +46,7 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
public bool UseSsl { get; }
|
||||
public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested;
|
||||
|
||||
/// <summary>(metodo, parametri) per le notifiche di subscription.</summary>
|
||||
public event Action<string, JsonElement>? NotificationReceived;
|
||||
|
||||
/// <summary>Scatta quando la connessione cade (errore di lettura o chiusura remota).</summary>
|
||||
public event Action<Exception?>? Disconnected;
|
||||
|
||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
||||
@@ -42,18 +56,14 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
Host = host;
|
||||
Port = port;
|
||||
UseSsl = useSsl;
|
||||
_readLoop = Task.Run(ReadLoopAsync);
|
||||
_readLoop = Task.Run(ReadLoopAsync);
|
||||
_writeLoop = Task.Run(WriteLoopAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connette al server. Con <paramref name="useSsl"/> la validazione del
|
||||
/// certificato è TOFU tramite <paramref name="pins"/> (§9): primo contatto
|
||||
/// salva, contatti successivi confrontano; mismatch ⇒ <see cref="CertificatePinMismatchException"/>.
|
||||
/// </summary>
|
||||
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
||||
CertificatePinStore? pins = null, CancellationToken ct = default)
|
||||
{
|
||||
var tcp = new TcpClient();
|
||||
var tcp = new TcpClient { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await tcp.ConnectAsync(host, port, ct);
|
||||
@@ -64,10 +74,7 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
var ssl = new SslStream(stream, leaveInnerStreamOpen: false,
|
||||
(_, cert, _, _) =>
|
||||
{
|
||||
// I server Electrum sono tipicamente self-signed: la
|
||||
// fiducia è il pin TOFU, non la catena CA (§9).
|
||||
if (cert is null)
|
||||
return false;
|
||||
if (cert is null) return false;
|
||||
pinOk = pins is null || pins.VerifyOrPin(host, port, cert);
|
||||
return pinOk;
|
||||
});
|
||||
@@ -84,7 +91,6 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
||||
// Negoziazione obbligatoria prima di ogni altra richiesta.
|
||||
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
||||
return client;
|
||||
}
|
||||
@@ -98,99 +104,189 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
||||
params object?[] parameters)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = tcs;
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id,
|
||||
method,
|
||||
@params = parameters,
|
||||
});
|
||||
|
||||
await _writeLock.WaitAsync(ct);
|
||||
// Gate before going in-flight: beyond MaxInFlight pending requests
|
||||
// we wait for a response to free a slot, instead of flooding the server.
|
||||
await _inFlight.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(payload, ct);
|
||||
await _stream.WriteAsync("\n"u8.ToArray(), ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = tcs;
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
id,
|
||||
method,
|
||||
@params = parameters,
|
||||
});
|
||||
|
||||
_outgoing.Writer.TryWrite(payload);
|
||||
|
||||
await using var registration = ct.Register(() =>
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
tcs.TrySetCanceled(ct);
|
||||
});
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
_inFlight.Release();
|
||||
}
|
||||
|
||||
await using var registration = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private async Task ReadLoopAsync()
|
||||
/// <summary>
|
||||
/// Drain loop: empties the channel into a single buffer → one WriteAsync+FlushAsync
|
||||
/// for all queued messages. When N requests are queued, they are
|
||||
/// transmitted in a single TCP segment instead of N serial flushes.
|
||||
/// </summary>
|
||||
private async Task WriteLoopAsync()
|
||||
{
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(_stream, Encoding.UTF8, leaveOpen: true);
|
||||
while (!_cts.IsCancellationRequested)
|
||||
while (await _outgoing.Reader.WaitToReadAsync(_cts.Token))
|
||||
{
|
||||
var line = await reader.ReadLineAsync(_cts.Token);
|
||||
if (line is null)
|
||||
break; // chiusura remota
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
|
||||
using var ms = new MemoryStream(512);
|
||||
while (_outgoing.Reader.TryRead(out var data))
|
||||
{
|
||||
if (!_pending.TryRemove(idEl.GetInt64(), out var tcs))
|
||||
continue;
|
||||
if (root.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null)
|
||||
tcs.TrySetException(new ElectrumServerException(error.ToString()));
|
||||
else
|
||||
tcs.TrySetResult(root.GetProperty("result").Clone());
|
||||
}
|
||||
else if (root.TryGetProperty("method", out var methodEl))
|
||||
{
|
||||
NotificationReceived?.Invoke(
|
||||
methodEl.GetString()!,
|
||||
root.GetProperty("params").Clone());
|
||||
ms.Write(data);
|
||||
ms.WriteByte((byte)'\n');
|
||||
}
|
||||
await _stream.WriteAsync(ms.GetBuffer().AsMemory(0, (int)ms.Length), _cts.Token);
|
||||
await _stream.FlushAsync(_cts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
foreach (var (_, pending) in _pending)
|
||||
pending.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read loop with PipeReader: pooled buffers, zero per-response allocations
|
||||
/// (no intermediate string), JSON parsing directly from a byte span.
|
||||
/// </summary>
|
||||
private async Task ReadLoopAsync()
|
||||
{
|
||||
Exception? failure = null;
|
||||
var pipe = PipeReader.Create(_stream, new StreamPipeReaderOptions(leaveOpen: true));
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ReadResult result;
|
||||
try { result = await pipe.ReadAsync(_cts.Token); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
var buffer = result.Buffer;
|
||||
try
|
||||
{
|
||||
while (TrySliceLine(ref buffer, out var line))
|
||||
if (!line.IsEmpty)
|
||||
DispatchLine(line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// consumed = everything we have consumed (up to the last \n)
|
||||
// examined = everything we have looked at (up to the end of the buffer)
|
||||
pipe.AdvanceTo(buffer.Start, buffer.End);
|
||||
}
|
||||
|
||||
if (result.IsCompleted) break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.CompleteAsync();
|
||||
foreach (var (_, tcs) in _pending)
|
||||
tcs.TrySetException(failure ?? new IOException("Connessione al server chiusa."));
|
||||
tcs.TrySetException(failure ?? new IOException("Connection to the server closed."));
|
||||
_pending.Clear();
|
||||
Disconnected?.Invoke(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySliceLine(ref ReadOnlySequence<byte> buffer,
|
||||
out ReadOnlySequence<byte> line)
|
||||
{
|
||||
var pos = buffer.PositionOf((byte)'\n');
|
||||
if (pos is null) { line = default; return false; }
|
||||
line = buffer.Slice(0, pos.Value);
|
||||
buffer = buffer.Slice(buffer.GetPosition(1, pos.Value));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DispatchLine(ReadOnlySequence<byte> line)
|
||||
{
|
||||
if (line.IsSingleSegment)
|
||||
{
|
||||
DispatchSpan(line.FirstSpan);
|
||||
return;
|
||||
}
|
||||
// Multi-segment (very long response): copy to ArrayPool then parse.
|
||||
var len = (int)line.Length;
|
||||
var buf = ArrayPool<byte>.Shared.Rent(len);
|
||||
try
|
||||
{
|
||||
line.CopyTo(buf);
|
||||
DispatchSpan(buf.AsSpan(0, len));
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buf); }
|
||||
}
|
||||
|
||||
private void DispatchSpan(ReadOnlySpan<byte> utf8)
|
||||
{
|
||||
// JsonDocument.Parse via Utf8JsonReader: no intermediate string,
|
||||
// parsing directly from the pooled span.
|
||||
var reader = new Utf8JsonReader(utf8);
|
||||
using var doc = JsonDocument.ParseValue(ref reader);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (!_pending.TryRemove(idEl.GetInt64(), out var tcs)) return;
|
||||
if (root.TryGetProperty("error", out var err) && err.ValueKind != JsonValueKind.Null)
|
||||
tcs.TrySetException(new ElectrumServerException(err.ToString()));
|
||||
else
|
||||
tcs.TrySetResult(root.GetProperty("result").Clone());
|
||||
}
|
||||
else if (root.TryGetProperty("method", out var methodEl))
|
||||
{
|
||||
NotificationReceived?.Invoke(
|
||||
methodEl.GetString()!,
|
||||
root.GetProperty("params").Clone());
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_outgoing.Writer.Complete();
|
||||
_tcp.Close();
|
||||
try { await _readLoop; } catch { /* in chiusura */ }
|
||||
try { await _readLoop; } catch { }
|
||||
try { await _writeLoop; } catch { }
|
||||
_cts.Dispose();
|
||||
_writeLock.Dispose();
|
||||
_inFlight.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Errore restituito dal server (campo "error" della risposta JSON-RPC).</summary>
|
||||
/// <summary>Error returned by the server ("error" field of the JSON-RPC response).</summary>
|
||||
public sealed class ElectrumServerException(string error) : Exception(error);
|
||||
|
||||
/// <summary>
|
||||
/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9).
|
||||
/// Si sblocca con il reset esplicito dei certificati.
|
||||
/// The server certificate changed with respect to the saved one (TOFU, §9).
|
||||
/// It is unlocked with an explicit reset of the certificates.
|
||||
/// </summary>
|
||||
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
||||
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
||||
"Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.");
|
||||
$"The TLS certificate of {host}:{port} has changed from the one saved. " +
|
||||
"If the server renewed its certificate, reset the SSL certificates.")
|
||||
{
|
||||
public string Host { get; } = host;
|
||||
public int Port { get; } = port;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using PalladiumWallet.Core.Chain;
|
||||
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>Server di indicizzazione noto (bootstrap o scoperto dai peer).</summary>
|
||||
/// <summary>Known indexing server (bootstrap or discovered from peers).</summary>
|
||||
public sealed record KnownServer(string Host, int TcpPort, int SslPort, string? Version = null)
|
||||
{
|
||||
public int PortFor(bool useSsl) => useSsl ? SslPort : TcpPort;
|
||||
@@ -13,9 +13,9 @@ public sealed record KnownServer(string Host, int TcpPort, int SslPort, string?
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registro dei server (blueprint §9): parte dai bootstrap del profilo (§3),
|
||||
/// si arricchisce con i peer annunciati via server.peers.subscribe e persiste
|
||||
/// le scoperte su file per le sessioni successive.
|
||||
/// Server registry (blueprint §9): starts from the profile bootstrap list (§3),
|
||||
/// is enriched with peers announced via server.peers.subscribe, and persists
|
||||
/// discovered servers to file for subsequent sessions.
|
||||
/// </summary>
|
||||
public sealed class ServerRegistry
|
||||
{
|
||||
@@ -42,7 +42,7 @@ public sealed class ServerRegistry
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// File corrotto: si riparte dai soli bootstrap.
|
||||
// Corrupted file: fall back to bootstrap servers only.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,15 +52,15 @@ public sealed class ServerRegistry
|
||||
get { lock (_lock) return [.. _servers]; }
|
||||
}
|
||||
|
||||
/// <summary>Primo server noto: default quando l'utente non ne indica uno.</summary>
|
||||
/// <summary>First known server: default when the user does not specify one.</summary>
|
||||
public KnownServer? Default
|
||||
{
|
||||
get { lock (_lock) return _servers.FirstOrDefault(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chiede al server connesso i peer che annuncia e integra i nuovi nel
|
||||
/// registro (porte mancanti → default del profilo). Ritorna quanti sono nuovi.
|
||||
/// Queries the connected server for its announced peers and adds new ones to the
|
||||
/// registry (missing ports fall back to the profile defaults). Returns the count of new entries.
|
||||
/// </summary>
|
||||
public async Task<int> DiscoverAsync(ElectrumClient client, CancellationToken ct = default)
|
||||
{
|
||||
@@ -95,7 +95,7 @@ public sealed class ServerRegistry
|
||||
private void Save()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
|
||||
// Si salvano solo gli scoperti: i bootstrap vengono dal profilo.
|
||||
// Only discovered servers are saved; bootstrap servers come from the profile.
|
||||
var discovered = _servers
|
||||
.Where(s => !_profile.BootstrapServers.Any(b =>
|
||||
string.Equals(b.Host, s.Host, StringComparison.OrdinalIgnoreCase)))
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>A GitHub release newer than the running app.</summary>
|
||||
public sealed record LatestRelease(string Tag, string HtmlUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Checks the latest GitHub release for the project against the running app version.
|
||||
/// Best-effort only: any network/parse failure or an up-to-date app both resolve to null,
|
||||
/// so callers never need to distinguish "no update" from "couldn't check".
|
||||
/// </summary>
|
||||
public static class UpdateChecker
|
||||
{
|
||||
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
|
||||
|
||||
private sealed class GitHubReleaseResponse
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("html_url")]
|
||||
public string? HtmlUrl { get; set; }
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
using var http = handler is null
|
||||
? new HttpClient()
|
||||
: new HttpClient(handler, disposeHandler: false);
|
||||
http.Timeout = TimeSpan.FromSeconds(10);
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
||||
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var release = await response.Content
|
||||
.ReadFromJsonAsync<GitHubReleaseResponse>(ct)
|
||||
.ConfigureAwait(false);
|
||||
if (release?.TagName is not { Length: > 0 } tag) return null;
|
||||
|
||||
if (!TryParse(tag, out var remote) || !TryParse(currentVersion, out var local))
|
||||
return null;
|
||||
if (remote <= local) return null;
|
||||
|
||||
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
||||
internal static bool TryParse(string raw, out Version version)
|
||||
{
|
||||
var s = raw.Trim();
|
||||
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
||||
var dash = s.IndexOf('-');
|
||||
if (dash >= 0) s = s[..dash];
|
||||
return Version.TryParse(s, out version!);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,8 +5,8 @@ using PalladiumWallet.Core.Chain;
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
/// <summary>
|
||||
/// Header di blocco parsato dagli 80 byte canonici (blueprint §7.2):
|
||||
/// versione, prev_hash, merkle_root, timestamp, bits, nonce.
|
||||
/// Block header parsed from the canonical 80 bytes (blueprint §7.2):
|
||||
/// version, prev_hash, merkle_root, timestamp, bits, nonce.
|
||||
/// </summary>
|
||||
public sealed record BlockHeaderInfo(
|
||||
int Version, uint256 PrevHash, uint256 MerkleRoot, uint Timestamp, uint Bits, uint Nonce, uint256 Hash)
|
||||
@@ -18,7 +18,7 @@ public sealed record BlockHeaderInfo(
|
||||
public static BlockHeaderInfo Parse(byte[] raw)
|
||||
{
|
||||
if (raw.Length != Size)
|
||||
throw new ArgumentException($"Header di {raw.Length} byte (attesi {Size}).", nameof(raw));
|
||||
throw new ArgumentException($"Header is {raw.Length} bytes (expected {Size}).", nameof(raw));
|
||||
|
||||
return new BlockHeaderInfo(
|
||||
Version: BitConverter.ToInt32(raw, 0),
|
||||
@@ -31,11 +31,10 @@ public sealed record BlockHeaderInfo(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validazione SPV dell'header (§7.2): collegamento al precedente e,
|
||||
/// se il profilo non impone lo skip (catena LWMA), verifica PoW
|
||||
/// hash <= target dai bits. Il controllo di coerenza dei bits col
|
||||
/// retargeting completo richiede la storia: per la catena di riferimento
|
||||
/// è sostituito dai checkpoint (§7.3).
|
||||
/// SPV header validation (§7.2): linkage to the previous header and,
|
||||
/// if the profile does not mandate skip (LWMA chain), PoW verification
|
||||
/// hash <= target from bits. Full bits/retargeting consistency requires
|
||||
/// history — for this chain it is replaced by checkpoints (§7.3).
|
||||
/// </summary>
|
||||
public bool IsValidChild(uint256 expectedPrevHash, ChainProfile profile)
|
||||
{
|
||||
@@ -47,7 +46,7 @@ public sealed record BlockHeaderInfo(
|
||||
return Hash <= target;
|
||||
}
|
||||
|
||||
/// <summary>Confronto con un checkpoint hardcoded (§7.3).</summary>
|
||||
/// <summary>Comparison against a hardcoded checkpoint (§7.3).</summary>
|
||||
public bool MatchesCheckpoint(Checkpoint checkpoint) =>
|
||||
Hash == uint256.Parse(checkpoint.BlockHash);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ using NBitcoin;
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
/// <summary>
|
||||
/// Scripthash del protocollo di indicizzazione (blueprint §0/§10): SHA-256
|
||||
/// dello scriptPubKey con i byte in ordine inverso, esadecimale.
|
||||
/// Script hash for the indexing protocol (blueprint §0/§10): SHA-256 of the
|
||||
/// scriptPubKey with bytes reversed, encoded as lowercase hex.
|
||||
/// </summary>
|
||||
public static class Scripthash
|
||||
{
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
/// <summary>Indirizzo derivato e tracciato durante la sincronizzazione.</summary>
|
||||
/// <summary>Address derived and tracked during synchronisation.</summary>
|
||||
public sealed record TrackedAddress(
|
||||
BitcoinAddress Address, string ScriptHash, bool IsChange, int Index)
|
||||
{
|
||||
public Script ScriptPubKey => Address.ScriptPubKey;
|
||||
}
|
||||
|
||||
/// <summary>Esito di una passata di sincronizzazione.</summary>
|
||||
/// <summary>Result of a synchronisation pass.</summary>
|
||||
public sealed class SyncResult
|
||||
{
|
||||
public required int TipHeight { get; init; }
|
||||
public required long ConfirmedSats { get; init; }
|
||||
public required long UnconfirmedSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed but not yet spendable: coinbase outputs below maturity or regular
|
||||
/// outputs below <see cref="Chain.ChainProfile.MinConfirmations"/>. Subset of
|
||||
/// <see cref="ConfirmedSats"/>.
|
||||
/// </summary>
|
||||
public required long ImmatureSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
|
||||
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
|
||||
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
|
||||
/// (an immature coinbase can also be unverified), so never subtract both from
|
||||
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
|
||||
/// </summary>
|
||||
public required long PendingVerificationSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
|
||||
/// now — the true spendable balance. Computed directly from the same gate coin selection
|
||||
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
|
||||
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
|
||||
/// </summary>
|
||||
public required long SpendableSats { get; init; }
|
||||
public required int NextReceiveIndex { get; init; }
|
||||
public required int NextChangeIndex { get; init; }
|
||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||
@@ -29,82 +56,246 @@ public sealed class SyncResult
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sincronizzazione del wallet (blueprint §7.4): per ogni indirizzo calcola lo
|
||||
/// scripthash e si sottoscrive; scarica storico e transazioni; verifica ogni tx
|
||||
/// confermata con la prova di Merkle contro l'header del suo blocco (le risposte
|
||||
/// del server non sono fidate, §17); ricostruisce localmente UTXO e saldo;
|
||||
/// estende la scansione fino al gap limit (§5).
|
||||
/// Wallet synchronisation (blueprint §7.4).
|
||||
/// </summary>
|
||||
public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client, int gapLimit = 20)
|
||||
public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient client, int gapLimit = 20)
|
||||
{
|
||||
/// <summary>Avanzamento leggibile (per CLI e barra di stato GUI).</summary>
|
||||
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
||||
public event Action<string>? Progress;
|
||||
|
||||
// Cache tra le passate (stesso synchronizer per tutta la vita della
|
||||
// connessione): le tx già scaricate e le prove di Merkle già verificate a
|
||||
// una data altezza non si rifanno — le risincronizzazioni da notifica
|
||||
// costano solo ciò che è cambiato (modello Electrum).
|
||||
private readonly Dictionary<string, Transaction> _txCache = [];
|
||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||
/// <summary>
|
||||
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
|
||||
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
|
||||
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
|
||||
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
|
||||
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
|
||||
/// only completes once verification is fully done, for callers that need the final state.
|
||||
/// </summary>
|
||||
public event Action<SyncResult>? PartialResult;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||
|
||||
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
||||
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = 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();
|
||||
|
||||
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
|
||||
// being verified) would otherwise race on overlapping ranges and issue duplicate range
|
||||
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
|
||||
// serializing them costs nothing that matters.
|
||||
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
|
||||
|
||||
// Max headers requested per blockchain.block.headers call. The server may return fewer
|
||||
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
|
||||
private const int HeaderBatchSize = 2016;
|
||||
|
||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||
// discovery — already-used addresses are fetched in a single burst instead of
|
||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
||||
private int _knownReceiveIndex;
|
||||
private int _knownChangeIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-populates internal caches from data saved on disk.
|
||||
/// Call before SyncOnceAsync to avoid re-downloading already known transactions.
|
||||
/// </summary>
|
||||
public void PreloadCaches(
|
||||
Dictionary<string, string> rawTxHex,
|
||||
Dictionary<string, int> verifiedAt,
|
||||
Dictionary<int, string>? blockHeaders,
|
||||
int knownReceiveIndex,
|
||||
int knownChangeIndex,
|
||||
Network network)
|
||||
{
|
||||
foreach (var (txid, hex) in rawTxHex)
|
||||
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
||||
foreach (var (txid, height) in verifiedAt)
|
||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||
_verifiedAtHeight[txid] = height;
|
||||
if (blockHeaders is not null)
|
||||
foreach (var (height, hex) in blockHeaders)
|
||||
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
||||
_knownReceiveIndex = knownReceiveIndex;
|
||||
_knownChangeIndex = knownChangeIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current caches in a serialisable form for disk storage.
|
||||
/// Only confirmed transactions (height > 0) are included: unconfirmed ones
|
||||
/// may change (RBF) and must always be re-downloaded.
|
||||
/// </summary>
|
||||
public (Dictionary<string, string> RawTxHex,
|
||||
Dictionary<string, int> VerifiedAt,
|
||||
Dictionary<int, string> BlockHeaders)
|
||||
ExportCaches(Network network)
|
||||
{
|
||||
var rawHex = _verifiedAtHeight.Keys
|
||||
.Where(_txCache.ContainsKey)
|
||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||
|
||||
// Only already-completed headers: in-progress Task<string> instances
|
||||
// are not persisted (they will be re-fetched on the next sync if needed).
|
||||
var headers = new Dictionary<int, string>();
|
||||
foreach (var (height, task) in _headerFetches)
|
||||
if (task.IsCompletedSuccessfully)
|
||||
headers[height] = task.Result;
|
||||
|
||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers);
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tip = await client.SubscribeHeadersAsync(ct);
|
||||
Progress?.Invoke($"tip della catena: {tip.Height}");
|
||||
Progress?.Invoke($"chain tip: {tip.Height}");
|
||||
|
||||
// 1-2. Scansione indirizzi con gap limit, per catena receiving e change.
|
||||
// 1-2. Address scanning.
|
||||
var tracked = new List<TrackedAddress>();
|
||||
var historyByAddress = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||
var nextReceive = await ScanChainAsync(isChange: false, tracked, historyByAddress, ct);
|
||||
var nextChange = await ScanChainAsync(isChange: true, tracked, historyByAddress, ct);
|
||||
int nextReceive, nextChange;
|
||||
|
||||
// 3. Storico unico (txid → altezza massima riportata).
|
||||
if (account.FixedAddresses is { } fixedAddresses)
|
||||
{
|
||||
foreach (var (addr, isChange, idx) in fixedAddresses)
|
||||
tracked.Add(new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, idx));
|
||||
nextReceive = tracked.Count(t => !t.IsChange);
|
||||
nextChange = 0;
|
||||
|
||||
await Task.WhenAll(tracked.Select(t => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var h = await client.GetHistoryAsync(t.ScriptHash, ct);
|
||||
if (h.Count > 0) historyByAddress[t.ScriptHash] = h;
|
||||
}, ct)).Concat(tracked.Select(t =>
|
||||
RetryOnBusyAsync(() => client.SubscribeScripthashAsync(t.ScriptHash, ct), ct))));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Receive and change chains in parallel (independent by definition).
|
||||
// ScanChainAsync uses _knownReceiveIndex/_knownChangeIndex for incremental
|
||||
// discovery: already-used addresses are fetched in a single burst.
|
||||
var receiveTask = ScanChainAsync(isChange: false, _knownReceiveIndex, ct);
|
||||
var changeTask = ScanChainAsync(isChange: true, _knownChangeIndex, ct);
|
||||
var rxScan = await receiveTask;
|
||||
var chScan = await changeTask;
|
||||
|
||||
tracked.AddRange(rxScan.Tracked);
|
||||
tracked.AddRange(chScan.Tracked);
|
||||
foreach (var (k, v) in rxScan.History) historyByAddress[k] = v;
|
||||
foreach (var (k, v) in chScan.History) historyByAddress[k] = v;
|
||||
nextReceive = rxScan.NextIndex;
|
||||
nextChange = chScan.NextIndex;
|
||||
|
||||
var gapAddresses = tracked.Where(t =>
|
||||
(!t.IsChange && t.Index >= nextReceive && t.Index < nextReceive + gapLimit) ||
|
||||
( t.IsChange && t.Index >= nextChange && t.Index < nextChange + gapLimit)).ToList();
|
||||
if (gapAddresses.Count > 0)
|
||||
await Task.WhenAll(gapAddresses.Select(t =>
|
||||
RetryOnBusyAsync(() => client.SubscribeScripthashAsync(t.ScriptHash, ct), ct)));
|
||||
}
|
||||
|
||||
// 3. Merged history (txid → highest reported height).
|
||||
var txHeights = new Dictionary<string, int>();
|
||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||
txHeights[item.TxHash] = item.Height;
|
||||
|
||||
// 4. Scarica in parallelo le sole transazioni nuove.
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
Progress?.Invoke($"scarico {missing.Count} transazioni…");
|
||||
var downloaded = await Task.WhenAll(missing.Select(async txid =>
|
||||
(txid, Transaction.Parse(await client.GetTransactionAsync(txid, ct), network))));
|
||||
foreach (var (txid, tx) in downloaded)
|
||||
_txCache[txid] = tx;
|
||||
}
|
||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||
|
||||
// 5. Verifica Merkle delle confermate (§7.4 punto 4), in parallelo e
|
||||
// solo per le tx non ancora verificate a quell'altezza.
|
||||
// 4. Download missing transactions — needed to compute amounts/UTXOs locally.
|
||||
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
|
||||
// wallet with thousands of transactions, downloads finish in seconds while proofs can
|
||||
// take much longer over a high-latency link, and the wallet has everything it needs to
|
||||
// show balance/history the moment downloads are done. Verification then continues in
|
||||
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
|
||||
// while coin selection stays locked out of any UTXO until its own proof is checked
|
||||
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
|
||||
// cannot get a fabricated balance spent just because it was shown early.
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||
var toVerify = txHeights
|
||||
.Where(kv => kv.Value > 0
|
||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||
.ToList();
|
||||
if (toVerify.Count > 0)
|
||||
{
|
||||
Progress?.Invoke($"verifico {toVerify.Count} prove di Merkle…");
|
||||
await Task.WhenAll(toVerify.Select(async kv =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
var headerTask = client.GetBlockHeaderAsync(height, ct);
|
||||
var proof = await proofTask;
|
||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Prova di Merkle non valida per {txid} (blocco {height}): server non affidabile.");
|
||||
}));
|
||||
foreach (var (txid, height) in toVerify)
|
||||
_verifiedAtHeight[txid] = height;
|
||||
}
|
||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||
|
||||
// 6. Ricostruzione locale degli UTXO: accrediti = output verso nostri
|
||||
// script; spesi = outpoint consumati da una qualunque tx del wallet.
|
||||
if (missing.Count > 0 || toVerify.Count > 0)
|
||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
||||
|
||||
var dlDone = 0;
|
||||
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
if (n % 50 == 0 || n == missing.Count)
|
||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
|
||||
}, ct)));
|
||||
|
||||
SyncResult BuildSnapshot() =>
|
||||
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
|
||||
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
|
||||
var merkDone = 0;
|
||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
// Anchor first: on a checkpointed height this fills _headerFetches[height]
|
||||
// via the batched range fetch (§7.3), so the header lookup below is a cache
|
||||
// hit instead of a second individual blockchain.block.header RPC per tx —
|
||||
// halves round-trips for this stage on mainnet, where it matters most on
|
||||
// high-latency mobile links. Falls back to an individual fetch when no
|
||||
// checkpoint covers this height (testnet/regtest today).
|
||||
await AnchorToCheckpointAsync(height, ct);
|
||||
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
|
||||
var header = BlockHeaderInfo.Parse(headerHex);
|
||||
var proof = await proofTask;
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||
_verifiedAtHeight[txid] = height;
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
if (n % 50 == 0 || n == toVerify.Count)
|
||||
Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||
if (n % PartialResultBatchSize == 0)
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
}, ct)).ToList();
|
||||
|
||||
if (merkTasks.Count > 0)
|
||||
await Task.WhenAll(merkTasks);
|
||||
|
||||
return BuildSnapshot();
|
||||
}
|
||||
|
||||
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
|
||||
// every single verified proof would make the background verification phase itself O(n²) for
|
||||
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
|
||||
// visibly without that cost.
|
||||
private const int PartialResultBatchSize = 200;
|
||||
|
||||
private bool IsTxVerified(string txid, int height) =>
|
||||
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
|
||||
|
||||
/// <summary>
|
||||
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
|
||||
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
|
||||
/// transaction downloads finish (proofs still pending), and again as verification progresses,
|
||||
/// each time reflecting whichever transactions have been proof-checked so far.
|
||||
/// </summary>
|
||||
private SyncResult BuildResult(
|
||||
int tipHeight,
|
||||
List<TrackedAddress> tracked,
|
||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
|
||||
Dictionary<string, int> txHeights,
|
||||
int nextReceive,
|
||||
int nextChange)
|
||||
{
|
||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||
|
||||
// 6. Local UTXO reconstruction.
|
||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||
var spent = transactions.Values
|
||||
.SelectMany(tx => tx.Inputs)
|
||||
@@ -114,6 +305,8 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
var utxos = new List<CachedUtxo>();
|
||||
foreach (var (txid, tx) in transactions)
|
||||
{
|
||||
var height = txHeights[txid];
|
||||
var verifiedTx = IsTxVerified(txid, height);
|
||||
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
||||
{
|
||||
var output = tx.Outputs[vout];
|
||||
@@ -123,21 +316,24 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
continue;
|
||||
utxos.Add(new CachedUtxo
|
||||
{
|
||||
Txid = txid,
|
||||
Vout = vout,
|
||||
ValueSats = output.Value.Satoshi,
|
||||
Address = addr.Address.ToString(),
|
||||
IsChange = addr.IsChange,
|
||||
AddressIndex = addr.Index,
|
||||
Height = txHeights[txid],
|
||||
Txid = txid,
|
||||
Vout = vout,
|
||||
ValueSats = output.Value.Satoshi,
|
||||
Address = addr.Address.ToString(),
|
||||
IsChange = addr.IsChange,
|
||||
AddressIndex = addr.Index,
|
||||
Height = height,
|
||||
IsCoinbase = tx.IsCoinBase,
|
||||
Verified = verifiedTx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Delta per voce di storico (entrate - uscite del wallet).
|
||||
// 7. Delta per history entry.
|
||||
var history = new List<CachedTx>();
|
||||
foreach (var (txid, tx) in transactions)
|
||||
{
|
||||
var height = txHeights[txid];
|
||||
var received = tx.Outputs
|
||||
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
||||
.Sum(o => o.Value.Satoshi);
|
||||
@@ -147,21 +343,19 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
.Sum(i => transactions[i.PrevOut.Hash.ToString()].Outputs[i.PrevOut.N].Value.Satoshi);
|
||||
history.Add(new CachedTx
|
||||
{
|
||||
Txid = txid,
|
||||
Height = txHeights[txid],
|
||||
Txid = txid,
|
||||
Height = height,
|
||||
DeltaSats = received - sentSats,
|
||||
Verified = verified[txid],
|
||||
Verified = IsTxVerified(txid, height),
|
||||
});
|
||||
}
|
||||
history.Sort((a, b) =>
|
||||
{
|
||||
// Non confermate (height<=0) in cima, poi per altezza decrescente.
|
||||
var ha = a.Height <= 0 ? int.MaxValue : a.Height;
|
||||
var hb = b.Height <= 0 ? int.MaxValue : b.Height;
|
||||
return hb.CompareTo(ha);
|
||||
});
|
||||
|
||||
// Saldo e numero di transazioni per singolo indirizzo (vista indirizzi).
|
||||
var balanceByAddress = utxos
|
||||
.GroupBy(u => u.Address)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(u => u.ValueSats));
|
||||
@@ -169,64 +363,168 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
.OrderBy(t => t.IsChange).ThenBy(t => t.Index)
|
||||
.Select(t => new CachedAddress
|
||||
{
|
||||
Address = t.Address.ToString(),
|
||||
IsChange = t.IsChange,
|
||||
Index = t.Index,
|
||||
BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()),
|
||||
TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0,
|
||||
Address = t.Address.ToString(),
|
||||
IsChange = t.IsChange,
|
||||
Index = t.Index,
|
||||
BalanceSats = balanceByAddress.GetValueOrDefault(t.Address.ToString()),
|
||||
TxCount = historyByAddress.TryGetValue(t.ScriptHash, out var h) ? h.Count : 0,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new SyncResult
|
||||
{
|
||||
TipHeight = tip.Height,
|
||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||
TipHeight = tipHeight,
|
||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||
ImmatureSats = utxos.Where(u =>
|
||||
u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile))
|
||||
.Sum(u => u.ValueSats),
|
||||
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
|
||||
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
|
||||
NextReceiveIndex = nextReceive,
|
||||
NextChangeIndex = nextChange,
|
||||
History = history,
|
||||
Utxos = utxos,
|
||||
Addresses = tracked,
|
||||
AddressRows = addressRows,
|
||||
Transactions = transactions,
|
||||
NextChangeIndex = nextChange,
|
||||
History = history,
|
||||
Utxos = utxos,
|
||||
Addresses = tracked,
|
||||
AddressRows = addressRows,
|
||||
Transactions = transactions,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scansiona una catena (receiving o change) finché trova gapLimit indirizzi
|
||||
/// vuoti consecutivi (§5), procedendo a batch paralleli di gapLimit
|
||||
/// subscribe per volta (le richieste JSON-RPC sono pipelinabili).
|
||||
/// Ritorna il primo indice non usato.
|
||||
/// 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<int> ScanChainAsync(bool isChange, List<TrackedAddress> tracked,
|
||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress, CancellationToken ct)
|
||||
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;
|
||||
|
||||
await FetchHeaderRangeAsync(cp.Height, height, ct);
|
||||
|
||||
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
|
||||
.ToArray();
|
||||
|
||||
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>
|
||||
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
|
||||
/// is present in <see cref="_headerFetches"/>, downloading gaps with
|
||||
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
|
||||
/// </summary>
|
||||
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
|
||||
{
|
||||
await _headerRangeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var h = fromHeight;
|
||||
while (h <= toHeightInclusive)
|
||||
{
|
||||
if (_headerFetches.ContainsKey(h)) { h++; continue; }
|
||||
|
||||
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
|
||||
var range = await client.GetBlockHeadersAsync(h, requested, ct);
|
||||
if (range.Count == 0)
|
||||
throw new SpvVerificationException(
|
||||
$"Server returned no headers starting at height {h}: server is not trustworthy.");
|
||||
|
||||
for (var i = 0; i < range.Count; i++)
|
||||
{
|
||||
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
|
||||
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
|
||||
}
|
||||
h += range.Count;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_headerRangeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans one chain (receiving or change).
|
||||
///
|
||||
/// Phase 1 — known addresses (0..fromIndex-1): all GetHistoryAsync calls are
|
||||
/// fired in a single parallel burst, with no sequential batching. A wallet
|
||||
/// with 100 used addresses costs 1 RTT instead of 5 sequential gap-limit rounds.
|
||||
///
|
||||
/// Phase 2 — discovery from fromIndex onwards: gap-limit batching as before,
|
||||
/// required to know when to stop.
|
||||
/// </summary>
|
||||
private async Task<(int NextIndex,
|
||||
List<TrackedAddress> Tracked,
|
||||
Dictionary<string, IReadOnlyList<HistoryItem>> History)>
|
||||
ScanChainAsync(bool isChange, int fromIndex, CancellationToken ct)
|
||||
{
|
||||
var tracked = new List<TrackedAddress>();
|
||||
var history = new Dictionary<string, IReadOnlyList<HistoryItem>>();
|
||||
|
||||
// Phase 1: single burst for all already-known addresses.
|
||||
if (fromIndex > 0)
|
||||
{
|
||||
var known = Enumerable.Range(0, fromIndex).Select(i =>
|
||||
{
|
||||
var addr = account.GetAddress(isChange, i);
|
||||
return new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, i);
|
||||
}).ToList();
|
||||
tracked.AddRange(known);
|
||||
|
||||
var knownHistories = await Task.WhenAll(
|
||||
known.Select(t => RetryOnBusyAsync(
|
||||
() => client.GetHistoryAsync(t.ScriptHash, ct), ct)));
|
||||
for (var i = 0; i < known.Count; i++)
|
||||
if (knownHistories[i].Count > 0)
|
||||
history[known[i].ScriptHash] = knownHistories[i];
|
||||
}
|
||||
|
||||
// Phase 2: gap-limit discovery from fromIndex onwards.
|
||||
var consecutiveEmpty = 0;
|
||||
var index = 0;
|
||||
var firstUnused = 0;
|
||||
var index = fromIndex;
|
||||
var firstUnused = fromIndex;
|
||||
|
||||
while (consecutiveEmpty < gapLimit)
|
||||
{
|
||||
var batch = Enumerable.Range(index, gapLimit).Select(i =>
|
||||
{
|
||||
var address = account.GetAddress(isChange, i);
|
||||
return new TrackedAddress(address, Scripthash.FromAddress(address), isChange, i);
|
||||
var addr = account.GetAddress(isChange, i);
|
||||
return new TrackedAddress(addr, Scripthash.FromAddress(addr), isChange, i);
|
||||
}).ToList();
|
||||
index += batch.Count;
|
||||
tracked.AddRange(batch);
|
||||
|
||||
// La subscribe registra anche la notifica push per i cambi futuri.
|
||||
var statuses = await Task.WhenAll(
|
||||
batch.Select(t => client.SubscribeScripthashAsync(t.ScriptHash, ct)));
|
||||
|
||||
var used = batch.Where((t, i) => statuses[i] is not null).ToList();
|
||||
var histories = await Task.WhenAll(
|
||||
used.Select(t => client.GetHistoryAsync(t.ScriptHash, ct)));
|
||||
for (var i = 0; i < used.Count; i++)
|
||||
historyByAddress[used[i].ScriptHash] = histories[i];
|
||||
batch.Select(t => RetryOnBusyAsync(
|
||||
() => client.GetHistoryAsync(t.ScriptHash, ct), ct)));
|
||||
|
||||
for (var i = 0; i < batch.Count && consecutiveEmpty < gapLimit; i++)
|
||||
{
|
||||
if (statuses[i] is null)
|
||||
if (histories[i].Count == 0)
|
||||
{
|
||||
consecutiveEmpty++;
|
||||
}
|
||||
@@ -234,12 +532,64 @@ public sealed class WalletSynchronizer(HdAccount account, ElectrumClient client,
|
||||
{
|
||||
consecutiveEmpty = 0;
|
||||
firstUnused = batch[i].Index + 1;
|
||||
history[batch[i].ScriptHash] = histories[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstUnused;
|
||||
|
||||
return (firstUnused, tracked, history);
|
||||
}
|
||||
|
||||
// Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
|
||||
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
|
||||
// rather than guessed at from wall-clock numbers alone.
|
||||
private int _busyRetries;
|
||||
|
||||
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||
{
|
||||
var delay = 200;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try { await op(); return; }
|
||||
catch (ElectrumServerException ex)
|
||||
when (IsBusy(ex) && attempt < 7)
|
||||
{
|
||||
ReportBusyRetry(delay);
|
||||
await Task.Delay(delay, ct);
|
||||
delay = Math.Min(delay * 2, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||
{
|
||||
var delay = 200;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try { return await op(); }
|
||||
catch (ElectrumServerException ex)
|
||||
when (IsBusy(ex) && attempt < 7)
|
||||
{
|
||||
ReportBusyRetry(delay);
|
||||
await Task.Delay(delay, ct);
|
||||
delay = Math.Min(delay * 2, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportBusyRetry(int delayMs)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _busyRetries);
|
||||
if (n == 1 || n % 20 == 0)
|
||||
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
|
||||
}
|
||||
|
||||
private static bool IsBusy(ElectrumServerException ex) =>
|
||||
ex.Message.Contains("-102") ||
|
||||
ex.Message.Contains("-101") ||
|
||||
ex.Message.Contains("server busy", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("excessive resource usage", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>La verifica SPV è fallita: i dati del server contraddicono le prove (§17).</summary>
|
||||
/// <summary>SPV verification failed: server data contradicts the proofs (§17).</summary>
|
||||
public sealed class SpvVerificationException(string message) : Exception(message);
|
||||
|
||||
@@ -3,18 +3,23 @@ using System.Text.Json;
|
||||
namespace PalladiumWallet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Configurazione globale dell'applicazione (blueprint §8), separata dai file
|
||||
/// wallet: lingua, unità di visualizzazione, ecc. Persistita in config.json
|
||||
/// nella radice dei dati (vale per tutte le reti).
|
||||
/// Global application configuration (blueprint §8), separate from wallet
|
||||
/// files: language, display unit, etc. Persisted in config.json
|
||||
/// in the data root (applies to all networks).
|
||||
/// </summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
/// <summary>Codice lingua UI.</summary>
|
||||
/// <summary>UI language code.</summary>
|
||||
public string Language { get; set; } = "en";
|
||||
|
||||
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||
/// <summary>Display unit for amounts (see <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||
public string Unit { get; set; } = "PLM";
|
||||
|
||||
/// <summary>Last successfully connected server, persisted across sessions.</summary>
|
||||
public string? LastServerHost { get; set; }
|
||||
public int? LastServerPort { get; set; }
|
||||
public bool LastServerUseSsl { get; set; } = true;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public static AppConfig Load(string? path = null)
|
||||
@@ -28,7 +33,7 @@ public sealed class AppConfig
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Config corrotta: si riparte dai default senza bloccare l'avvio.
|
||||
// Corrupted config: fall back to defaults without blocking startup.
|
||||
return new AppConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,110 @@ using PalladiumWallet.Core.Chain;
|
||||
namespace PalladiumWallet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
|
||||
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
|
||||
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
|
||||
/// esiste una cartella "palladium-data".
|
||||
/// Per-platform data paths (blueprint §8). The data root can be:
|
||||
/// 1. <b>portable</b>: "palladium-data" folder next to the executable;
|
||||
/// 2. <b>custom</b>: chosen by the user at first launch and stored in
|
||||
/// a small "pointer" file at a fixed bootstrap location;
|
||||
/// 3. <b>legacy</b>: old location (%APPDATA%/PalladiumWallet) if it already holds data;
|
||||
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) or %ProgramFiles%\PalladiumWallet (Windows).
|
||||
/// Under the root there is a per-network subfolder (config, wallet, header, certificates).
|
||||
/// </summary>
|
||||
public static class AppPaths
|
||||
{
|
||||
public const string PortableDirName = "palladium-data";
|
||||
|
||||
/// <summary>Application folder name, used in the various paths.</summary>
|
||||
public const string AppDirName = "PalladiumWallet";
|
||||
|
||||
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
|
||||
public static string? OverrideDataRoot { get; set; }
|
||||
|
||||
// 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>
|
||||
/// Default data root, following each platform's convention:
|
||||
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
|
||||
/// Linux/macOS → ~/.palladium-wallet (lowercase dotfolder, like ~/.bitcoin).
|
||||
/// Per-user and always writable, without administrator privileges.
|
||||
/// </summary>
|
||||
public static string DefaultDataRoot()
|
||||
{
|
||||
if (DefaultRootOverride is { } o)
|
||||
return o;
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
AppDirName);
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(home, ".palladium-wallet");
|
||||
}
|
||||
|
||||
/// <summary>Pointer file to the user-chosen data root. Lives at a
|
||||
/// bootstrap location that is always writable and independent of the data root.</summary>
|
||||
private static string LocationPointerPath() =>
|
||||
Path.Combine(
|
||||
BootstrapDirOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDirName),
|
||||
"data-location");
|
||||
|
||||
private static string PortableRoot() =>
|
||||
Path.Combine(PortableBaseOverride ?? AppContext.BaseDirectory, PortableDirName);
|
||||
|
||||
private static bool HasData(string root) =>
|
||||
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
||||
|
||||
/// <summary>Effective data root, following the precedence order documented on the class.</summary>
|
||||
public static string DataRoot()
|
||||
{
|
||||
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
||||
if (!string.IsNullOrEmpty(OverrideDataRoot))
|
||||
return OverrideDataRoot;
|
||||
|
||||
var portable = PortableRoot();
|
||||
if (Directory.Exists(portable))
|
||||
return portable;
|
||||
|
||||
// Windows → %APPDATA%\PalladiumWallet
|
||||
// Linux → $XDG_CONFIG_HOME/PalladiumWallet (default ~/.config/PalladiumWallet)
|
||||
// macOS → ~/Library/Application Support/PalladiumWallet
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"PalladiumWallet");
|
||||
if (ReadPointer() is { } custom)
|
||||
return custom;
|
||||
|
||||
return DefaultDataRoot();
|
||||
}
|
||||
|
||||
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
|
||||
/// <summary>
|
||||
/// true if the data location is already determined and need not be asked
|
||||
/// of the user: portable mode, override, pointer already written, or
|
||||
/// data already present at the default location.
|
||||
/// </summary>
|
||||
public static bool IsDataLocationConfigured() =>
|
||||
!string.IsNullOrEmpty(OverrideDataRoot)
|
||||
|| Directory.Exists(PortableRoot())
|
||||
|| ReadPointer() is not null
|
||||
|| HasData(DefaultDataRoot());
|
||||
|
||||
/// <summary>Stores the user-chosen data root and creates it on disk.</summary>
|
||||
public static void ConfigureDataLocation(string root)
|
||||
{
|
||||
root = Path.GetFullPath(root.Trim());
|
||||
Directory.CreateDirectory(root);
|
||||
var pointer = LocationPointerPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(pointer)!);
|
||||
File.WriteAllText(pointer, root);
|
||||
}
|
||||
|
||||
private static string? ReadPointer()
|
||||
{
|
||||
var pointer = LocationPointerPath();
|
||||
if (!File.Exists(pointer))
|
||||
return null;
|
||||
var path = File.ReadAllText(pointer).Trim();
|
||||
return string.IsNullOrEmpty(path) ? null : path;
|
||||
}
|
||||
|
||||
/// <summary>Network data folder (config, wallet, header, certificates).</summary>
|
||||
public static string ForNetwork(NetKind net)
|
||||
{
|
||||
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
|
||||
@@ -44,6 +124,15 @@ public static class AppPaths
|
||||
public static string DefaultWalletPath(NetKind net) =>
|
||||
Path.Combine(WalletsDir(net), "default.wallet.json");
|
||||
|
||||
/// <summary>All wallet files for the network, ordered by name (multi-wallet §8).</summary>
|
||||
public static IReadOnlyList<string> WalletFiles(NetKind net)
|
||||
{
|
||||
var dir = WalletsDir(net);
|
||||
return Directory.EnumerateFiles(dir, "*.wallet.json")
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string CertificatePinsPath(NetKind net) =>
|
||||
Path.Combine(ForNetwork(net), "server-certs.json");
|
||||
|
||||
|
||||
@@ -5,14 +5,18 @@ using System.Text.Json;
|
||||
namespace PalladiumWallet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
|
||||
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
|
||||
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
|
||||
/// Wallet file encryption (blueprint §8/§17): AES-256-GCM with a key derived
|
||||
/// from the password via PBKDF2-HMAC-SHA512. The container is self-describing JSON
|
||||
/// (kdf, parameters, salt, nonce) to allow future parameter upgrades.
|
||||
/// </summary>
|
||||
public static class EncryptedFile
|
||||
{
|
||||
private const string Format = "plm-wallet-aesgcm-v1";
|
||||
private const int DefaultIterations = 600_000;
|
||||
// Upper bound on the iteration count read from the container: the value is
|
||||
// attacker-controlled in a tampered file, and an absurd count would hang the
|
||||
// wallet at open (PBKDF2 DoS). Leaves ample room for future increases.
|
||||
private const int MaxIterations = 10_000_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
@@ -26,13 +30,21 @@ public static class EncryptedFile
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(fileContent);
|
||||
return doc.RootElement.TryGetProperty("Format", out var f)
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("Format", out var f)
|
||||
&& f.ValueKind == JsonValueKind.String
|
||||
&& f.GetString() == Format;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Invalid UTF-16 (lone surrogates) cannot be transcoded for parsing:
|
||||
// certainly not an encrypted container.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Encrypt(string plaintext, string password)
|
||||
@@ -54,27 +66,55 @@ public static class EncryptedFile
|
||||
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||
}
|
||||
|
||||
/// <summary>Lancia <see cref="WrongPasswordException"/> se la password è errata o il file è manomesso.</summary>
|
||||
/// <summary>
|
||||
/// Throws <see cref="WrongPasswordException"/> if the password is wrong or the
|
||||
/// ciphertext is tampered with, <see cref="InvalidDataException"/> for any
|
||||
/// malformed container (broken JSON, missing fields, bad base64, wrong
|
||||
/// nonce/tag size, out-of-range iteration count) — never a raw parsing exception.
|
||||
/// </summary>
|
||||
public static string Decrypt(string fileContent, string password)
|
||||
{
|
||||
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
||||
?? throw new InvalidDataException("Contenitore cifrato non valido.");
|
||||
Container? container;
|
||||
try
|
||||
{
|
||||
container = JsonSerializer.Deserialize<Container>(fileContent);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
}
|
||||
if (container is null)
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
if (container.Format != Format)
|
||||
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
|
||||
throw new InvalidDataException($"Unknown container format: {container.Format}");
|
||||
if (container.Iterations is <= 0 or > MaxIterations)
|
||||
throw new InvalidDataException($"Iteration count out of range: {container.Iterations}");
|
||||
|
||||
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
|
||||
var cipher = Convert.FromBase64String(container.Data);
|
||||
byte[] salt, nonce, tag, cipher;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(container.Salt);
|
||||
nonce = Convert.FromBase64String(container.Nonce);
|
||||
tag = Convert.FromBase64String(container.Tag);
|
||||
cipher = Convert.FromBase64String(container.Data);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||
{
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
}
|
||||
if (nonce.Length != NonceSize || tag.Length != TagSize)
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
|
||||
var key = DeriveKey(password, salt, container.Iterations);
|
||||
var plain = new byte[cipher.Length];
|
||||
try
|
||||
{
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Decrypt(
|
||||
Convert.FromBase64String(container.Nonce), cipher,
|
||||
Convert.FromBase64String(container.Tag), plain);
|
||||
aes.Decrypt(nonce, cipher, tag, plain);
|
||||
}
|
||||
catch (AuthenticationTagMismatchException)
|
||||
{
|
||||
// Il tag GCM autentica: password errata e manomissione sono indistinguibili.
|
||||
// The GCM tag authenticates: a wrong password and tampering are indistinguishable.
|
||||
throw new WrongPasswordException();
|
||||
}
|
||||
finally
|
||||
@@ -88,7 +128,7 @@ public static class EncryptedFile
|
||||
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
|
||||
}
|
||||
|
||||
/// <summary>Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).</summary>
|
||||
/// <summary>Wrong password (or tampered wallet file: the GCM tag does not distinguish the two cases).</summary>
|
||||
public sealed class WrongPasswordException : Exception
|
||||
{
|
||||
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
|
||||
|
||||
@@ -4,9 +4,9 @@ using System.Text.Json.Serialization;
|
||||
namespace PalladiumWallet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
|
||||
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
|
||||
/// l'intero documento via <see cref="EncryptedFile"/>.
|
||||
/// Wallet file schema (blueprint §8), versioned to allow automatic
|
||||
/// migrations on opening. Encryption (when there is a password) wraps
|
||||
/// the entire document via <see cref="EncryptedFile"/>.
|
||||
/// </summary>
|
||||
public sealed class WalletDocument
|
||||
{
|
||||
@@ -14,36 +14,48 @@ public sealed class WalletDocument
|
||||
|
||||
public int Version { get; set; } = CurrentVersion;
|
||||
|
||||
/// <summary>Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.</summary>
|
||||
/// <summary>Network name (mainnet/testnet/regtest), as ChainProfile.NetName.</summary>
|
||||
public required string Network { get; set; }
|
||||
|
||||
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
|
||||
/// <summary>Script type (name of the ScriptKind enum).</summary>
|
||||
public required string ScriptKind { get; set; }
|
||||
|
||||
/// <summary>Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.</summary>
|
||||
/// <summary>BIP39 mnemonic in plaintext in the document (the document must be encrypted!); null if watch-only.</summary>
|
||||
public string? Mnemonic { get; set; }
|
||||
|
||||
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
||||
/// <summary>BIP39 extension word (§4.1); null if absent.</summary>
|
||||
public string? Passphrase { get; set; }
|
||||
|
||||
/// <summary>Path di account (es. "84'/746'/0'").</summary>
|
||||
public required string AccountPath { get; set; }
|
||||
/// <summary>Account path (e.g. "84'/746'/0'"); null for imported WIF.</summary>
|
||||
public string? AccountPath { get; set; }
|
||||
|
||||
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
|
||||
public required string AccountXpub { get; set; }
|
||||
/// <summary>Account xpub in SLIP-132 for HD wallets; null for imported WIF.</summary>
|
||||
public string? AccountXpub { get; set; }
|
||||
|
||||
/// <summary>Account xprv in SLIP-132; present only for import from xprv without seed.</summary>
|
||||
public string? AccountXprv { get; set; }
|
||||
|
||||
public string? MasterFingerprint { get; set; }
|
||||
|
||||
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
||||
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
||||
public List<string>? WifKeys { get; set; }
|
||||
|
||||
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
||||
public int GapLimit { get; set; } = 20;
|
||||
|
||||
/// <summary>Etichette per indirizzo/txid (§12).</summary>
|
||||
/// <summary>Labels by address/txid (§12).</summary>
|
||||
public Dictionary<string, string> Labels { get; set; } = [];
|
||||
|
||||
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
||||
/// <summary>Contact address book (name + blockchain address).</summary>
|
||||
public List<StoredContact> Contacts { get; set; } = [];
|
||||
|
||||
/// <summary>Cache of the last synced state (balance/history viewable offline).</summary>
|
||||
public SyncCache? Cache { get; set; }
|
||||
|
||||
public bool IsWatchOnly => Mnemonic is null;
|
||||
public bool IsWatchOnly =>
|
||||
Mnemonic is null &&
|
||||
AccountXprv is null &&
|
||||
(WifKeys is null || WifKeys.Count == 0);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -57,6 +69,7 @@ public sealed class WalletDocument
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
|
||||
?? throw new InvalidDataException("File wallet non valido.");
|
||||
// string literal above intentionally left untranslated
|
||||
return Migrate(doc);
|
||||
}
|
||||
|
||||
@@ -69,20 +82,61 @@ public sealed class WalletDocument
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Contatto in rubrica: nome leggibile + indirizzo blockchain.</summary>
|
||||
public sealed class StoredContact
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Address { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
|
||||
public sealed class SyncCache
|
||||
{
|
||||
public int TipHeight { get; set; }
|
||||
public long ConfirmedSats { get; set; }
|
||||
public long UnconfirmedSats { get; set; }
|
||||
|
||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||
public long ImmatureSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
|
||||
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
|
||||
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
|
||||
/// </summary>
|
||||
public long PendingVerificationSats { get; set; }
|
||||
|
||||
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
|
||||
public long SpendableSats { get; set; }
|
||||
public int NextReceiveIndex { get; set; }
|
||||
public int NextChangeIndex { get; set; }
|
||||
public List<CachedTx> History { get; set; } = [];
|
||||
public List<CachedUtxo> Utxos { get; set; } = [];
|
||||
public List<CachedAddress> Addresses { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Raw transaction cache (txid → hex). Avoids re-downloading confirmed
|
||||
/// transactions on every launch: confirmed txs are immutable by definition.
|
||||
/// May be partially populated after an interrupted sync (e.g. -101 error);
|
||||
/// the synchronizer resumes from where it left off.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? RawTxHex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Already-verified Merkle proofs (txid → block height). Avoids re-verifying
|
||||
/// the same proofs on every launch: confirmations are immutable.
|
||||
/// </summary>
|
||||
public Dictionary<string, int>? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw headers by height (height → hex). Immutable: never re-fetched once saved.
|
||||
/// Eliminates GetBlockHeader calls for Merkle proofs already verified in
|
||||
/// subsequent syncs.
|
||||
/// </summary>
|
||||
public Dictionary<int, string>? BlockHeaders { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Indirizzo scansionato con saldo proprio e numero di transazioni (vista indirizzi).</summary>
|
||||
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
||||
public sealed class CachedAddress
|
||||
{
|
||||
public required string Address { get; set; }
|
||||
@@ -97,6 +151,13 @@ public sealed class CachedTx
|
||||
public required string Txid { get; set; }
|
||||
public int Height { get; set; }
|
||||
public long DeltaSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once this tx's Merkle proof has actually been checked against a
|
||||
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
|
||||
/// be pending its own proof while background verification catches up). Always
|
||||
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -109,5 +170,13 @@ public sealed class CachedUtxo
|
||||
public bool IsChange { get; set; }
|
||||
public int AddressIndex { get; set; }
|
||||
public int Height { get; set; }
|
||||
public bool IsCoinbase { get; set; }
|
||||
public bool Frozen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
|
||||
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
|
||||
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace PalladiumWallet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Exclusive lock on a wallet file held for the lifetime of the session.
|
||||
/// The real lock is the open FileStream with FileShare.None — the .lock file
|
||||
/// is just its vessel. The OS releases it automatically on process exit or crash.
|
||||
/// </summary>
|
||||
public sealed class WalletLock : IDisposable
|
||||
{
|
||||
private readonly string _lockPath;
|
||||
private FileStream? _stream;
|
||||
|
||||
private WalletLock(string lockPath, FileStream stream)
|
||||
{
|
||||
_lockPath = lockPath;
|
||||
_stream = stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to acquire an exclusive lock for <paramref name="walletPath"/>.
|
||||
/// Returns null if another process already holds the lock (IOException).
|
||||
/// Lets UnauthorizedAccessException propagate so callers can show a distinct message.
|
||||
/// </summary>
|
||||
public static WalletLock? TryAcquire(string walletPath)
|
||||
{
|
||||
var lockPath = walletPath + ".lock";
|
||||
try
|
||||
{
|
||||
var stream = new FileStream(
|
||||
lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None);
|
||||
return new WalletLock(lockPath, stream);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stream?.Dispose();
|
||||
_stream = null;
|
||||
try { File.Delete(_lockPath); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public static class WalletStore
|
||||
return WalletDocument.FromJson(content);
|
||||
}
|
||||
|
||||
/// <param name="password">Null saves in plaintext. Only omit when the user has
|
||||
/// explicitly opted out of encryption (UI must show a clear warning).</param>
|
||||
public static void Save(WalletDocument doc, string path, string? password = null)
|
||||
{
|
||||
var content = doc.ToJson();
|
||||
@@ -35,4 +37,12 @@ public static class WalletStore
|
||||
File.WriteAllText(tmp, content);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
|
||||
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
|
||||
/// transactions/headers), where the synchronous version would block the UI.
|
||||
/// </summary>
|
||||
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
|
||||
Task.Run(() => Save(doc, path, password));
|
||||
}
|
||||
|
||||
@@ -3,30 +3,31 @@ using System.Globalization;
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Conversione satoshi ↔ unità coin (8 decimali) per visualizzazione e input.
|
||||
/// Si lavora sempre in satoshi internamente; la stringa è solo presentazione.
|
||||
/// Satoshi ↔ coin unit conversion (8 decimal places) for display and input.
|
||||
/// All internal computation uses satoshis; the formatted string is presentation only.
|
||||
/// </summary>
|
||||
public static class CoinAmount
|
||||
{
|
||||
public const long SatsPerCoin = 100_000_000;
|
||||
|
||||
/// <summary>Unità di visualizzazione selezionabili (config §8).</summary>
|
||||
/// <summary>Selectable display units (config §8).</summary>
|
||||
public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
|
||||
|
||||
/// <summary>(satoshi per unità, decimali mostrati) di ciascuna unità.</summary>
|
||||
/// <summary>(satoshis per unit, displayed decimals) for each unit.</summary>
|
||||
private static (long Factor, int Decimals) Of(string unit) => unit switch
|
||||
{
|
||||
"PLM" => (SatsPerCoin, 8),
|
||||
"mPLM" => (100_000, 5),
|
||||
"µPLM" => (100, 2),
|
||||
"sat" => (1, 0),
|
||||
_ => (SatsPerCoin, 8), // PLM
|
||||
"sat" => (1, 0),
|
||||
_ => throw new ArgumentException($"Unknown coin unit: {unit}", nameof(unit)),
|
||||
};
|
||||
|
||||
public static string Format(long sats, string unit = "") =>
|
||||
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
|
||||
+ (unit.Length > 0 ? " " + unit : "");
|
||||
|
||||
/// <summary>Formatta nell'unità scelta (es. 150000 sat → "1.50000 mPLM").</summary>
|
||||
/// <summary>Formats in the chosen unit (e.g. 150000 sat → "1.50000 mPLM").</summary>
|
||||
public static string FormatIn(long sats, string unit, bool withLabel = true)
|
||||
{
|
||||
var (factor, decimals) = Of(unit);
|
||||
@@ -35,7 +36,7 @@ public static class CoinAmount
|
||||
return withLabel ? $"{value} {unit}" : value;
|
||||
}
|
||||
|
||||
/// <summary>Parsa un importo espresso nell'unità scelta in satoshi.</summary>
|
||||
/// <summary>Parses an amount expressed in the chosen unit into satoshis.</summary>
|
||||
public static bool TryParseIn(string text, string unit, out long sats)
|
||||
{
|
||||
sats = 0;
|
||||
@@ -46,7 +47,10 @@ public static class CoinAmount
|
||||
return false;
|
||||
try
|
||||
{
|
||||
sats = (long)(value * factor);
|
||||
var satsDecimal = value * factor;
|
||||
if (satsDecimal % 1 != 0)
|
||||
return false;
|
||||
sats = (long)satsDecimal;
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
@@ -55,7 +59,7 @@ public static class CoinAmount
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Parsa un importo in coin (punto o virgola decimale) in satoshi.</summary>
|
||||
/// <summary>Parses a coin amount (decimal point or comma) into satoshis.</summary>
|
||||
public static bool TryParseCoins(string text, out long sats)
|
||||
{
|
||||
sats = 0;
|
||||
@@ -65,7 +69,10 @@ public static class CoinAmount
|
||||
return false;
|
||||
try
|
||||
{
|
||||
sats = (long)(coins * SatsPerCoin);
|
||||
var satsDecimal = coins * SatsPerCoin;
|
||||
if (satsDecimal % 1 != 0)
|
||||
return false;
|
||||
sats = (long)satsDecimal;
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>Esito della costruzione di una transazione.</summary>
|
||||
/// <summary>Result of a transaction build operation.</summary>
|
||||
public sealed class BuiltTransaction
|
||||
{
|
||||
public required Transaction Transaction { get; init; }
|
||||
@@ -14,7 +14,7 @@ public sealed class BuiltTransaction
|
||||
public required FeeRate FeeRate { get; init; }
|
||||
public required bool Signed { get; init; }
|
||||
|
||||
/// <summary>PSBT per i flussi watch-only/air-gapped/multisig (§6.5).</summary>
|
||||
/// <summary>PSBT for watch-only/air-gapped/multisig flows (§6.5).</summary>
|
||||
public required PSBT Psbt { get; init; }
|
||||
|
||||
public string ToHex() => Transaction.ToHex();
|
||||
@@ -22,25 +22,28 @@ public sealed class BuiltTransaction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Costruzione e firma delle transazioni (blueprint §6) sopra le primitive
|
||||
/// NBitcoin: selezione monete (manuale o automatica), fee a rate fisso,
|
||||
/// invia-tutto con fee sottratta, change sulla catena interna, RBF di default.
|
||||
/// Con un account watch-only produce la PSBT non firmata (§6.5).
|
||||
/// Transaction construction and signing (blueprint §6) on top of NBitcoin primitives:
|
||||
/// coin selection (manual or automatic), fixed fee rate, send-all with fee subtracted,
|
||||
/// change on the internal chain, RBF on by default.
|
||||
/// With a watch-only account produces an unsigned PSBT (§6.5).
|
||||
/// </summary>
|
||||
public sealed class TransactionFactory(HdAccount account)
|
||||
public sealed class TransactionFactory(IWalletAccount account)
|
||||
{
|
||||
private const int MaxStandardTransactionVirtualSize = 100_000;
|
||||
|
||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Costruisce (e se possibile firma) una transazione.
|
||||
/// Builds (and signs if possible) a transaction.
|
||||
/// </summary>
|
||||
/// <param name="utxos">UTXO selezionati (coin control §6.2) o tutti quelli spendibili.</param>
|
||||
/// <param name="transactions">Tx di provenienza degli UTXO (txid → tx), dalla sincronizzazione.</param>
|
||||
/// <param name="destination">Indirizzo destinatario.</param>
|
||||
/// <param name="amountSats">Importo; ignorato se <paramref name="sendAll"/>.</param>
|
||||
/// <param name="feeRateSatPerVByte">Fee rate fisso in sat/vByte (§6.4).</param>
|
||||
/// <param name="changeIndex">Indice del prossimo indirizzo di change (catena interna).</param>
|
||||
/// <param name="sendAll">Invia tutto: fee sottratta dall'importo (§6.1).</param>
|
||||
/// <param name="utxos">Selected UTXOs (coin control §6.2) or all spendable ones.</param>
|
||||
/// <param name="transactions">Source transactions for the UTXOs (txid → tx), from sync.</param>
|
||||
/// <param name="destination">Recipient address.</param>
|
||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
||||
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
||||
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
|
||||
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||
public BuiltTransaction Build(
|
||||
IReadOnlyList<CachedUtxo> utxos,
|
||||
IReadOnlyDictionary<string, Transaction> transactions,
|
||||
@@ -48,27 +51,128 @@ public sealed class TransactionFactory(HdAccount account)
|
||||
long amountSats,
|
||||
decimal feeRateSatPerVByte,
|
||||
int changeIndex,
|
||||
int tipHeight,
|
||||
bool sendAll = false)
|
||||
{
|
||||
// Si spendono solo UTXO confermati: i fondi in mempool si vedono nel
|
||||
// saldo "in attesa" ma non sono spendibili finché non confermano.
|
||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
||||
var profile = account.Profile;
|
||||
var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
|
||||
|
||||
if (spendable.Count == 0)
|
||||
{
|
||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
||||
throw new WalletSpendException(pending > 0
|
||||
? $"Nessun fondo confermato: {CoinAmount.Format(pending)} in attesa di conferma (non spendibile)."
|
||||
: "Nessun UTXO spendibile selezionato.");
|
||||
var reasons = new System.Text.StringBuilder();
|
||||
|
||||
var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList();
|
||||
if (mempool.Count > 0)
|
||||
reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). ");
|
||||
|
||||
var immature = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (immature.Count > 0)
|
||||
{
|
||||
var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
|
||||
var threshold = profile.CoinbaseMaturity + 1;
|
||||
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). ");
|
||||
}
|
||||
|
||||
var underConf = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && !u.IsCoinbase &&
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (underConf.Count > 0)
|
||||
{
|
||||
var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
|
||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). ");
|
||||
}
|
||||
|
||||
var unverified = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && !u.Verified &&
|
||||
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
|
||||
if (unverified.Count > 0)
|
||||
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
|
||||
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
|
||||
|
||||
throw new WalletSpendException(reasons.Length > 0
|
||||
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||
: "No spendable UTXOs selected.");
|
||||
}
|
||||
|
||||
var coins = spendable.Select(u => new Coin(
|
||||
var ordered = spendable
|
||||
.OrderByDescending(u => u.ValueSats)
|
||||
.ThenBy(u => u.Height)
|
||||
.ThenBy(u => u.Txid, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Vout)
|
||||
.ToList();
|
||||
|
||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||
|
||||
if (sendAll)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BuildWithSelectedUtxos(
|
||||
ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count);
|
||||
}
|
||||
catch (TransactionTooLargeException ex)
|
||||
{
|
||||
throw new WalletSpendException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
NotEnoughFundsException? lastInsufficientFunds = null;
|
||||
TransactionTooLargeException? tooLarge = null;
|
||||
BuiltTransaction? best = null;
|
||||
var low = 1;
|
||||
var high = ordered.Count;
|
||||
while (low <= high)
|
||||
{
|
||||
var count = low + ((high - low) / 2);
|
||||
try
|
||||
{
|
||||
best = BuildWithSelectedUtxos(
|
||||
ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex,
|
||||
sendAll: false, totalSpendableCount: ordered.Count);
|
||||
high = count - 1;
|
||||
}
|
||||
catch (NotEnoughFundsException ex)
|
||||
{
|
||||
lastInsufficientFunds = ex;
|
||||
low = count + 1;
|
||||
}
|
||||
catch (TransactionTooLargeException ex)
|
||||
{
|
||||
tooLarge = ex;
|
||||
high = count - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best is not null)
|
||||
return best;
|
||||
|
||||
if (tooLarge is not null)
|
||||
throw new WalletSpendException(tooLarge.Message);
|
||||
|
||||
throw new WalletSpendException(lastInsufficientFunds is null
|
||||
? "Insufficient funds."
|
||||
: $"Insufficient funds: {lastInsufficientFunds.Message}");
|
||||
}
|
||||
|
||||
private BuiltTransaction BuildWithSelectedUtxos(
|
||||
IReadOnlyList<CachedUtxo> selectedUtxos,
|
||||
IReadOnlyDictionary<string, Transaction> transactions,
|
||||
BitcoinAddress destination,
|
||||
long amountSats,
|
||||
FeeRate feeRate,
|
||||
int changeIndex,
|
||||
bool sendAll,
|
||||
int totalSpendableCount)
|
||||
{
|
||||
var coins = selectedUtxos.Select(u => new Coin(
|
||||
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
|
||||
transactions[u.Txid].Outputs[u.Vout])).ToList();
|
||||
|
||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||
var builder = Network.CreateTransactionBuilder();
|
||||
builder.SetVersion(2);
|
||||
// Sequence RBF per consentire il bump della fee (§6.6).
|
||||
// RBF sequence to allow fee bumping (§6.6).
|
||||
builder.OptInRBF = true;
|
||||
builder.AddCoins(coins);
|
||||
builder.SetChange(account.GetChangeAddress(changeIndex));
|
||||
@@ -81,8 +185,9 @@ public sealed class TransactionFactory(HdAccount account)
|
||||
|
||||
if (!account.IsWatchOnly)
|
||||
{
|
||||
builder.AddKeys(spendable
|
||||
.Select(u => account.GetExtPrivateKey(u.IsChange, u.AddressIndex))
|
||||
builder.AddKeys(selectedUtxos
|
||||
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
||||
.OfType<Key>()
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
@@ -91,16 +196,32 @@ public sealed class TransactionFactory(HdAccount account)
|
||||
{
|
||||
tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
|
||||
}
|
||||
catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// NBitcoin's coin selector refuses to assemble a combination over the standard size
|
||||
// cap itself and reports it through NotEnoughFundsException rather than ever handing
|
||||
// back an oversized transaction — the GetVirtualSize() check below is unreachable for
|
||||
// this case and exists only as a defense-in-depth net for other NBitcoin versions.
|
||||
throw new TransactionTooLargeException(
|
||||
BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll));
|
||||
}
|
||||
catch (NotEnoughFundsException) when (!sendAll)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotEnoughFundsException ex)
|
||||
{
|
||||
throw new WalletSpendException($"Fondi insufficienti: {ex.Message}");
|
||||
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
|
||||
}
|
||||
|
||||
if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize)
|
||||
throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize()));
|
||||
|
||||
if (!account.IsWatchOnly)
|
||||
{
|
||||
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
||||
throw new WalletSpendException(
|
||||
"Transazione non valida: " + string.Join("; ", errors.Select(e => e.ToString())));
|
||||
"Invalid transaction: " + string.Join("; ", errors.Select(e => e.ToString())));
|
||||
}
|
||||
|
||||
return new BuiltTransaction
|
||||
@@ -113,6 +234,21 @@ public sealed class TransactionFactory(HdAccount account)
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTooLargeMessage(
|
||||
int selectedInputCount,
|
||||
int totalSpendableCount,
|
||||
bool sendAll,
|
||||
int? actualVirtualSize = null)
|
||||
{
|
||||
var prefix = sendAll
|
||||
? "Send-all cannot fit in one standard transaction"
|
||||
: "Transaction cannot fit in one standard transaction";
|
||||
var size = actualVirtualSize is { } vsize ? $"{vsize} vB exceeds" : "Estimated size exceeds";
|
||||
|
||||
return $"{prefix}: {size} the {MaxStandardTransactionVirtualSize} vB standard relay limit " +
|
||||
$"with {selectedInputCount}/{totalSpendableCount} spendable input(s). Send a smaller amount or consolidate in multiple smaller transactions.";
|
||||
}
|
||||
|
||||
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
|
||||
{
|
||||
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
|
||||
@@ -120,7 +256,9 @@ public sealed class TransactionFactory(HdAccount account)
|
||||
.Sum(c => (Money)c.Amount);
|
||||
return inputSum - tx.Outputs.Sum(o => o.Value);
|
||||
}
|
||||
|
||||
private sealed class TransactionTooLargeException(string message) : Exception(message);
|
||||
}
|
||||
|
||||
/// <summary>Errore di costruzione/firma della spesa (fondi, policy, parametri).</summary>
|
||||
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
|
||||
public sealed class WalletSpendException(string message) : Exception(message);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
||||
/// <param name="CoinbaseTag">
|
||||
/// Printable ASCII runs (e.g. pool tags like "/slush/") extracted from the coinbase
|
||||
/// scriptSig; null for non-coinbase inputs or if no printable text was found.
|
||||
/// </param>
|
||||
public sealed record TxInputInfo(
|
||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase,
|
||||
string? CoinbaseTag = null);
|
||||
|
||||
/// <summary>An output of a transaction.</summary>
|
||||
/// <param name="OpReturnText">Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise.</param>
|
||||
public sealed record TxOutputInfo(
|
||||
uint Index, long AmountSats, string? Address, string ScriptType, string? OpReturnText, bool IsMine);
|
||||
|
||||
/// <summary>
|
||||
/// Complete transaction data assembled by querying the server: the raw transaction
|
||||
/// plus the outputs spent by each input (to derive amounts, addresses, and fees)
|
||||
/// and the block header (for the timestamp). Everything the ElectrumX-like protocol
|
||||
/// (§10) can tell us about a transaction.
|
||||
/// </summary>
|
||||
public sealed class TransactionDetails
|
||||
{
|
||||
public required string Txid { get; init; }
|
||||
/// <summary>Block height; ≤0 = still in mempool.</summary>
|
||||
public required int Height { get; init; }
|
||||
public required int Confirmations { get; init; }
|
||||
/// <summary>Net effect on the wallet balance (delta computed during sync).</summary>
|
||||
public required long NetSats { get; init; }
|
||||
/// <summary>Transaction fee; null if any input amount cannot be resolved (e.g. coinbase).</summary>
|
||||
public required long? FeeSats { get; init; }
|
||||
public required int TotalSize { get; init; }
|
||||
public required int VirtualSize { get; init; }
|
||||
public required uint Version { get; init; }
|
||||
public required uint LockTime { get; init; }
|
||||
public required bool RbfSignaled { get; init; }
|
||||
/// <summary>Merkle proof verified during sync (§7.4).</summary>
|
||||
public required bool Verified { get; init; }
|
||||
public required DateTimeOffset? BlockTime { get; init; }
|
||||
public required long TotalOutSats { get; init; }
|
||||
public required long? TotalInSats { get; init; }
|
||||
public required IReadOnlyList<TxInputInfo> Inputs { get; init; }
|
||||
public required IReadOnlyList<TxOutputInfo> Outputs { get; init; }
|
||||
|
||||
public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
|
||||
public bool IsIncoming => NetSats >= 0;
|
||||
/// <summary>Amount sent to external recipients (outputs not ours): the "sent" amount.</summary>
|
||||
public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
|
||||
public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
|
||||
public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
|
||||
/// <summary>
|
||||
/// Counterparty addresses: external recipients for a send (outputs not ours),
|
||||
/// external senders for a receive (inputs not ours).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CounterpartyAddresses => IsIncoming
|
||||
? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
|
||||
: [.. Outputs.Where(o => !o.IsMine && o.Address is not null).Select(o => o.Address!).Distinct()];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all data for a single transaction from the server (blueprint §10):
|
||||
/// the raw transaction and the outputs spent by its inputs, to reconstruct
|
||||
/// amounts, fees, and addresses that the server does not summarise.
|
||||
/// </summary>
|
||||
public static class TransactionInspector
|
||||
{
|
||||
public static async Task<TransactionDetails> FetchAsync(
|
||||
ElectrumClient client, Network network, string txid, int tipHeight, int height,
|
||||
IReadOnlySet<string> ownedAddresses, long netSats, bool verified,
|
||||
IReadOnlyDictionary<string, Transaction>? cache = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
async Task<Transaction> GetTx(string id)
|
||||
{
|
||||
if (cache is not null && cache.TryGetValue(id, out var hit))
|
||||
return hit;
|
||||
return Transaction.Parse(await client.GetTransactionAsync(id, ct), network);
|
||||
}
|
||||
|
||||
async Task<Transaction?> GetTxOrNull(string id)
|
||||
{
|
||||
try { return await GetTx(id); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
async Task<DateTimeOffset?> GetBlockTimeOrNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
|
||||
return DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
string? AddrOf(Script s)
|
||||
{
|
||||
try { return s.GetDestinationAddress(network)?.ToString(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
var tx = await GetTx(txid);
|
||||
|
||||
var outputs = new List<TxOutputInfo>(tx.Outputs.Count);
|
||||
for (var i = 0; i < tx.Outputs.Count; i++)
|
||||
{
|
||||
var o = tx.Outputs[i];
|
||||
var addr = AddrOf(o.ScriptPubKey);
|
||||
var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null;
|
||||
outputs.Add(new TxOutputInfo(
|
||||
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), opReturn,
|
||||
addr is not null && ownedAddresses.Contains(addr)));
|
||||
}
|
||||
|
||||
var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
|
||||
|
||||
// Input transactions are needed for amounts/addresses/fees. Fetched in
|
||||
// parallel (unique ids, concurrent requests supported by ElectrumClient):
|
||||
// sequential fetching costs one round-trip per input. The block header
|
||||
// is also fetched in parallel.
|
||||
var prevTxids = tx.IsCoinBase
|
||||
? []
|
||||
: tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
|
||||
|
||||
var prevFetch = prevTxids.ToDictionary(id => id, id => GetTxOrNull(id));
|
||||
var headerTask = height > 0 ? GetBlockTimeOrNull() : Task.FromResult<DateTimeOffset?>(null);
|
||||
await Task.WhenAll(prevFetch.Values.Cast<Task>().Append(headerTask));
|
||||
|
||||
var prevTxs = prevFetch.ToDictionary(kv => kv.Key, kv => kv.Value.Result);
|
||||
var blockTime = await headerTask;
|
||||
|
||||
var inputs = new List<TxInputInfo>(tx.Inputs.Count);
|
||||
var feeKnown = !tx.IsCoinBase;
|
||||
long inSum = 0;
|
||||
foreach (var inp in tx.Inputs)
|
||||
{
|
||||
if (tx.IsCoinBase)
|
||||
{
|
||||
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true, CoinbaseTagOf(inp.ScriptSig.ToBytes())));
|
||||
continue;
|
||||
}
|
||||
|
||||
long? amt = null;
|
||||
string? addr = null;
|
||||
var prev = prevTxs.GetValueOrDefault(inp.PrevOut.Hash.ToString());
|
||||
if (prev is not null && inp.PrevOut.N < prev.Outputs.Count)
|
||||
{
|
||||
var po = prev.Outputs[(int)inp.PrevOut.N];
|
||||
amt = po.Value.Satoshi;
|
||||
addr = AddrOf(po.ScriptPubKey);
|
||||
inSum += po.Value.Satoshi;
|
||||
}
|
||||
else feeKnown = false;
|
||||
|
||||
inputs.Add(new TxInputInfo(
|
||||
inp.PrevOut.Hash.ToString(), inp.PrevOut.N, amt, addr,
|
||||
addr is not null && ownedAddresses.Contains(addr), false));
|
||||
}
|
||||
|
||||
var outSum = tx.Outputs.Sum(o => o.Value.Satoshi);
|
||||
|
||||
return new TransactionDetails
|
||||
{
|
||||
Txid = txid,
|
||||
Height = height,
|
||||
Confirmations = height > 0 && tipHeight >= height ? tipHeight - height + 1 : 0,
|
||||
NetSats = netSats,
|
||||
FeeSats = feeKnown ? inSum - outSum : null,
|
||||
TotalSize = tx.ToBytes().Length,
|
||||
VirtualSize = tx.GetVirtualSize(),
|
||||
Version = tx.Version,
|
||||
LockTime = tx.LockTime.Value,
|
||||
RbfSignaled = rbf,
|
||||
Verified = verified,
|
||||
BlockTime = blockTime,
|
||||
TotalOutSats = outSum,
|
||||
TotalInSats = feeKnown ? inSum : null,
|
||||
Inputs = inputs,
|
||||
Outputs = outputs,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ScriptType(Script script)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = StandardScripts.GetTemplateFromScriptPubKey(script);
|
||||
return t is null
|
||||
? "nonstandard"
|
||||
: t.GetType().Name.Replace("PayTo", "").Replace("Template", "");
|
||||
}
|
||||
catch { return "—"; }
|
||||
}
|
||||
|
||||
/// <summary>Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text.</summary>
|
||||
private static string? OpReturnTextOf(Script script)
|
||||
{
|
||||
if (!script.IsUnspendable) return null;
|
||||
try
|
||||
{
|
||||
var data = script.ToOps().Skip(1)
|
||||
.Where(op => op.PushData is { Length: > 0 })
|
||||
.SelectMany(op => op.PushData)
|
||||
.ToArray();
|
||||
return data.Length == 0 ? null : DecodeUtf8OrHex(data);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string DecodeUtf8OrHex(byte[] data)
|
||||
{
|
||||
var text = System.Text.Encoding.UTF8.GetString(data);
|
||||
var looksLikeText = !text.Contains('�') && text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t');
|
||||
return looksLikeText ? text : "0x" + Convert.ToHexString(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool
|
||||
/// tags like "/slush/" embedded among the binary BIP34 height and extranonce.
|
||||
/// </summary>
|
||||
private static string? CoinbaseTagOf(byte[] scriptSig)
|
||||
{
|
||||
var runs = new List<string>();
|
||||
var run = new System.Text.StringBuilder();
|
||||
void Flush()
|
||||
{
|
||||
if (run.Length >= 4) runs.Add(run.ToString());
|
||||
run.Clear();
|
||||
}
|
||||
foreach (var b in scriptSig)
|
||||
{
|
||||
if (b is >= 0x20 and <= 0x7E) run.Append((char)b);
|
||||
else Flush();
|
||||
}
|
||||
Flush();
|
||||
return runs.Count == 0 ? null : string.Join(" ", runs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Confirmation-threshold rules shared between coin selection (<see cref="TransactionFactory"/>)
|
||||
/// and balance reporting: a coinbase output needs COINBASE_MATURITY + 1 confirmations
|
||||
/// (consensus rule nSpendHeight - nHeight >= 120, plus one block of safety margin, matching
|
||||
/// the Qt wallet); a regular output needs <see cref="ChainProfile.MinConfirmations"/> (wallet
|
||||
/// policy, no consensus rule).
|
||||
/// </summary>
|
||||
public static class UtxoSpendability
|
||||
{
|
||||
public static int RequiredConfirmations(this CachedUtxo utxo, ChainProfile profile) =>
|
||||
utxo.IsCoinbase ? profile.CoinbaseMaturity + 1 : profile.MinConfirmations;
|
||||
|
||||
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
||||
|
||||
/// <summary>
|
||||
/// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle
|
||||
/// proof has actually been checked. Without the <see cref="CachedUtxo.Verified"/> gate a
|
||||
/// malicious server could report a fake confirmed UTXO and have it spent before background
|
||||
/// verification ever caught the forgery.
|
||||
/// </summary>
|
||||
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||
!utxo.Frozen && utxo.Height > 0 && utxo.Verified
|
||||
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||
}
|
||||
@@ -6,39 +6,66 @@ using PalladiumWallet.Core.Storage;
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Ponte documento wallet ↔ dominio (blueprint §4.5): dal file ricostruisce
|
||||
/// l'HdAccount giusto (da seed o watch-only da xpub). È l'embrione della
|
||||
/// factory dei tipi di wallet; crescerà con multisig e importati.
|
||||
/// Wallet type factory (blueprint §4.5): reconstructs the correct account type
|
||||
/// from a document (HD from seed, HD from imported xprv, imported WIF, watch-only).
|
||||
/// </summary>
|
||||
public static class WalletLoader
|
||||
{
|
||||
public static ChainProfile ProfileOf(WalletDocument doc) =>
|
||||
ChainProfiles.For(Enum.Parse<NetKind>(doc.Network, ignoreCase: true));
|
||||
|
||||
public static HdAccount ToAccount(WalletDocument doc)
|
||||
public static IWalletAccount ToAccount(WalletDocument doc)
|
||||
{
|
||||
var profile = ProfileOf(doc);
|
||||
var kind = Enum.Parse<ScriptKind>(doc.ScriptKind);
|
||||
var path = KeyPath.Parse(doc.AccountPath);
|
||||
var network = PalladiumNetworks.For(profile.Kind);
|
||||
|
||||
// 1. HD from seed (most common case)
|
||||
if (doc.Mnemonic is { } words)
|
||||
{
|
||||
if (!Bip39.TryParse(words, out var mnemonic))
|
||||
throw new InvalidDataException("Mnemonica del file wallet non valida.");
|
||||
throw new InvalidDataException("Invalid mnemonic in wallet file.");
|
||||
var path = KeyPath.Parse(doc.AccountPath ?? DerivationPaths.AccountPath(kind, profile).ToString());
|
||||
return HdAccount.FromSeed(Bip39.ToSeed(mnemonic!, doc.Passphrase), kind, profile, path);
|
||||
}
|
||||
|
||||
// 2. HD from imported xprv (spendable, no seed)
|
||||
if (doc.AccountXprv is { } xprvStr)
|
||||
{
|
||||
if (!Slip132.TryDecodePrivate(xprvStr, profile, out var xprv, out _))
|
||||
throw new InvalidDataException("Xprv in wallet file is invalid for this network.");
|
||||
var path = doc.AccountPath is { Length: > 0 } p ? KeyPath.Parse(p) : null;
|
||||
return HdAccount.FromAccountXprv(xprv!, kind, profile, path);
|
||||
}
|
||||
|
||||
// 3. Imported WIF keys
|
||||
if (doc.WifKeys is { Count: > 0 } wifKeys)
|
||||
{
|
||||
var entries = wifKeys.Select(wif =>
|
||||
{
|
||||
var secret = new BitcoinSecret(wif, network);
|
||||
var addr = secret.PrivateKey.PubKey
|
||||
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
||||
return (addr, (Key?)secret.PrivateKey);
|
||||
}).ToList();
|
||||
return new ImportedKeyAccount(entries, kind, profile);
|
||||
}
|
||||
|
||||
// 4. Watch-only from xpub
|
||||
if (doc.AccountXpub is null)
|
||||
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||
throw new InvalidDataException("Xpub del file wallet non valida per questa rete.");
|
||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, path);
|
||||
throw new InvalidDataException("Xpub in wallet file is invalid for this network.");
|
||||
var accountPath = doc.AccountPath is { Length: > 0 } ap ? KeyPath.Parse(ap) : null;
|
||||
return HdAccount.FromAccountXpub(xpub!, kind, profile, accountPath);
|
||||
}
|
||||
|
||||
/// <summary>Crea il documento per un nuovo wallet da seed.</summary>
|
||||
/// <summary>Creates the document for a new seed wallet.</summary>
|
||||
public static (WalletDocument Doc, HdAccount Account) NewFromMnemonic(
|
||||
string words, string? passphrase, ScriptKind kind, ChainProfile profile, KeyPath? customPath = null)
|
||||
{
|
||||
if (!Bip39.TryParse(words, out var mnemonic))
|
||||
throw new InvalidDataException("Mnemonica non valida (parole o checksum errati).");
|
||||
throw new InvalidDataException("Invalid mnemonic (wrong words or checksum).");
|
||||
|
||||
var account = customPath is null
|
||||
? HdAccount.FromMnemonic(mnemonic!, passphrase, kind, profile)
|
||||
@@ -56,4 +83,91 @@ public static class WalletLoader
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the document from an imported SLIP-132 xpub (watch-only).
|
||||
/// Detects the ScriptKind from the SLIP-132 header; <paramref name="kindOverride"/> overrides
|
||||
/// it for ambiguous prefixes (xpub can be Legacy or Taproot).
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, HdAccount Account) NewFromXpub(
|
||||
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||
{
|
||||
if (!Slip132.TryDecodePublic(slip132.Trim(), profile, out var xpub, out var detectedKind))
|
||||
throw new InvalidDataException("Extended public key is invalid or unrecognised for this network.");
|
||||
var kind = kindOverride ?? detectedKind;
|
||||
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
||||
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = profile.NetName,
|
||||
ScriptKind = kind.ToString(),
|
||||
AccountPath = account.AccountPath.ToString(),
|
||||
AccountXpub = slip132.Trim(),
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the document from an imported SLIP-132 xprv (spendable without seed).
|
||||
/// The document contains the xprv in plaintext — it must be encrypted.
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, HdAccount Account) NewFromXprv(
|
||||
string slip132, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||
{
|
||||
if (!Slip132.TryDecodePrivate(slip132.Trim(), profile, out var xprv, out var detectedKind))
|
||||
throw new InvalidDataException("Extended private key is invalid or unrecognised for this network.");
|
||||
var kind = kindOverride ?? detectedKind;
|
||||
var account = HdAccount.FromAccountXprv(xprv!, kind, profile);
|
||||
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = profile.NetName,
|
||||
ScriptKind = kind.ToString(),
|
||||
AccountPath = account.AccountPath.ToString(),
|
||||
AccountXpub = account.ToSlip132(),
|
||||
AccountXprv = slip132.Trim(),
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the document from one or more imported WIF keys.
|
||||
/// The document contains the WIF keys in plaintext — it must be encrypted.
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromWif(
|
||||
IReadOnlyList<string> wifKeys, ScriptKind kind, ChainProfile profile)
|
||||
{
|
||||
if (wifKeys.Count == 0)
|
||||
throw new InvalidDataException("At least one WIF key is required.");
|
||||
|
||||
var network = PalladiumNetworks.For(profile.Kind);
|
||||
var entries = new List<(BitcoinAddress, Key?)>();
|
||||
var wifStrings = new List<string>();
|
||||
|
||||
foreach (var raw in wifKeys)
|
||||
{
|
||||
BitcoinSecret secret;
|
||||
try
|
||||
{
|
||||
secret = new BitcoinSecret(raw.Trim(), network);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid WIF key: {ex.Message}");
|
||||
}
|
||||
var addr = secret.PrivateKey.PubKey
|
||||
.GetAddress(DerivationPaths.ScriptPubKeyTypeFor(kind), network);
|
||||
entries.Add((addr, secret.PrivateKey));
|
||||
wifStrings.Add(raw.Trim());
|
||||
}
|
||||
|
||||
var account = new ImportedKeyAccount(entries, kind, profile);
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = profile.NetName,
|
||||
ScriptKind = kind.ToString(),
|
||||
WifKeys = wifStrings,
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
|
||||
@@ -0,0 +1 @@
|
||||
hello world
|
||||
@@ -0,0 +1 @@
|
||||
PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
|
||||
@@ -0,0 +1 @@
|
||||
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||