Compare commits
34 Commits
5d2d0ad312
...
0.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
*.key
|
||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
# Wallet data and local runtime state
|
# Wallet data and local runtime state
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to coding agents when working with code in this repository.
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
Operate as an **expert in cryptocurrencies and cryptography**: reason with the domain's rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks and pitfalls (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||||
|
|
||||||
|
## Language policy
|
||||||
|
|
||||||
|
- **Conversation with the user**: Italian.
|
||||||
|
- **All code, comments, commit messages, and documentation files**: English only.
|
||||||
|
|
||||||
|
## How to assist
|
||||||
|
|
||||||
|
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement -- an honest opinion is worth more than blind execution.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
||||||
|
|
||||||
|
## 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 goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
||||||
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
|
- Build: `dotnet build`
|
||||||
|
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client -- it isn't an interface, by design.
|
||||||
|
- 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 builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs -- Skia/HarfBuzz/ANGLE -- stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 -> bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed -- see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
|
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||||
|
- Manual Linux publish: same with `-r linux-x64` (single-file binary; 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. To provision the SDK once:
|
||||||
|
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
||||||
|
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
||||||
|
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
||||||
|
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
||||||
|
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
||||||
|
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
||||||
|
|
||||||
|
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||||
|
|
||||||
|
## Architecture (points that require reading multiple files)
|
||||||
|
|
||||||
|
- **Layers:** 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**. 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), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||||
|
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||||
|
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s -- instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg. Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile). Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||||
|
- **Localization:** `Localization/Loc.cs`, key->6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||||
|
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
|
||||||
|
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam -- the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Releases:** whenever a new version tag is created (bumping `<Version>` in `src/App/PalladiumWallet.App.csproj`), update `CHANGELOG.md` with an entry for that version before/with the tag -- it's the technical record of what shipped, not optional bookkeeping.
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## [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).
|
||||||
@@ -19,8 +19,6 @@ On **every requested change**, before implementing, judge whether it makes sense
|
|||||||
|
|
||||||
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.
|
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
||||||
|
|
||||||
[blueprint.md](blueprint.md) is a **reference for understanding** (consensus parameters verified against the node, algorithms, network protocol): consult it when it helps to understand an area, but **it is no longer binding** — it need not be followed to the letter or read before every change. The source of truth is the current code; the `§` references below point to the blueprint only as further reading.
|
|
||||||
|
|
||||||
## Stack and structure
|
## Stack and structure
|
||||||
|
|
||||||
.NET 10 + Avalonia UI 12 + NBitcoin.
|
.NET 10 + Avalonia UI 12 + NBitcoin.
|
||||||
@@ -31,6 +29,7 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
|
|||||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||||
src/Cli/ CLI on the same Core tests/ xUnit
|
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
|
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
||||||
@@ -45,11 +44,12 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
|||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- Build: `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||||
|
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||||
|
|
||||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
||||||
@@ -64,11 +64,11 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
|
|
||||||
## Architecture (points that require reading multiple files)
|
## Architecture (points that require reading multiple files)
|
||||||
|
|
||||||
- **Layers (§2):** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
||||||
- **Network profile (§3):** all chain constants (address prefixes, BIP32 headers, bech32 HRP, genesis, ports, coin_type 746) **centralized in `Core/Chain`** (`ChainProfiles`/`PalladiumNetworks`), selectable per network (mainnet/testnet/regtest). No scattered magic numbers.
|
- **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 (§3, §7):** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints** (§7.3). Custom layer: NBitcoin assumes Bitcoin's retargeting.
|
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. Custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||||
- **NBitcoin vs custom (§19.2):** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like, §10); SPV sync with Merkle verification (§7.4); header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
- **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 (§6.5):** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
- **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).
|
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||||
|
|
||||||
## GUI conventions (`src/App`)
|
## GUI conventions (`src/App`)
|
||||||
@@ -80,14 +80,8 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
|
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
|
||||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||||
|
|
||||||
## Implementation state (§16 steps 1–7 + GUI)
|
|
||||||
|
|
||||||
`Core/Chain` network profiles; `Core/Crypto` BIP39/32/SLIP-132/`HdAccount`; `Core/Storage` JSON wallet v1 + AES-GCM (PBKDF2-SHA512) + data paths; `Core/Net` `ElectrumClient` (newline JSON-RPC over TCP/TLS, TOFU in `server-certs.json`, concurrent requests) + `ElectrumApi`; `Core/Spv` scripthash, mandatory Merkle verification on every confirmed tx, sync with gap limit; `Core/Wallet` `TransactionFactory` (RBF on, send-all, watch-only PSBT), `TransactionInspector` (tx detail from the server), `WalletLoader`. GUI: setup wizard, dashboard (history/send/receive with QR+copy/addresses/contacts), transaction detail, settings/server/help, multi-wallet. Runs on desktop and Android from one shared UI (debug apk builds end-to-end).
|
|
||||||
|
|
||||||
**TODO (§16 steps 8–9):** multisig, hardware wallet, coin control UI, fee ETA/mempool, RBF/CPFP UI, on-disk header chain, multi-server pool, proxy/Tor.
|
|
||||||
|
|
||||||
## Working rules
|
## Working rules
|
||||||
|
|
||||||
- **Cross-implementation tests (§16):** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||||
- **Security (§17):** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
||||||
- *(Optional)* blueprint features may be deferred but must still be considered in the design.
|
- **Releases:** whenever a new version tag is created (bumping `<Version>` in `src/App/PalladiumWallet.App.csproj`), update `CHANGELOG.md` with an entry for that version before/with the tag — it's the technical record of what shipped, not optional bookkeeping.
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto a
|
|||||||
|
|
||||||
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
|
1. Install the .NET 10 SDK through your distro's package manager, or without root via the official script:
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0
|
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 10.0 --install-dir "$HOME/.dotnet10"
|
||||||
export PATH="$HOME/.dotnet:$PATH" DOTNET_ROOT="$HOME/.dotnet"
|
export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"
|
||||||
```
|
```
|
||||||
(add the two `export` lines to your `~/.bashrc` to make them permanent)
|
(add the two `export` lines to your `~/.bashrc` to make them permanent)
|
||||||
2. Clone and restore:
|
2. Clone and restore:
|
||||||
@@ -139,14 +139,59 @@ Run only the tests in one project:
|
|||||||
dotnet test tests/PalladiumWallet.Tests
|
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.
|
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
||||||
|
|
||||||
The suite includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
### 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 |
|
||||||
|
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode, HD and imported-key accounts, watch-only isolation |
|
||||||
|
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the path where a lying server fails Merkle verification and the sync must abort |
|
||||||
|
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), release-tag parsing |
|
||||||
|
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data paths |
|
||||||
|
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Building
|
## 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
|
### Development build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,7 +207,10 @@ dotnet build src/App.Desktop # desktop head only
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
|
# Windows — single self-contained .exe (output: src/App.Desktop/bin/Release/net10.0/win-x64/publish/PalladiumWallet.exe)
|
||||||
dotnet publish src/App.Desktop -c Release -r win-x64 -p:PublishSingleFile=true --self-contained
|
# 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
|
# Linux — self-contained; AppImage then produced with PupNet Deploy
|
||||||
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
|
# (output: src/App.Desktop/bin/Release/net10.0/linux-x64/publish/PalladiumWallet)
|
||||||
@@ -195,8 +243,11 @@ The ABI restriction uses the `AbiArm64Only` flag, which is scoped to the Android
|
|||||||
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
command line, it leaks to the `net10.0` projects (`Core`/`App`) and breaks the build. (The legacy
|
||||||
`AndroidSupportedAbis` property is deprecated and ignored.)
|
`AndroidSupportedAbis` property is deprecated and ignored.)
|
||||||
|
|
||||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) Release signing with your own
|
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) This debug apk is fine for personal
|
||||||
keystore is not set up yet; the debug apk is fine for personal sideloading.
|
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
|
> **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,
|
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
||||||
@@ -309,6 +360,10 @@ The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
Technical, per-version changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
||||||
|
|||||||
@@ -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).
|
||||||
Executable
+235
@@ -0,0 +1,235 @@
|
|||||||
|
#!/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: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.
|
||||||
Executable
+65
@@ -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."
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
<ApplicationVersion>2</ApplicationVersion>
|
||||||
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>0.9.1</ApplicationDisplayVersion>
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
<!-- 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
|
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||||
|
|||||||
+61
-12
@@ -45,8 +45,8 @@ public sealed class Loc
|
|||||||
// Menu it en es fr pt de
|
// Menu it en es fr pt de
|
||||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
|
["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.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"] = ["_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.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.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
|
||||||
@@ -54,12 +54,30 @@ public sealed class Loc
|
|||||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
||||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
||||||
["help.info"] = [
|
["help.info"] = [
|
||||||
"Wallet SPV per la criptovaluta Palladium (PLM).",
|
"Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
|
||||||
"SPV wallet for the Palladium (PLM) cryptocurrency.",
|
"Lightweight, non-custodial SPV wallet for the Palladium (PLM) network. Local security: seed and keys always encrypted, never exposed on the wire.",
|
||||||
"Monedero SPV para la criptomoneda Palladium (PLM).",
|
"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 pour la cryptomonnaie Palladium (PLM).",
|
"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 para a criptomoeda Palladium (PLM).",
|
"Carteira SPV leve e não custodial para a rede Palladium (PLM). Segurança local: semente e chaves sempre cifradas, nunca expostas na rede.",
|
||||||
"SPV-Wallet für die Kryptowährung Palladium (PLM)."],
|
"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"],
|
||||||
|
["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"],
|
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
@@ -155,9 +173,9 @@ public sealed class Loc
|
|||||||
// Wallet panel
|
// Wallet panel
|
||||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
["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.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.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"],
|
["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.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
||||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
||||||
@@ -182,6 +200,8 @@ public sealed class Loc
|
|||||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
|
["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.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.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.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
||||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
||||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
||||||
@@ -210,8 +230,7 @@ public sealed class Loc
|
|||||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
|
["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.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.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
|
||||||
["tx.verified"] = ["Verifica SPV", "SPV verification", "Verificación SPV", "Vérification SPV", "Verificação SPV", "SPV-Prüfung"],
|
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
||||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
|
||||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
||||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
||||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
||||||
@@ -235,6 +254,35 @@ public sealed class Loc
|
|||||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
|
["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"],
|
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
|
||||||
|
|
||||||
|
// 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
|
// Connection status
|
||||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
["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.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
||||||
@@ -345,6 +393,7 @@ public sealed class Loc
|
|||||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
["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.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.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.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||||
["msg.certreset"] = [
|
["msg.certreset"] = [
|
||||||
"Certificati SSL azzerati: riprova la connessione.",
|
"Certificati SSL azzerati: riprova la connessione.",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||||
<Version>0.9.0</Version>
|
<Version>0.9.1</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -117,6 +117,11 @@
|
|||||||
<Style Selector="TabItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="TabItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
<Setter Property="TextElement.Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||||
</Style>
|
</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 ===== -->
|
<!-- ===== Reusable classes ===== -->
|
||||||
<!-- Card: elevated surface with a subtle border and soft corners -->
|
<!-- Card: elevated surface with a subtle border and soft corners -->
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainWindowViewModel
|
||||||
|
{
|
||||||
|
public const string DevAddress = "plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string donateAmount = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string donatePreview = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool hasPendingDonate;
|
||||||
|
|
||||||
|
private BuiltTransaction? _pendingDonate;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PrepareDonate()
|
||||||
|
{
|
||||||
|
_pendingDonate = null;
|
||||||
|
HasPendingDonate = false;
|
||||||
|
|
||||||
|
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
||||||
|
{
|
||||||
|
DonatePreview = "Sincronizza prima di inviare.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
||||||
|
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
||||||
|
{
|
||||||
|
DonatePreview = "Importo non valido.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var feeRate) || feeRate <= 0)
|
||||||
|
feeRate = 1m;
|
||||||
|
|
||||||
|
_pendingDonate = new TransactionFactory(_account).Build(
|
||||||
|
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||||
|
_doc.Cache.NextChangeIndex, _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 = $"Errore: {ex.Message}";
|
||||||
|
}
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConfirmDonate()
|
||||||
|
{
|
||||||
|
if (_pendingDonate is null || _client is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
||||||
|
DonatePreview = $"Grazie! txid: {txid}";
|
||||||
|
DonateAmount = "";
|
||||||
|
_pendingDonate = null;
|
||||||
|
HasPendingDonate = false;
|
||||||
|
await ConnectAndSync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DonatePreview = $"Errore broadcast: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetDonate()
|
||||||
|
{
|
||||||
|
DonateAmount = "";
|
||||||
|
DonatePreview = "";
|
||||||
|
HasPendingDonate = false;
|
||||||
|
_pendingDonate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string unconfirmedText = "";
|
private string unconfirmedText = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string immatureText = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string networkInfo = "";
|
private string networkInfo = "";
|
||||||
|
|
||||||
@@ -68,11 +71,81 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private AddressInfo? addressInfo;
|
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) =>
|
public void ShowAddressInfo(AddressRow row) =>
|
||||||
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
|
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseAddressInfo() => AddressInfo = null;
|
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 ----
|
// ---- transaction detail overlay ----
|
||||||
|
|
||||||
@@ -179,6 +252,7 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||||
UnconfirmedText = "";
|
UnconfirmedText = "";
|
||||||
|
ImmatureText = "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
@@ -191,19 +265,21 @@ public partial class MainWindowViewModel
|
|||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BalanceText = Fmt(cache.ConfirmedSats);
|
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
|
||||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||||
UnconfirmedText = pending != 0
|
UnconfirmedText = pending != 0
|
||||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
? $"{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")}"
|
||||||
|
: "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
foreach (var tx in cache.History)
|
foreach (var tx in cache.History)
|
||||||
History.Add(new HistoryRow(
|
History.Add(new HistoryRow(
|
||||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||||
tx.Txid,
|
tx.Txid));
|
||||||
tx.Verified ? "✓ SPV" : "—"));
|
|
||||||
|
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
foreach (var a in cache.Addresses)
|
foreach (var a in cache.Addresses)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public partial class MainWindowViewModel
|
|||||||
|
|
||||||
_pendingSend = new TransactionFactory(_account).Build(
|
_pendingSend = new TransactionFactory(_account).Build(
|
||||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||||
_doc.Cache.NextChangeIndex, SendAll);
|
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||||
|
|
||||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||||
|
|||||||
@@ -70,5 +70,11 @@ public partial class MainWindowViewModel
|
|||||||
private void OpenHelp() => IsHelpOpen = true;
|
private void OpenHelp() => IsHelpOpen = true;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseHelp() => IsHelpOpen = false;
|
private static void QuitApp() =>
|
||||||
|
(Avalonia.Application.Current?.ApplicationLifetime
|
||||||
|
as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)
|
||||||
|
?.Shutdown();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CloseHelp() { IsHelpOpen = false; ResetDonate(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
@@ -31,10 +32,13 @@ public partial class MainWindowViewModel
|
|||||||
private bool isServerSettingsOpen;
|
private bool isServerSettingsOpen;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenServerSettings()
|
private async Task OpenServerSettings()
|
||||||
{
|
{
|
||||||
IsSettingsOpen = false;
|
IsSettingsOpen = false;
|
||||||
|
RefreshServers();
|
||||||
IsServerSettingsOpen = true;
|
IsServerSettingsOpen = true;
|
||||||
|
if (_client is { IsConnected: true })
|
||||||
|
await DiscoverServers();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -43,12 +47,17 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string connectionStatus = Loc.Tr("conn.none");
|
private string connectionStatus = Loc.Tr("conn.none");
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string connectionStatusShort = Loc.Tr("conn.none");
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isConnected;
|
private bool isConnected;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isSyncing;
|
private bool isSyncing;
|
||||||
|
|
||||||
|
private CancellationTokenSource _syncCts = new();
|
||||||
|
|
||||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -100,11 +109,24 @@ public partial class MainWindowViewModel
|
|||||||
KnownServers.Clear();
|
KnownServers.Clear();
|
||||||
foreach (var server in Registry.All)
|
foreach (var server in Registry.All)
|
||||||
KnownServers.Add(server);
|
KnownServers.Add(server);
|
||||||
SelectedKnownServer = KnownServers.FirstOrDefault();
|
|
||||||
if (SelectedKnownServer is null)
|
if (_config.LastServerHost is { Length: > 0 } savedHost && _config.LastServerPort is { } savedPort)
|
||||||
{
|
{
|
||||||
ServerHost = "127.0.0.1";
|
_syncingServerFields = true;
|
||||||
ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +168,23 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
if (IsSyncing)
|
if (IsSyncing)
|
||||||
{
|
{
|
||||||
_resyncRequested = true;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncCts = new CancellationTokenSource();
|
||||||
|
var ct = _syncCts.Token;
|
||||||
IsSyncing = true;
|
IsSyncing = true;
|
||||||
StatusMessage = "";
|
StatusMessage = "";
|
||||||
|
bool cancelled = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (host, port) = ParseServer();
|
var (host, port) = ParseServer();
|
||||||
@@ -175,20 +209,24 @@ public partial class MainWindowViewModel
|
|||||||
Exception? lastError = null;
|
Exception? lastError = null;
|
||||||
foreach (var (h, p) in candidates)
|
foreach (var (h, p) in candidates)
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||||
_client.NotificationReceived += OnServerNotification;
|
_client.NotificationReceived += OnServerNotification;
|
||||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||||
});
|
});
|
||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
_autoReconnect = true;
|
_autoReconnect = true;
|
||||||
ConnectionStatus = Loc.Tr("conn.connectedto");
|
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {h}:{p}";
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.connectedto");
|
||||||
// Update the UI to reflect the server actually connected.
|
// Update the UI to reflect the server actually connected.
|
||||||
_syncingServerFields = true;
|
_syncingServerFields = true;
|
||||||
ServerHost = h;
|
ServerHost = h;
|
||||||
@@ -196,9 +234,18 @@ public partial class MainWindowViewModel
|
|||||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == h)
|
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == h)
|
||||||
?? SelectedKnownServer;
|
?? SelectedKnownServer;
|
||||||
_syncingServerFields = false;
|
_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;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
lastError = ex;
|
lastError = ex;
|
||||||
@@ -231,7 +278,7 @@ public partial class MainWindowViewModel
|
|||||||
do
|
do
|
||||||
{
|
{
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
var result = await _synchronizer.SyncOnceAsync();
|
var result = await _synchronizer.SyncOnceAsync(ct);
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||||
@@ -241,6 +288,7 @@ public partial class MainWindowViewModel
|
|||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
|
ImmatureSats = result.ImmatureSats,
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
@@ -255,18 +303,29 @@ public partial class MainWindowViewModel
|
|||||||
_syncFailed = false;
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||||
} while (_resyncRequested);
|
} 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)
|
catch (CertificatePinMismatchException ex)
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
ConnectionStatus = Loc.Tr("conn.none");
|
ConnectionStatus = Loc.Tr("conn.none");
|
||||||
|
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||||
StatusMessage = ex.Message;
|
StatusMessage = ex.Message;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IsConnected = _client?.IsConnected == true;
|
IsConnected = _client?.IsConnected == true;
|
||||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||||
|
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
StatusMessage = $"Errore: {ex.Message}";
|
||||||
if (_account is not null)
|
if (_account is not null)
|
||||||
{
|
{
|
||||||
@@ -280,19 +339,63 @@ public partial class MainWindowViewModel
|
|||||||
{
|
{
|
||||||
IsSyncing = false;
|
IsSyncing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If cancelled due to a server change, restart immediately with the new server.
|
||||||
|
if (cancelled)
|
||||||
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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]
|
[RelayCommand]
|
||||||
private async Task DiscoverServers()
|
private async Task DiscoverServers()
|
||||||
{
|
{
|
||||||
if (_client is null || !_client.IsConnected)
|
if (_client is { IsConnected: true } connected)
|
||||||
{
|
{
|
||||||
StatusMessage = Loc.Tr("conn.none") + ".";
|
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 = $"Errore nella scoperta peer: {lastError?.Message ?? Loc.Tr("conn.none")}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var added = await Registry.DiscoverAsync(_client);
|
await DiscoverServersUsing(temp);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await temp.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscoverServersUsing(ElectrumClient client)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var added = await Registry.DiscoverAsync(client);
|
||||||
var selected = SelectedKnownServer;
|
var selected = SelectedKnownServer;
|
||||||
RefreshServers();
|
RefreshServers();
|
||||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -477,41 +477,6 @@ public partial class MainWindowViewModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OpenFromPath(string path)
|
|
||||||
{
|
|
||||||
var newLock = WalletLock.TryAcquire(path);
|
|
||||||
if (newLock is null) { StatusMessage = Loc.Tr("msg.wallet.locked"); return; }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doc = WalletStore.Load(path);
|
|
||||||
if (IsWalletOpen)
|
|
||||||
CloseWallet();
|
|
||||||
OpenLoaded(doc, WalletLoader.ToAccount(doc), path, password: null, newLock);
|
|
||||||
}
|
|
||||||
catch (WrongPasswordException)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
if (IsWalletOpen)
|
|
||||||
CloseWallet();
|
|
||||||
_pendingOpenPath = path;
|
|
||||||
WalletFileExists = true;
|
|
||||||
PasswordInput = "";
|
|
||||||
SetupStep = StepOpen;
|
|
||||||
StatusMessage = "";
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = Loc.Tr("msg.wallet.noaccess");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
newLock.Dispose();
|
|
||||||
StatusMessage = $"Errore: {ex.Message}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void NewWallet()
|
private void NewWallet()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>Transaction history row for the view.</summary>
|
/// <summary>Transaction history row for the view.</summary>
|
||||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
|
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
|
||||||
|
|
||||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||||
public sealed record AddressRow(
|
public sealed record AddressRow(
|
||||||
@@ -96,6 +96,11 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsMobile => !IsDesktop;
|
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 string UnitLabel => _config.Unit;
|
||||||
public AppConfig CurrentConfig => _config;
|
public AppConfig CurrentConfig => _config;
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||||
_keepAliveTimer.Start();
|
_keepAliveTimer.Start();
|
||||||
|
_ = CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||||
|
|||||||
@@ -62,10 +62,8 @@ public sealed class TransactionDetailsViewModel
|
|||||||
VersionText = d.Version.ToString();
|
VersionText = d.Version.ToString();
|
||||||
LockTimeText = d.LockTime.ToString();
|
LockTimeText = d.LockTime.ToString();
|
||||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||||
VerifiedText = d.Verified ? "✓ SPV" : "—";
|
|
||||||
|
|
||||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
|
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
||||||
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
||||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||||
i.IsMine)));
|
i.IsMine)));
|
||||||
@@ -93,7 +91,6 @@ public sealed class TransactionDetailsViewModel
|
|||||||
public string VersionText { get; }
|
public string VersionText { get; }
|
||||||
public string LockTimeText { get; }
|
public string LockTimeText { get; }
|
||||||
public string RbfText { get; }
|
public string RbfText { get; }
|
||||||
public string VerifiedText { get; }
|
|
||||||
public ObservableCollection<TxIoRow> Inputs { get; }
|
public ObservableCollection<TxIoRow> Inputs { get; }
|
||||||
public ObservableCollection<TxIoRow> Outputs { get; }
|
public ObservableCollection<TxIoRow> Outputs { get; }
|
||||||
|
|
||||||
@@ -111,7 +108,4 @@ public sealed class TransactionDetailsViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
|
||||||
|
|
||||||
private static string Shorten(string txid) =>
|
|
||||||
txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+533
-199
@@ -31,12 +31,15 @@
|
|||||||
<Menu Grid.Row="0" IsVisible="{Binding IsDesktop}">
|
<Menu Grid.Row="0" IsVisible="{Binding IsDesktop}">
|
||||||
<MenuItem Header="{Binding Loc[menu.file]}">
|
<MenuItem Header="{Binding Loc[menu.file]}">
|
||||||
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
|
<MenuItem Header="{Binding Loc[menu.file.new]}" Command="{Binding NewWalletCommand}"/>
|
||||||
<MenuItem Header="{Binding Loc[menu.file.open]}" Click="OnOpenWalletFileClick"
|
|
||||||
IsVisible="{Binding IsDesktop}"/>
|
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
|
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
|
||||||
IsEnabled="{Binding IsWalletOpen}"/>
|
IsEnabled="{Binding IsWalletOpen}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="{Binding Loc[menu.file.quit]}" Command="{Binding QuitAppCommand}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem Header="{Binding Loc[menu.wallet]}"
|
||||||
|
Command="{Binding OpenWalletInfoCommand}"
|
||||||
|
IsVisible="{Binding IsWalletOpen}"/>
|
||||||
<MenuItem Header="{Binding Loc[menu.settings]}"
|
<MenuItem Header="{Binding Loc[menu.settings]}"
|
||||||
Command="{Binding OpenSettingsCommand}"/>
|
Command="{Binding OpenSettingsCommand}"/>
|
||||||
<MenuItem Header="{Binding Loc[menu.help]}"
|
<MenuItem Header="{Binding Loc[menu.help]}"
|
||||||
@@ -47,6 +50,9 @@
|
|||||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8"
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8"
|
||||||
HorizontalAlignment="Right" Margin="8,4"
|
HorizontalAlignment="Right" Margin="8,4"
|
||||||
IsVisible="{Binding IsMobile}">
|
IsVisible="{Binding IsMobile}">
|
||||||
|
<Button Content="{Binding Loc[menu.wallet]}"
|
||||||
|
Command="{Binding OpenWalletInfoCommand}"
|
||||||
|
IsVisible="{Binding IsWalletOpen}"/>
|
||||||
<Button Content="{Binding Loc[menu.settings]}" Command="{Binding OpenSettingsCommand}"/>
|
<Button Content="{Binding Loc[menu.settings]}" Command="{Binding OpenSettingsCommand}"/>
|
||||||
<Button Content="{Binding Loc[menu.help]}" Command="{Binding OpenHelpCommand}"/>
|
<Button Content="{Binding Loc[menu.help]}" Command="{Binding OpenHelpCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -308,6 +314,9 @@
|
|||||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
|
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding UnconfirmedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
||||||
Margin="0,2,0,0"/>
|
Margin="0,2,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -324,7 +333,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
|
||||||
<Setter Property="ItemsPanel">
|
<Setter Property="ItemsPanel">
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<UniformGrid Rows="1"/>
|
<UniformGrid Rows="1" Columns="5"/>
|
||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -332,24 +341,27 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
<Setter Property="Padding" Value="8,14"/>
|
<Setter Property="Padding" Value="8,12"/>
|
||||||
<Setter Property="MinWidth" Value="80"/>
|
|
||||||
</Style>
|
</Style>
|
||||||
</TabControl.Styles>
|
</TabControl.Styles>
|
||||||
|
|
||||||
<!-- 1. History -->
|
<!-- 1. History -->
|
||||||
<TabItem>
|
<TabItem>
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="4" HorizontalAlignment="Center">
|
<StackPanel Spacing="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabSpacing}"
|
||||||
<Viewbox Width="24" Height="24" HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
<Canvas Width="24" Height="24">
|
<Viewbox Width="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
Height="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
Data="M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z"/>
|
HorizontalAlignment="Center">
|
||||||
</Canvas>
|
<Canvas Width="24" Height="24">
|
||||||
</Viewbox>
|
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
||||||
<TextBlock Text="{Binding Loc[tab.history]}" FontSize="13"
|
Data="M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z"/>
|
||||||
HorizontalAlignment="Center"/>
|
</Canvas>
|
||||||
</StackPanel>
|
</Viewbox>
|
||||||
|
<TextBlock Text="{Binding Loc[tab.history]}"
|
||||||
|
FontSize="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabFontSize}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
<Grid RowDefinitions="Auto,*" Margin="4">
|
<Grid RowDefinitions="Auto,*" Margin="4">
|
||||||
<TextBlock Grid.Row="0" Text="{Binding Loc[history.hint]}"
|
<TextBlock Grid.Row="0" Text="{Binding Loc[history.hint]}"
|
||||||
@@ -360,25 +372,20 @@
|
|||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:HistoryRow">
|
<DataTemplate x:DataType="vm:HistoryRow">
|
||||||
<Panel Cursor="Hand">
|
<Panel Cursor="Hand">
|
||||||
<!-- Desktop: 4 fixed columns -->
|
<!-- Desktop: 3 fixed columns -->
|
||||||
<Grid ColumnDefinitions="90,160,*,70"
|
<Grid ColumnDefinitions="90,160,*"
|
||||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||||
FontFamily="monospace" FontSize="12"
|
FontFamily="monospace" FontSize="12"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="{DynamicResource SuccessBrush}"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Mobile: vertical card -->
|
<!-- Mobile: vertical card -->
|
||||||
<StackPanel Spacing="2"
|
<StackPanel Spacing="2"
|
||||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
|
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsMobile}">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<TextBlock Text="{Binding Importo}"
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Importo}"
|
FontFamily="monospace" FontWeight="SemiBold"/>
|
||||||
FontFamily="monospace" FontWeight="SemiBold"/>
|
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Verificata}"
|
|
||||||
Foreground="{DynamicResource SuccessBrush}" FontSize="11"/>
|
|
||||||
</Grid>
|
|
||||||
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
||||||
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
@@ -393,16 +400,20 @@
|
|||||||
<!-- 2. Send -->
|
<!-- 2. Send -->
|
||||||
<TabItem>
|
<TabItem>
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="4" HorizontalAlignment="Center">
|
<StackPanel Spacing="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabSpacing}"
|
||||||
<Viewbox Width="24" Height="24" HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
<Canvas Width="24" Height="24">
|
<Viewbox Width="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
Height="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
Data="M2,21L23,12L2,3V10L17,12L2,14V21Z"/>
|
HorizontalAlignment="Center">
|
||||||
</Canvas>
|
<Canvas Width="24" Height="24">
|
||||||
</Viewbox>
|
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
||||||
<TextBlock Text="{Binding Loc[tab.send]}" FontSize="13"
|
Data="M2,21L23,12L2,3V10L17,12L2,14V21Z"/>
|
||||||
HorizontalAlignment="Center"/>
|
</Canvas>
|
||||||
</StackPanel>
|
</Viewbox>
|
||||||
|
<TextBlock Text="{Binding Loc[tab.send]}"
|
||||||
|
FontSize="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabFontSize}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<Panel Margin="16,14">
|
<Panel Margin="16,14">
|
||||||
@@ -496,62 +507,87 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ── MOBILE: vertical stack ── -->
|
<!-- ── MOBILE: vertical stack ── -->
|
||||||
<StackPanel IsVisible="{Binding IsMobile}" Spacing="14">
|
<StackPanel IsVisible="{Binding IsMobile}" Spacing="12">
|
||||||
<Border Classes="card" Padding="20,16">
|
|
||||||
<StackPanel Spacing="12">
|
<!-- Recipient card with labelled inputs -->
|
||||||
|
<Border Classes="card" Padding="20,18">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[send.sect.recipient]}" Classes="section"/>
|
<TextBlock Text="{Binding Loc[send.sect.recipient]}" Classes="section"/>
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
<!-- Contact picker -->
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Loc[send.from.contact]}"
|
<StackPanel Spacing="4">
|
||||||
VerticalAlignment="Center" Margin="0,0,10,0"
|
<TextBlock Text="{Binding Loc[send.from.contact]}" Classes="label"/>
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}" FontSize="13"/>
|
<ComboBox HorizontalAlignment="Stretch"
|
||||||
<ComboBox Grid.Column="1" HorizontalAlignment="Stretch"
|
|
||||||
ItemsSource="{Binding Contacts}"
|
ItemsSource="{Binding Contacts}"
|
||||||
SelectedItem="{Binding SendToContact}"
|
SelectedItem="{Binding SendToContact}"
|
||||||
PlaceholderText="{Binding Loc[send.contact.hint]}">
|
PlaceholderText="{Binding Loc[send.contact.hint]}"
|
||||||
|
MinHeight="48">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ContactEntry">
|
<DataTemplate x:DataType="vm:ContactEntry">
|
||||||
<TextBlock Text="{Binding Name}"/>
|
<TextBlock Text="{Binding Name}" FontSize="15"/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</Grid>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<!-- Address + QR scan button -->
|
||||||
<TextBox Grid.Column="0"
|
<StackPanel Spacing="4">
|
||||||
PlaceholderText="{Binding Loc[send.to]}"
|
<TextBlock Text="{Binding Loc[send.to]}" Classes="label"/>
|
||||||
Text="{Binding SendTo}"
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
FontFamily="monospace"/>
|
<TextBox Grid.Column="0"
|
||||||
<Button Grid.Column="1" Margin="8,0,0,0" Padding="10,6"
|
PlaceholderText="{Binding Loc[send.to]}"
|
||||||
Command="{Binding ScanQrCommand}"
|
Text="{Binding SendTo}"
|
||||||
ToolTip.Tip="{Binding Loc[send.scan]}">
|
FontFamily="monospace" FontSize="15"
|
||||||
<Viewbox Width="20" Height="20">
|
MinHeight="48"/>
|
||||||
<Canvas Width="24" Height="24">
|
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||||
<Path Fill="{DynamicResource SystemBaseHighColor}"
|
Width="52" Height="48"
|
||||||
Data="M20,5H16.83L15,3H9L7.17,5H4A2,2 0 0,0 2,7V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V7A2,2 0 0,0 20,5M20,19H4V7H8.05L9.88,5H14.12L15.95,7H20V19M12,8A5,5 0 0,0 7,13A5,5 0 0,0 12,18A5,5 0 0,0 17,13A5,5 0 0,0 12,8M12,16A3,3 0 0,1 9,13A3,3 0 0,1 12,10A3,3 0 0,1 15,13A3,3 0 0,1 12,16Z"/>
|
Command="{Binding ScanQrCommand}"
|
||||||
</Canvas>
|
ToolTip.Tip="{Binding Loc[send.scan]}">
|
||||||
</Viewbox>
|
<Viewbox Width="22" Height="22">
|
||||||
</Button>
|
<Canvas Width="24" Height="24">
|
||||||
</Grid>
|
<Path Fill="{DynamicResource SystemBaseHighColor}"
|
||||||
</StackPanel>
|
Data="M20,5H16.83L15,3H9L7.17,5H4A2,2 0 0,0 2,7V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V7A2,2 0 0,0 20,5M20,19H4V7H8.05L9.88,5H14.12L15.95,7H20V19M12,8A5,5 0 0,0 7,13A5,5 0 0,0 12,18A5,5 0 0,0 17,13A5,5 0 0,0 12,8M12,16A3,3 0 0,1 9,13A3,3 0 0,1 12,10A3,3 0 0,1 15,13A3,3 0 0,1 12,16Z"/>
|
||||||
</Border>
|
</Canvas>
|
||||||
<Border Classes="card" Padding="20,16">
|
</Viewbox>
|
||||||
<StackPanel Spacing="12">
|
</Button>
|
||||||
<TextBlock Text="{Binding Loc[send.sect.amount]}" Classes="section"/>
|
</Grid>
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
|
||||||
<TextBox Grid.Column="0" PlaceholderText="0.00000000"
|
|
||||||
Text="{Binding SendAmount}" IsEnabled="{Binding !SendAll}"
|
|
||||||
FontFamily="monospace"/>
|
|
||||||
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
|
|
||||||
VerticalAlignment="Center" Margin="8,0,0,0"
|
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
|
||||||
<CheckBox Grid.Column="2" Content="{Binding Loc[send.all]}"
|
|
||||||
IsChecked="{Binding SendAll}" Margin="10,0,0,0"/>
|
|
||||||
</Grid>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<TextBlock Text="{Binding Loc[send.feerate]}" VerticalAlignment="Center" Classes="label"/>
|
|
||||||
<TextBox Text="{Binding SendFeeRate}" MinWidth="80" FontFamily="monospace"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Amount & fee card with labelled inputs -->
|
||||||
|
<Border Classes="card" Padding="20,18">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<TextBlock Text="{Binding Loc[send.sect.amount]}" Classes="section"/>
|
||||||
|
<!-- Amount field -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Loc[send.amount]}" Classes="label"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
PlaceholderText="0.00000000"
|
||||||
|
Text="{Binding SendAmount}"
|
||||||
|
IsEnabled="{Binding !SendAll}"
|
||||||
|
FontFamily="monospace" FontSize="16"
|
||||||
|
MinHeight="48"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
|
||||||
|
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||||
|
FontWeight="Medium"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Send-all toggle -->
|
||||||
|
<CheckBox Content="{Binding Loc[send.all]}"
|
||||||
|
IsChecked="{Binding SendAll}"
|
||||||
|
MinHeight="44" FontSize="14"/>
|
||||||
|
<!-- Fee rate field -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Loc[send.feerate]}" Classes="label"/>
|
||||||
|
<TextBox Text="{Binding SendFeeRate}"
|
||||||
|
FontFamily="monospace" FontSize="15"
|
||||||
|
MinHeight="48" HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Summary card -->
|
||||||
<Border Classes="card" Padding="20,16"
|
<Border Classes="card" Padding="20,16"
|
||||||
IsVisible="{Binding SendPreview, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding SendPreview, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
@@ -561,15 +597,19 @@
|
|||||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
|
||||||
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
|
<!-- Action buttons: primary (Confirm) prominent, secondary below -->
|
||||||
Command="{Binding PrepareSendCommand}"
|
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
Command="{Binding ConfirmSendCommand}"
|
||||||
<Button Grid.Column="1" Content="{Binding Loc[send.confirm]}" Classes="accent"
|
IsEnabled="{Binding HasPendingSend}"
|
||||||
Command="{Binding ConfirmSendCommand}"
|
MinHeight="52"
|
||||||
IsEnabled="{Binding HasPendingSend}"
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
FontSize="15"/>
|
||||||
</Grid>
|
<Button Content="{Binding Loc[send.prepare]}"
|
||||||
|
Command="{Binding PrepareSendCommand}"
|
||||||
|
MinHeight="48"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -579,16 +619,20 @@
|
|||||||
<!-- 3. Receive -->
|
<!-- 3. Receive -->
|
||||||
<TabItem>
|
<TabItem>
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="4" HorizontalAlignment="Center">
|
<StackPanel Spacing="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabSpacing}"
|
||||||
<Viewbox Width="24" Height="24" HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
<Canvas Width="24" Height="24">
|
<Viewbox Width="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
Height="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
Data="M2,12H4V17H20V12H22V17A2,2 0 0,1 20,19H4A2,2 0 0,1 2,17V12M12,15L17,10L15.59,8.58L13,11.17V2H11V11.17L8.41,8.59L7,10L12,15Z"/>
|
HorizontalAlignment="Center">
|
||||||
</Canvas>
|
<Canvas Width="24" Height="24">
|
||||||
</Viewbox>
|
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
||||||
<TextBlock Text="{Binding Loc[tab.receive]}" FontSize="13"
|
Data="M2,12H4V17H20V12H22V17A2,2 0 0,1 20,19H4A2,2 0 0,1 2,17V12M12,15L17,10L15.59,8.58L13,11.17V2H11V11.17L8.41,8.59L7,10L12,15Z"/>
|
||||||
HorizontalAlignment="Center"/>
|
</Canvas>
|
||||||
</StackPanel>
|
</Viewbox>
|
||||||
|
<TextBlock Text="{Binding Loc[tab.receive]}"
|
||||||
|
FontSize="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabFontSize}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<Panel Margin="16,14">
|
<Panel Margin="16,14">
|
||||||
@@ -635,39 +679,49 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ── MOBILE: vertical stack ── -->
|
<!-- ── MOBILE: vertical stack ── -->
|
||||||
<StackPanel IsVisible="{Binding IsMobile}" Spacing="14">
|
<StackPanel IsVisible="{Binding IsMobile}" Spacing="12">
|
||||||
<Border Classes="card" Padding="24,20"
|
|
||||||
|
<!-- QR card: full-width, centered with generous padding -->
|
||||||
|
<Border Classes="card" Padding="24,22"
|
||||||
IsVisible="{Binding ReceiveQr, Converter={x:Static ObjectConverters.IsNotNull}}">
|
IsVisible="{Binding ReceiveQr, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<StackPanel Spacing="18" HorizontalAlignment="Center">
|
<StackPanel Spacing="16" HorizontalAlignment="Center">
|
||||||
<TextBlock Text="{Binding Loc[receive.next]}" Classes="label"
|
<TextBlock Text="{Binding Loc[receive.next]}" Classes="label"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center" FontSize="13"/>
|
||||||
<Border Background="White" Padding="14" CornerRadius="10"
|
<Border Background="White" Padding="16" CornerRadius="12"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
<Image Source="{Binding ReceiveQr}" Width="200" Height="200"
|
<Image Source="{Binding ReceiveQr}" Width="220" Height="220"
|
||||||
RenderOptions.BitmapInterpolationMode="None"/>
|
RenderOptions.BitmapInterpolationMode="None"/>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Classes="card" Padding="20,18">
|
|
||||||
<StackPanel Spacing="12">
|
<!-- Address card: label + monospace box + big Copy button -->
|
||||||
|
<Border Classes="card" Padding="20,20">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Text="{Binding Loc[receive.your.address]}" Classes="section"/>
|
<TextBlock Text="{Binding Loc[receive.your.address]}" Classes="section"/>
|
||||||
<Border Background="{DynamicResource SurfaceAltBrush}"
|
<Border Background="{DynamicResource SurfaceAltBrush}"
|
||||||
CornerRadius="8" Padding="14,10">
|
CornerRadius="8" Padding="16,12">
|
||||||
<SelectableTextBlock Text="{Binding ReceiveAddress}"
|
<SelectableTextBlock Text="{Binding ReceiveAddress}"
|
||||||
FontFamily="monospace" FontSize="13"
|
FontFamily="monospace" FontSize="14"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
TextAlignment="Center"
|
TextAlignment="Center"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"
|
||||||
|
LineHeight="22"/>
|
||||||
</Border>
|
</Border>
|
||||||
<Button Content="{Binding Loc[receive.copy]}" Classes="accent"
|
<Button Content="{Binding Loc[receive.copy]}" Classes="accent"
|
||||||
Click="OnCopyReceiveAddressClick"
|
Click="OnCopyReceiveAddressClick"
|
||||||
|
MinHeight="52"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
HorizontalContentAlignment="Center"/>
|
HorizontalContentAlignment="Center"
|
||||||
|
FontSize="15"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Hint -->
|
||||||
<TextBlock Text="{Binding Loc[receive.hint]}"
|
<TextBlock Text="{Binding Loc[receive.hint]}"
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}" FontSize="12"
|
Foreground="{DynamicResource TextSecondaryBrush}" FontSize="12"
|
||||||
TextWrapping="Wrap" TextAlignment="Center" Margin="4,0"/>
|
TextWrapping="Wrap" TextAlignment="Center" Margin="4,0"/>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -677,16 +731,20 @@
|
|||||||
<!-- 4. Addresses -->
|
<!-- 4. Addresses -->
|
||||||
<TabItem>
|
<TabItem>
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="4" HorizontalAlignment="Center">
|
<StackPanel Spacing="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabSpacing}"
|
||||||
<Viewbox Width="24" Height="24" HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
<Canvas Width="24" Height="24">
|
<Viewbox Width="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
Height="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
Data="M21,18V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V6H12C10.89,6 10,6.9 10,8V16A2,2 0 0,0 12,18H21M12,16H22V8H12V16M16,13.5A1.5,1.5 0 0,1 14.5,12A1.5,1.5 0 0,1 16,10.5A1.5,1.5 0 0,1 17.5,12A1.5,1.5 0 0,1 16,13.5Z"/>
|
HorizontalAlignment="Center">
|
||||||
</Canvas>
|
<Canvas Width="24" Height="24">
|
||||||
</Viewbox>
|
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
||||||
<TextBlock Text="{Binding Loc[tab.addresses]}" FontSize="13"
|
Data="M21,18V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V6H12C10.89,6 10,6.9 10,8V16A2,2 0 0,0 12,18H21M12,16H22V8H12V16M16,13.5A1.5,1.5 0 0,1 14.5,12A1.5,1.5 0 0,1 16,10.5A1.5,1.5 0 0,1 17.5,12A1.5,1.5 0 0,1 16,13.5Z"/>
|
||||||
HorizontalAlignment="Center"/>
|
</Canvas>
|
||||||
</StackPanel>
|
</Viewbox>
|
||||||
|
<TextBlock Text="{Binding Loc[tab.addresses]}"
|
||||||
|
FontSize="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabFontSize}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
<Grid RowDefinitions="Auto,*" Margin="4">
|
<Grid RowDefinitions="Auto,*" Margin="4">
|
||||||
<!-- Column header — desktop only -->
|
<!-- Column header — desktop only -->
|
||||||
@@ -739,14 +797,18 @@
|
|||||||
<!-- 5. Contacts -->
|
<!-- 5. Contacts -->
|
||||||
<TabItem>
|
<TabItem>
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Spacing="4" HorizontalAlignment="Center">
|
<StackPanel Spacing="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabSpacing}"
|
||||||
<Viewbox Width="24" Height="24" HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
|
<Viewbox Width="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
|
Height="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabIconSize}"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
<Canvas Width="24" Height="24">
|
<Canvas Width="24" Height="24">
|
||||||
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
<Path Fill="{Binding $parent[TabItem].Foreground}"
|
||||||
Data="M16,13C15.71,13 15.38,13 15.03,13.05C16.19,13.89 17,15 17,16.5V19H23V16.5C23,14.17 18.33,13 16,13M8,13C5.67,13 1,14.17 1,16.5V19H15V16.5C15,14.17 10.33,13 8,13M8,11A3,3 0 0,0 11,8A3,3 0 0,0 8,5A3,3 0 0,0 5,8A3,3 0 0,0 8,11M16,11A3,3 0 0,0 19,8A3,3 0 0,0 16,5C15.71,5 15.42,5.03 15.14,5.09C15.68,5.95 16,6.94 16,8C16,9.06 15.68,10.05 15.14,10.91C15.42,10.97 15.71,11 16,11Z"/>
|
Data="M16,13C15.71,13 15.38,13 15.03,13.05C16.19,13.89 17,15 17,16.5V19H23V16.5C23,14.17 18.33,13 16,13M8,13C5.67,13 1,14.17 1,16.5V19H15V16.5C15,14.17 10.33,13 8,13M8,11A3,3 0 0,0 11,8A3,3 0 0,0 8,5A3,3 0 0,0 5,8A3,3 0 0,0 8,11M16,11A3,3 0 0,0 19,8A3,3 0 0,0 16,5C15.71,5 15.42,5.03 15.14,5.09C15.68,5.95 16,6.94 16,8C16,9.06 15.68,10.05 15.14,10.91C15.42,10.97 15.71,11 16,11Z"/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</Viewbox>
|
</Viewbox>
|
||||||
<TextBlock Text="{Binding Loc[tab.contacts]}" FontSize="13"
|
<TextBlock Text="{Binding Loc[tab.contacts]}"
|
||||||
|
FontSize="{Binding $parent[TabControl].((vm:MainWindowViewModel)DataContext).TabFontSize}"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
@@ -837,7 +899,7 @@
|
|||||||
IsVisible="{Binding IsConnected}"/>
|
IsVisible="{Binding IsConnected}"/>
|
||||||
<Ellipse Width="10" Height="10" Fill="{DynamicResource DangerBrush}" VerticalAlignment="Center"
|
<Ellipse Width="10" Height="10" Fill="{DynamicResource DangerBrush}" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding !IsConnected}"/>
|
IsVisible="{Binding !IsConnected}"/>
|
||||||
<TextBlock Text="{Binding ConnectionStatus}" FontSize="12"
|
<TextBlock Text="{Binding ConnectionStatusShort}" FontSize="12"
|
||||||
Foreground="{DynamicResource SystemAccentColor}"
|
Foreground="{DynamicResource SystemAccentColor}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -885,12 +947,25 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Private key (only when available) -->
|
<!-- Private key (only when available) -->
|
||||||
<StackPanel Spacing="4" IsVisible="{Binding HasPrivKey}">
|
<StackPanel Spacing="8" IsVisible="{Binding HasPrivKey}">
|
||||||
<TextBlock Text="{Binding Loc[addr.privkey]}" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
|
<TextBlock Text="{Binding Loc[addr.privkey]}" FontSize="11"
|
||||||
<SelectableTextBlock Text="{Binding PrivKey}"
|
Foreground="{DynamicResource DangerBrush}"/>
|
||||||
FontFamily="monospace" FontSize="12"
|
|
||||||
Foreground="{DynamicResource DangerBrush}"
|
<!-- Not yet revealed: single reveal button -->
|
||||||
TextWrapping="Wrap"/>
|
<Button IsVisible="{Binding !$parent[UserControl].((vm:MainWindowViewModel)DataContext).IsAddressPrivKeyRevealed}"
|
||||||
|
Content="{Binding Loc[walletinfo.seed.reveal]}"
|
||||||
|
Command="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).RequestPrivKeyRevealCommand}"/>
|
||||||
|
|
||||||
|
<!-- Revealed: key text + hide button -->
|
||||||
|
<StackPanel IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsAddressPrivKeyRevealed}"
|
||||||
|
Spacing="6">
|
||||||
|
<SelectableTextBlock Text="{Binding PrivKey}"
|
||||||
|
FontFamily="monospace" FontSize="12"
|
||||||
|
Foreground="{DynamicResource DangerBrush}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<Button Content="{Binding Loc[walletinfo.seed.hide]}"
|
||||||
|
Command="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).HideAddressPrivKeyCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Content="{Binding Loc[addr.close]}"
|
<Button Content="{Binding Loc[addr.close]}"
|
||||||
@@ -901,6 +976,38 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ PRIVATE KEY PASSWORD PROMPT ============ -->
|
||||||
|
<!-- Appears on top of the address detail overlay when the wallet is encrypted. -->
|
||||||
|
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||||
|
Background="{DynamicResource ScrimBrush}"
|
||||||
|
IsVisible="{Binding IsPrivKeyPromptOpen}">
|
||||||
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
|
MaxWidth="360" Margin="16"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Margin="24" Spacing="14">
|
||||||
|
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
||||||
|
FontSize="16" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding Loc[addr.privkey.prompt.desc]}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBox Text="{Binding PrivKeyPromptPassword}"
|
||||||
|
PasswordChar="●"
|
||||||
|
PlaceholderText="{Binding Loc[wiz.open.placeholder]}"/>
|
||||||
|
<TextBlock Text="{Binding PrivKeyPromptError}"
|
||||||
|
Foreground="{DynamicResource DangerBrush}" FontSize="12"
|
||||||
|
IsVisible="{Binding PrivKeyPromptError,
|
||||||
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="{Binding Loc[addr.close]}"
|
||||||
|
Command="{Binding CancelPrivKeyPromptCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[walletinfo.seed.reveal]}" Classes="accent"
|
||||||
|
Command="{Binding ConfirmPrivKeyPasswordCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ TRANSACTION DETAIL OVERLAY ============ -->
|
<!-- ============ TRANSACTION DETAIL OVERLAY ============ -->
|
||||||
<!-- In-app overlay (like the address overlay): appears immediately with a
|
<!-- In-app overlay (like the address overlay): appears immediately with a
|
||||||
spinner; data arrives from the server in the background; instant close. -->
|
spinner; data arrives from the server in the background; instant close. -->
|
||||||
@@ -910,7 +1017,7 @@
|
|||||||
IsVisible="{Binding IsTxDetailsOpen}">
|
IsVisible="{Binding IsTxDetailsOpen}">
|
||||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
MaxWidth="540" MaxHeight="600" Margin="16" Padding="0"
|
MaxWidth="660" MaxHeight="800" Margin="16" Padding="0"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<Panel Margin="24">
|
<Panel Margin="24">
|
||||||
|
|
||||||
@@ -958,20 +1065,6 @@
|
|||||||
FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Classes="chip" Margin="0,0,8,6"
|
|
||||||
Background="{DynamicResource PrimarySoftBrush}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Viewbox Width="14" Height="14" VerticalAlignment="Center">
|
|
||||||
<Canvas Width="24" Height="24">
|
|
||||||
<Path Fill="{DynamicResource SuccessBrush}"
|
|
||||||
Data="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/>
|
|
||||||
</Canvas>
|
|
||||||
</Viewbox>
|
|
||||||
<TextBlock Text="{Binding VerifiedText}"
|
|
||||||
Foreground="{DynamicResource SuccessBrush}"
|
|
||||||
FontSize="12" FontWeight="Medium" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Classes="chip" Margin="0,0,8,6">
|
<Border Classes="chip" Margin="0,0,8,6">
|
||||||
<TextBlock Text="{Binding StatusText}" FontSize="12"
|
<TextBlock Text="{Binding StatusText}" FontSize="12"
|
||||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
@@ -1045,38 +1138,48 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="section" Text="{Binding Loc[tx.inputs]}"/>
|
<TextBlock Classes="section" Text="{Binding Loc[tx.inputs]}"/>
|
||||||
<Border Classes="card" Padding="14,6">
|
<Border Classes="card" Padding="14,4">
|
||||||
<ItemsControl ItemsSource="{Binding Inputs}">
|
<ItemsControl ItemsSource="{Binding Inputs}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TxIoRow">
|
<DataTemplate x:DataType="vm:TxIoRow">
|
||||||
<Grid ColumnDefinitions="160,*,Auto" Margin="0,4">
|
<!-- Two-line layout: txid ref on first line, address+amount on second -->
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Position}"
|
<StackPanel Margin="0,6,0,4">
|
||||||
FontFamily="monospace" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
|
<SelectableTextBlock Text="{Binding Position}"
|
||||||
TextTrimming="CharacterEllipsis" Margin="0,0,8,0"/>
|
FontFamily="monospace" FontSize="10"
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Address}"
|
Foreground="{DynamicResource TextMutedBrush}"
|
||||||
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
|
TextWrapping="Wrap"/>
|
||||||
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
|
<Grid ColumnDefinitions="*,Auto" Margin="0,2,0,0">
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Amount}"
|
<SelectableTextBlock Grid.Column="0" Text="{Binding Address}"
|
||||||
FontFamily="monospace" FontFeatures="+tnum" FontSize="11" Margin="8,0,0,0"/>
|
FontFamily="monospace" FontSize="11"
|
||||||
</Grid>
|
TextWrapping="Wrap" VerticalAlignment="Top"
|
||||||
|
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
|
||||||
|
<SelectableTextBlock Grid.Column="1" Text="{Binding Amount}"
|
||||||
|
FontFamily="monospace" FontFeatures="+tnum" FontSize="11"
|
||||||
|
VerticalAlignment="Top" Margin="12,0,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="section" Text="{Binding Loc[tx.outputs]}"/>
|
<TextBlock Classes="section" Text="{Binding Loc[tx.outputs]}"/>
|
||||||
<Border Classes="card" Padding="14,6">
|
<Border Classes="card" Padding="14,4">
|
||||||
<ItemsControl ItemsSource="{Binding Outputs}">
|
<ItemsControl ItemsSource="{Binding Outputs}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TxIoRow">
|
<DataTemplate x:DataType="vm:TxIoRow">
|
||||||
<Grid ColumnDefinitions="44,*,Auto" Margin="0,4">
|
<Grid ColumnDefinitions="44,*,Auto" Margin="0,6">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Position}"
|
<TextBlock Grid.Column="0" Text="{Binding Position}"
|
||||||
FontFamily="monospace" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"/>
|
FontFamily="monospace" FontSize="11"
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Address}"
|
Foreground="{DynamicResource TextMutedBrush}"
|
||||||
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
|
VerticalAlignment="Top"/>
|
||||||
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
|
<SelectableTextBlock Grid.Column="1" Text="{Binding Address}"
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Amount}"
|
FontFamily="monospace" FontSize="11"
|
||||||
FontFamily="monospace" FontFeatures="+tnum" FontSize="11" Margin="8,0,0,0"/>
|
TextWrapping="Wrap" VerticalAlignment="Top"
|
||||||
|
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
|
||||||
|
<SelectableTextBlock Grid.Column="2" Text="{Binding Amount}"
|
||||||
|
FontFamily="monospace" FontFeatures="+tnum" FontSize="11"
|
||||||
|
VerticalAlignment="Top" Margin="12,0,0,0"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
@@ -1106,21 +1209,17 @@
|
|||||||
<TextBlock Text="{Binding Loc[server.title]}"
|
<TextBlock Text="{Binding Loc[server.title]}"
|
||||||
FontSize="18" FontWeight="Bold"/>
|
FontSize="18" FontWeight="Bold"/>
|
||||||
|
|
||||||
<!-- Host + port — Desktop: 3 columns -->
|
<!-- Host + port — Desktop: 2 columns -->
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" RowDefinitions="Auto,Auto"
|
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto"
|
||||||
IsVisible="{Binding IsDesktop}">
|
IsVisible="{Binding IsDesktop}">
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[server.host]}"
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[server.host]}"
|
||||||
FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Loc[server.port]}"
|
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Loc[server.port]}"
|
||||||
FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}" Margin="10,0,0,0" Width="90"/>
|
FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}" Margin="10,0,0,0" Width="90"/>
|
||||||
<TextBlock Grid.Row="0" Grid.Column="2" Text="TLS"
|
|
||||||
FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}" Margin="10,0,0,0"/>
|
|
||||||
<TextBox Grid.Row="1" Grid.Column="0" Text="{Binding ServerHost}"
|
<TextBox Grid.Row="1" Grid.Column="0" Text="{Binding ServerHost}"
|
||||||
FontFamily="monospace" Margin="0,2,0,0"/>
|
FontFamily="monospace" Margin="0,2,0,0"/>
|
||||||
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ServerPort}"
|
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ServerPort}"
|
||||||
FontFamily="monospace" Width="90" Margin="10,2,0,0"/>
|
FontFamily="monospace" Width="90" Margin="10,2,0,0"/>
|
||||||
<CheckBox Grid.Row="1" Grid.Column="2" IsChecked="{Binding UseSsl}"
|
|
||||||
Margin="10,2,0,0" VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Host + port — Mobile: vertical, larger font -->
|
<!-- Host + port — Mobile: vertical, larger font -->
|
||||||
<StackPanel Spacing="10" IsVisible="{Binding IsMobile}">
|
<StackPanel Spacing="10" IsVisible="{Binding IsMobile}">
|
||||||
@@ -1128,25 +1227,16 @@
|
|||||||
<TextBlock Text="{Binding Loc[server.host]}" FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<TextBlock Text="{Binding Loc[server.host]}" FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<TextBox Text="{Binding ServerHost}" FontFamily="monospace" FontSize="15"/>
|
<TextBox Text="{Binding ServerHost}" FontFamily="monospace" FontSize="15"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<StackPanel Spacing="4">
|
||||||
<StackPanel Grid.Column="0" Spacing="4">
|
<TextBlock Text="{Binding Loc[server.port]}" FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<TextBlock Text="{Binding Loc[server.port]}" FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<TextBox Text="{Binding ServerPort}" FontFamily="monospace" FontSize="15"/>
|
||||||
<TextBox Text="{Binding ServerPort}" FontFamily="monospace" FontSize="15"/>
|
</StackPanel>
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="1" Spacing="4" Margin="20,0,0,0"
|
|
||||||
VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="TLS" FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
<CheckBox IsChecked="{Binding UseSsl}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Actions — Desktop: in a row -->
|
<!-- Actions — Desktop: in a row -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
||||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
Command="{Binding ConnectAndSyncCommand}"/>
|
||||||
<Button Content="{Binding Loc[wallet.discover]}"
|
<Button Content="{Binding Loc[wallet.discover]}"
|
||||||
Command="{Binding DiscoverServersCommand}"/>
|
Command="{Binding DiscoverServersCommand}"/>
|
||||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||||
@@ -1155,7 +1245,7 @@
|
|||||||
<!-- Actions — Mobile: stacked -->
|
<!-- Actions — Mobile: stacked -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
||||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"
|
Command="{Binding ConnectAndSyncCommand}"
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
<Button Content="{Binding Loc[wallet.discover]}"
|
<Button Content="{Binding Loc[wallet.discover]}"
|
||||||
Command="{Binding DiscoverServersCommand}"
|
Command="{Binding DiscoverServersCommand}"
|
||||||
@@ -1166,14 +1256,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Connection status -->
|
<!-- Connection status -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<TextBlock Text="{Binding ConnectionStatus}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<Ellipse Width="10" Height="10" Fill="{DynamicResource SuccessBrush}" VerticalAlignment="Center"
|
|
||||||
IsVisible="{Binding IsConnected}"/>
|
|
||||||
<Ellipse Width="10" Height="10" Fill="{DynamicResource DangerBrush}" VerticalAlignment="Center"
|
|
||||||
IsVisible="{Binding !IsConnected}"/>
|
|
||||||
<TextBlock Text="{Binding ConnectionStatus}" Foreground="{DynamicResource TextSecondaryBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Known servers: click to populate host/port -->
|
<!-- Known servers: click to populate host/port -->
|
||||||
<TextBlock Text="{Binding Loc[server.known]}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<TextBlock Text="{Binding Loc[server.known]}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
@@ -1234,6 +1317,154 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ WALLET INFO OVERLAY ============ -->
|
||||||
|
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||||
|
Background="{DynamicResource ScrimBrush}"
|
||||||
|
Tapped="OnWalletInfoOverlayBackdropTapped"
|
||||||
|
IsVisible="{Binding IsWalletInfoOpen}">
|
||||||
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
|
MaxWidth="500" Margin="16"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<ScrollViewer MaxHeight="620">
|
||||||
|
<StackPanel Margin="24" Spacing="14">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
||||||
|
FontSize="18" FontWeight="Bold"/>
|
||||||
|
|
||||||
|
<!-- File name -->
|
||||||
|
<StackPanel Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.file]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<SelectableTextBlock Text="{Binding WalletInfoFileName}"
|
||||||
|
FontFamily="monospace" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Network -->
|
||||||
|
<StackPanel Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.network]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBlock Text="{Binding WalletInfoNetwork}" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Wallet type -->
|
||||||
|
<StackPanel Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.type]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBlock Text="{Binding WalletInfoType}" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Script kind -->
|
||||||
|
<StackPanel Spacing="3">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.script]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBlock Text="{Binding WalletInfoScriptKind}" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Derivation path (only if present) -->
|
||||||
|
<StackPanel Spacing="3"
|
||||||
|
IsVisible="{Binding WalletInfoDerivPath,
|
||||||
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.derivpath]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<SelectableTextBlock Text="{Binding WalletInfoDerivPath}"
|
||||||
|
FontFamily="monospace" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Master fingerprint (only if present) -->
|
||||||
|
<StackPanel Spacing="3"
|
||||||
|
IsVisible="{Binding WalletInfoFingerprint,
|
||||||
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.fingerprint]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<SelectableTextBlock Text="{Binding WalletInfoFingerprint}"
|
||||||
|
FontFamily="monospace" FontSize="13"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Account xpub (only if present) -->
|
||||||
|
<StackPanel Spacing="3"
|
||||||
|
IsVisible="{Binding WalletInfoXpub,
|
||||||
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.xpub]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<SelectableTextBlock Text="{Binding WalletInfoXpub}"
|
||||||
|
FontFamily="monospace" FontSize="11"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- BIP39 passphrase indicator -->
|
||||||
|
<StackPanel Spacing="3" IsVisible="{Binding WalletInfoHasPassphrase}">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.passphrase]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.passphrase.set]}"
|
||||||
|
FontSize="13" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<Border Height="1" Background="{DynamicResource BorderSubtleBrush}" Margin="0,4"/>
|
||||||
|
|
||||||
|
<!-- Seed section -->
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.seed.section]}"
|
||||||
|
FontSize="13" FontWeight="SemiBold"/>
|
||||||
|
|
||||||
|
<!-- No seed wallet -->
|
||||||
|
<TextBlock IsVisible="{Binding !WalletInfoHasSeed}"
|
||||||
|
Text="{Binding Loc[walletinfo.seed.noseed]}"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<!-- Seed wallet: needs password -->
|
||||||
|
<StackPanel IsVisible="{Binding WalletInfoHasSeed}" Spacing="10">
|
||||||
|
|
||||||
|
<!-- Not yet revealed + password needed -->
|
||||||
|
<StackPanel IsVisible="{Binding WalletInfoSeedNeedsPassword}" Spacing="8">
|
||||||
|
<StackPanel IsVisible="{Binding !IsWalletInfoSeedRevealed}" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.seed.password]}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBox Text="{Binding WalletInfoSeedPasswordInput}"
|
||||||
|
PasswordChar="●"
|
||||||
|
PlaceholderText="{Binding Loc[wiz.open.placeholder]}"/>
|
||||||
|
<TextBlock Text="{Binding WalletInfoSeedError}"
|
||||||
|
Foreground="{DynamicResource DangerBrush}" FontSize="12"
|
||||||
|
IsVisible="{Binding WalletInfoSeedError,
|
||||||
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<Button Content="{Binding Loc[walletinfo.seed.reveal]}"
|
||||||
|
Command="{Binding RevealSeedCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Not yet revealed + no password needed -->
|
||||||
|
<Button IsVisible="{Binding !WalletInfoSeedNeedsPassword}"
|
||||||
|
Content="{Binding Loc[walletinfo.seed.reveal]}"
|
||||||
|
Command="{Binding RevealSeedCommand}"
|
||||||
|
IsEnabled="{Binding !IsWalletInfoSeedRevealed}"/>
|
||||||
|
|
||||||
|
<!-- Revealed seed -->
|
||||||
|
<StackPanel IsVisible="{Binding IsWalletInfoSeedRevealed}" Spacing="8">
|
||||||
|
<Border BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="14"
|
||||||
|
Background="{DynamicResource SurfaceAltBrush}">
|
||||||
|
<SelectableTextBlock Text="{Binding WalletInfoSeedText}"
|
||||||
|
FontFamily="monospace" FontSize="15"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding Loc[walletinfo.seed.warning]}"
|
||||||
|
Foreground="{DynamicResource DangerBrush}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
<Button Content="{Binding Loc[walletinfo.seed.hide]}"
|
||||||
|
Command="{Binding HideSeedCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Content="{Binding Loc[addr.close]}"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Command="{Binding CloseWalletInfoCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ SETTINGS OVERLAY ============ -->
|
<!-- ============ SETTINGS OVERLAY ============ -->
|
||||||
<!-- In-app instead of nested submenus: avoids slow OS popups on WSLg. -->
|
<!-- In-app instead of nested submenus: avoids slow OS popups on WSLg. -->
|
||||||
<Border Grid.Row="0" Grid.RowSpan="3"
|
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||||
@@ -1315,17 +1546,91 @@
|
|||||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
MaxWidth="440" Margin="16"
|
MaxWidth="440" Margin="16"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||||
<StackPanel Margin="24" Spacing="16">
|
<StackPanel Margin="24" Spacing="16">
|
||||||
<TextBlock Text="{Binding Loc[help.title]}"
|
<TextBlock Text="{Binding Loc[help.title]}"
|
||||||
FontSize="18" FontWeight="Bold"/>
|
FontSize="18" FontWeight="Bold"/>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<!-- Fixed height so the overlay never resizes when switching tabs. -->
|
||||||
<TextBlock Text="Palladium Wallet" FontSize="16" FontWeight="Bold"/>
|
<TabControl Height="320">
|
||||||
<TextBlock Text="{Binding WindowTitle}" FontSize="12" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<!-- Tab: Info -->
|
||||||
</StackPanel>
|
<TabItem Header="{Binding Loc[help.tab.info]}">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Palladium Wallet" FontSize="16" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text="{Binding WindowTitle}" FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
|
||||||
|
<Button Content="{Binding Loc[help.bug.report]}"
|
||||||
|
Click="OnOpenBugReportClick"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
<Border BorderBrush="{DynamicResource BorderSubtleBrush}"
|
||||||
|
BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="© 2026 Davide Grilli — MIT License"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<TextBlock Text="Built with .NET 10 · Avalonia 12 · NBitcoin"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
|
<!-- Tab: Donate -->
|
||||||
|
<TabItem Header="{Binding Loc[help.tab.donate]}">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||||
|
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
|
||||||
|
<!-- Dev address (read-only) -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Loc[donate.dev.address]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<SelectableTextBlock Text="{x:Static vm:MainWindowViewModel.DevAddress}"
|
||||||
|
FontFamily="monospace" FontSize="11"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Amount input -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="{Binding Loc[donate.amount]}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding DonateAmount}"
|
||||||
|
PlaceholderText="{Binding UnitLabel}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding UnitLabel}"
|
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<SelectableTextBlock Text="{Binding DonatePreview}"
|
||||||
|
IsVisible="{Binding DonatePreview,
|
||||||
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
FontFamily="monospace" FontSize="11"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="{Binding Loc[donate.confirm]}"
|
||||||
|
Classes="accent"
|
||||||
|
IsEnabled="{Binding HasPendingDonate}"
|
||||||
|
Command="{Binding ConfirmDonateCommand}"/>
|
||||||
|
<Button Content="{Binding Loc[donate.prepare]}"
|
||||||
|
Command="{Binding PrepareDonateCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
|
||||||
<Button Content="{Binding Loc[addr.close]}"
|
<Button Content="{Binding Loc[addr.close]}"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
@@ -1333,5 +1638,34 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ UPDATE AVAILABLE OVERLAY ============ -->
|
||||||
|
<!-- Same pattern as the help overlay: instant open/close. -->
|
||||||
|
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||||
|
Background="{DynamicResource ScrimBrush}"
|
||||||
|
Tapped="OnUpdateAvailableOverlayBackdropTapped"
|
||||||
|
IsVisible="{Binding IsUpdateAvailableOpen}">
|
||||||
|
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||||
|
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||||
|
MaxWidth="440" Margin="16"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Margin="24" Spacing="16">
|
||||||
|
<TextBlock Text="{Binding Loc[update.title]}"
|
||||||
|
FontSize="18" FontWeight="Bold"/>
|
||||||
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
<Run Text="{Binding Loc[update.message]}"/>
|
||||||
|
<Run Text=" "/>
|
||||||
|
<Run Text="{Binding UpdateAvailableTag}" FontWeight="Bold"/>
|
||||||
|
</TextBlock>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Content="{Binding Loc[update.download]}"
|
||||||
|
Classes="accent"
|
||||||
|
Click="OnOpenReleasePageClick"/>
|
||||||
|
<Button Content="{Binding Loc[update.dismiss]}"
|
||||||
|
Command="{Binding CloseUpdateAvailableCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
@@ -23,25 +24,6 @@ public partial class MainView : UserControl
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not MainWindowViewModel vm) return;
|
|
||||||
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage) return;
|
|
||||||
|
|
||||||
var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
|
|
||||||
{
|
|
||||||
Title = "Apri file wallet",
|
|
||||||
AllowMultiple = false,
|
|
||||||
FileTypeFilter =
|
|
||||||
[
|
|
||||||
new FilePickerFileType("Wallet Palladium") { Patterns = ["*.wallet.json", "*.json"] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
|
|
||||||
vm.OpenFromPath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
|
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
|
||||||
@@ -126,6 +108,13 @@ public partial class MainView : UserControl
|
|||||||
vm.IsServerSettingsOpen = true;
|
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)
|
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(e.Source, sender)) return;
|
if (!ReferenceEquals(e.Source, sender)) return;
|
||||||
@@ -140,16 +129,43 @@ public partial class MainView : UserControl
|
|||||||
vm.IsHelpOpen = false;
|
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 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.
|
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||||
protected override void OnKeyDown(KeyEventArgs e)
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if ((e.Key == Key.Escape || e.Key == Key.Back) && DataContext is MainWindowViewModel vm)
|
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.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
|
||||||
if (vm.AddressInfo is not null) { vm.AddressInfo = 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.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.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = 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);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -104,8 +104,9 @@ static int Info(string[] o)
|
|||||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||||
if (doc.Cache is { } cache)
|
if (doc.Cache is { } cache)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
||||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
|
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} in maturazione (non spendibile)" : "")
|
||||||
|
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa di conferma (non spendibile)." : ""));
|
||||||
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
||||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||||
}
|
}
|
||||||
@@ -147,6 +148,7 @@ static async Task<int> Sync(string[] o)
|
|||||||
TipHeight = result.TipHeight,
|
TipHeight = result.TipHeight,
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
|
ImmatureSats = result.ImmatureSats,
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
@@ -158,7 +160,8 @@ static async Task<int> Sync(string[] o)
|
|||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendibile"
|
||||||
|
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} in maturazione (non spendibile)" : "")
|
||||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
||||||
Console.WriteLine($"Storico ({result.History.Count}):");
|
Console.WriteLine($"Storico ({result.History.Count}):");
|
||||||
foreach (var tx in result.History)
|
foreach (var tx in result.History)
|
||||||
@@ -193,7 +196,7 @@ static async Task<int> Send(string[] o)
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
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($"txid: {built.Txid}");
|
||||||
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ public sealed record ChainProfile
|
|||||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||||
public required int BlockTimeSeconds { get; init; }
|
public required int BlockTimeSeconds { get; init; }
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>BIP32/SLIP-132 headers for each script type.</summary>
|
||||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public static class ChainProfiles
|
|||||||
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
||||||
SkipPowValidation = true,
|
SkipPowValidation = true,
|
||||||
BlockTimeSeconds = 120,
|
BlockTimeSeconds = 120,
|
||||||
|
CoinbaseMaturity = 120,
|
||||||
|
MinConfirmations = 6,
|
||||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||||
{
|
{
|
||||||
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
||||||
@@ -53,6 +55,7 @@ public static class ChainProfiles
|
|||||||
{
|
{
|
||||||
Kind = NetKind.Testnet,
|
Kind = NetKind.Testnet,
|
||||||
NetName = "testnet",
|
NetName = "testnet",
|
||||||
|
MinConfirmations = 1,
|
||||||
WifPrefix = 0xff,
|
WifPrefix = 0xff,
|
||||||
AddrP2pkh = 127,
|
AddrP2pkh = 127,
|
||||||
AddrP2sh = 115,
|
AddrP2sh = 115,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
|
|||||||
MajorityRejectBlockOutdated = 950,
|
MajorityRejectBlockOutdated = 950,
|
||||||
MajorityWindow = 1000,
|
MajorityWindow = 1000,
|
||||||
MinimumChainWork = uint256.Zero,
|
MinimumChainWork = uint256.Zero,
|
||||||
CoinbaseMaturity = 100,
|
CoinbaseMaturity = 120,
|
||||||
SupportSegwit = true,
|
SupportSegwit = true,
|
||||||
SupportTaproot = true,
|
SupportTaproot = true,
|
||||||
ConsensusFactory = new ConsensusFactory(),
|
ConsensusFactory = new ConsensusFactory(),
|
||||||
|
|||||||
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Dictionary<string, string> Load() =>
|
private Dictionary<string, string> Load()
|
||||||
File.Exists(filePath)
|
{
|
||||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
|
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)
|
private void Save(Dictionary<string, string> pins)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 async Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { 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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,12 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
|
|||||||
using PalladiumWallet.Core.Crypto;
|
using PalladiumWallet.Core.Crypto;
|
||||||
using PalladiumWallet.Core.Net;
|
using PalladiumWallet.Core.Net;
|
||||||
using PalladiumWallet.Core.Storage;
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
namespace PalladiumWallet.Core.Spv;
|
namespace PalladiumWallet.Core.Spv;
|
||||||
|
|
||||||
@@ -21,6 +22,13 @@ public sealed class SyncResult
|
|||||||
public required int TipHeight { get; init; }
|
public required int TipHeight { get; init; }
|
||||||
public required long ConfirmedSats { get; init; }
|
public required long ConfirmedSats { get; init; }
|
||||||
public required long UnconfirmedSats { 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; }
|
||||||
public required int NextReceiveIndex { get; init; }
|
public required int NextReceiveIndex { get; init; }
|
||||||
public required int NextChangeIndex { get; init; }
|
public required int NextChangeIndex { get; init; }
|
||||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||||
@@ -225,6 +233,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
IsChange = addr.IsChange,
|
IsChange = addr.IsChange,
|
||||||
AddressIndex = addr.Index,
|
AddressIndex = addr.Index,
|
||||||
Height = txHeights[txid],
|
Height = txHeights[txid],
|
||||||
|
IsCoinbase = tx.IsCoinBase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +284,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
TipHeight = tip.Height,
|
TipHeight = tip.Height,
|
||||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||||
|
ImmatureSats = utxos.Where(u =>
|
||||||
|
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile))
|
||||||
|
.Sum(u => u.ValueSats),
|
||||||
NextReceiveIndex = nextReceive,
|
NextReceiveIndex = nextReceive,
|
||||||
NextChangeIndex = nextChange,
|
NextChangeIndex = nextChange,
|
||||||
History = history,
|
History = history,
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public sealed class AppConfig
|
|||||||
/// <summary>Display unit for amounts (see <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
/// <summary>Display unit for amounts (see <see cref="Wallet.CoinAmount.Units"/>).</summary>
|
||||||
public string Unit { get; set; } = "PLM";
|
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 };
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
|
|
||||||
public static AppConfig Load(string? path = null)
|
public static AppConfig Load(string? path = null)
|
||||||
|
|||||||
@@ -26,13 +26,21 @@ public static class EncryptedFile
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(fileContent);
|
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;
|
&& f.GetString() == Format;
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return false;
|
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)
|
public static string Encrypt(string plaintext, string password)
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ public sealed class SyncCache
|
|||||||
public int TipHeight { get; set; }
|
public int TipHeight { get; set; }
|
||||||
public long ConfirmedSats { get; set; }
|
public long ConfirmedSats { get; set; }
|
||||||
public long UnconfirmedSats { 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; }
|
||||||
public int NextReceiveIndex { get; set; }
|
public int NextReceiveIndex { get; set; }
|
||||||
public int NextChangeIndex { get; set; }
|
public int NextChangeIndex { get; set; }
|
||||||
public List<CachedTx> History { get; set; } = [];
|
public List<CachedTx> History { get; set; } = [];
|
||||||
@@ -150,5 +153,6 @@ public sealed class CachedUtxo
|
|||||||
public bool IsChange { get; set; }
|
public bool IsChange { get; set; }
|
||||||
public int AddressIndex { get; set; }
|
public int AddressIndex { get; set; }
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
|
public bool IsCoinbase { get; set; }
|
||||||
public bool Frozen { get; set; }
|
public bool Frozen { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</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="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||||
/// <param name="changeIndex">Index of the next change address (internal chain).</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>
|
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||||
public BuiltTransaction Build(
|
public BuiltTransaction Build(
|
||||||
IReadOnlyList<CachedUtxo> utxos,
|
IReadOnlyList<CachedUtxo> utxos,
|
||||||
@@ -48,16 +49,41 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
long amountSats,
|
long amountSats,
|
||||||
decimal feeRateSatPerVByte,
|
decimal feeRateSatPerVByte,
|
||||||
int changeIndex,
|
int changeIndex,
|
||||||
|
int tipHeight,
|
||||||
bool sendAll = false)
|
bool sendAll = false)
|
||||||
{
|
{
|
||||||
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
var profile = account.Profile;
|
||||||
// balance but are not spendable until confirmed.
|
var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
|
||||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
|
||||||
if (spendable.Count == 0)
|
if (spendable.Count == 0)
|
||||||
{
|
{
|
||||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
var reasons = new System.Text.StringBuilder();
|
||||||
throw new WalletSpendException(pending > 0
|
|
||||||
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
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 best = immature.Max(u => u.Confirmations(tipHeight));
|
||||||
|
var threshold = profile.CoinbaseMaturity + 1;
|
||||||
|
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{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 best = underConf.Max(u => u.Confirmations(tipHeight));
|
||||||
|
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WalletSpendException(reasons.Length > 0
|
||||||
|
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||||
: "No spendable UTXOs selected.");
|
: "No spendable UTXOs selected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 and is not frozen.</summary>
|
||||||
|
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||||
|
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class AddressDerivationTests
|
|||||||
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
||||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
||||||
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
||||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
var pubKey = account.GetPublicKey(isChange: false, 0)!;
|
||||||
|
|
||||||
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
|
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
|
||||||
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
|
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
|
||||||
@@ -37,7 +37,7 @@ public class AddressDerivationTests
|
|||||||
{
|
{
|
||||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
|
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
|
||||||
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
|
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
|
||||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
var pubKey = account.GetPublicKey(isChange: false, 0)!;
|
||||||
|
|
||||||
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
|
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
|
||||||
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
|
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ public class Bip84Slip132Tests
|
|||||||
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
|
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
|
||||||
{
|
{
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var pubKey = account.GetPublicKey(isChange, index);
|
var pubKey = account.GetPublicKey(isChange, index)!;
|
||||||
|
|
||||||
if (expectedPubKeyHex is not null)
|
if (expectedPubKeyHex is not null)
|
||||||
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
|
||||||
|
/// certificate passes, changed certificate is rejected until an explicit reset.
|
||||||
|
/// </summary>
|
||||||
|
public class CertificatePinStoreTests
|
||||||
|
{
|
||||||
|
private static string TempPath() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
|
||||||
|
|
||||||
|
private static X509Certificate2 Cert(string cn) =>
|
||||||
|
FakeElectrumServer.CreateSelfSignedCertificate(cn);
|
||||||
|
|
||||||
|
private static void Cleanup(string path)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.GetDirectoryName(path)))
|
||||||
|
Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert = Cert("server");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
|
||||||
|
|
||||||
|
// A fresh instance reads the same file (persistence).
|
||||||
|
Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_certificato_cambiato_viene_rifiutato()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var original = Cert("originale");
|
||||||
|
using var changed = Cert("cambiato");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||||
|
Assert.False(store.VerifyOrPin("host", 50002, changed));
|
||||||
|
// The rejection must not overwrite the pin.
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Host_e_porte_diverse_hanno_pin_indipendenti()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert1 = Cert("uno");
|
||||||
|
using var cert2 = Cert("due");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
|
||||||
|
Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
|
||||||
|
Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
|
||||||
|
Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var original = Cert("originale");
|
||||||
|
using var renewed = Cert("rinnovato");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||||
|
Assert.False(store.VerifyOrPin("host", 50002, renewed));
|
||||||
|
|
||||||
|
Assert.True(store.Reset("host", 50002));
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
|
||||||
|
Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_reset_di_un_server_mai_visto_restituisce_false()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetAll_cancella_il_file_e_tutti_i_pin()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert1 = Cert("uno");
|
||||||
|
using var cert2 = Cert("due");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
store.VerifyOrPin("host-a", 50002, cert1);
|
||||||
|
store.VerifyOrPin("host-b", 50002, cert1);
|
||||||
|
|
||||||
|
store.ResetAll();
|
||||||
|
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_file_pin_corrotto_non_blocca_le_connessioni()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, "{ non-json ");
|
||||||
|
|
||||||
|
using var cert = Cert("server");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
|
||||||
|
{
|
||||||
|
using var cert = Cert("server");
|
||||||
|
var fingerprint = CertificatePinStore.Fingerprint(cert);
|
||||||
|
|
||||||
|
Assert.Equal(64, fingerprint.Length);
|
||||||
|
Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
|
||||||
|
Assert.Equal(
|
||||||
|
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
|
||||||
|
.ToLowerInvariant(),
|
||||||
|
fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
|
||||||
|
/// (real loopback TCP socket: framing, pipelining, and failure paths are the
|
||||||
|
/// same code paths used in production).
|
||||||
|
/// </summary>
|
||||||
|
public class ElectrumClientTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connessione_e_richiesta_fanno_roundtrip()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.banner", _ => "benvenuto");
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
Assert.True(client.IsConnected);
|
||||||
|
Assert.Equal("benvenuto", await client.BannerAsync());
|
||||||
|
// ConnectAsync performs the protocol handshake up front.
|
||||||
|
Assert.Equal(1, server.CallCount("server.version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_errore_del_server_diventa_ElectrumServerException()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.transaction.get",
|
||||||
|
_ => throw new FakeElectrumError(-32600, "tx non trovata"));
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<ElectrumServerException>(
|
||||||
|
() => client.GetTransactionAsync("00"));
|
||||||
|
Assert.Contains("tx non trovata", ex.Message);
|
||||||
|
Assert.Contains("-32600", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("echo", p => p[0].GetInt32());
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
// More requests than the in-flight cap (32): exercises both the write
|
||||||
|
// batching and the id → response correlation.
|
||||||
|
var tasks = Enumerable.Range(0, 200)
|
||||||
|
.Select(i => client.RequestAsync("echo", default, i))
|
||||||
|
.ToList();
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
for (var i = 0; i < results.Length; i++)
|
||||||
|
Assert.Equal(i, results[i].GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
|
||||||
|
|
||||||
|
await server.NotifyAsync("blockchain.headers.subscribe", new object[]
|
||||||
|
{
|
||||||
|
new { height = 4242, hex = "00" },
|
||||||
|
});
|
||||||
|
|
||||||
|
var (method, parameters) = await received.Task.WaitAsync(Timeout);
|
||||||
|
Assert.Equal("blockchain.headers.subscribe", method);
|
||||||
|
Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
client.Disconnected += _ => disconnected.TrySetResult();
|
||||||
|
|
||||||
|
var pending = client.BannerAsync();
|
||||||
|
server.DropClients();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
|
||||||
|
await disconnected.Task.WaitAsync(Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var pending = client.BannerAsync(cts.Token);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending.WaitAsync(Timeout));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.scripthash.listunspent", _ => new object[]
|
||||||
|
{
|
||||||
|
new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
|
||||||
|
});
|
||||||
|
server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
|
||||||
|
server.Handle("blockchain.estimatefee", _ => 0.00012m);
|
||||||
|
server.Handle("blockchain.relayfee", _ => 0.00001m);
|
||||||
|
server.Handle("server.ping", _ => null);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
|
||||||
|
Assert.Equal(12_345L, unspent.ValueSats);
|
||||||
|
Assert.Equal(1, unspent.TxPos);
|
||||||
|
Assert.Equal(99, unspent.Height);
|
||||||
|
|
||||||
|
Assert.Equal("4", await client.BroadcastAsync("beef"));
|
||||||
|
Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
|
||||||
|
Assert.Equal(0.00001m, await client.RelayFeeAsync());
|
||||||
|
await client.PingAsync(); // must not throw
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
|
||||||
|
{
|
||||||
|
var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pins = new CertificatePinStore(pinsPath);
|
||||||
|
int port;
|
||||||
|
|
||||||
|
// First contact: any certificate is pinned (trust on first use).
|
||||||
|
using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer(cert1);
|
||||||
|
port = server.Port;
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(
|
||||||
|
server.Host, port, useSsl: true, pins);
|
||||||
|
Assert.True(client.IsConnected);
|
||||||
|
Assert.True(client.UseSsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same host:port, different certificate: the pin must block the connection.
|
||||||
|
using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
|
||||||
|
{
|
||||||
|
await using var server = await RebindAsync(cert2, port);
|
||||||
|
await Assert.ThrowsAsync<CertificatePinMismatchException>(() =>
|
||||||
|
ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(pinsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebinds a fresh server to the port just released by the previous one.</summary>
|
||||||
|
private static async Task<FakeElectrumServer> RebindAsync(
|
||||||
|
System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
try { return new FakeElectrumServer(cert, port); }
|
||||||
|
catch (System.Net.Sockets.SocketException) when (attempt < 20)
|
||||||
|
{
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error a handler can throw to make the fake server reply with a JSON-RPC
|
||||||
|
/// error object (e.g. code -102 "server busy" to exercise the retry path).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeElectrumError(int code, string message) : Exception(message)
|
||||||
|
{
|
||||||
|
public int Code { get; } = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
|
||||||
|
/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
|
||||||
|
/// Handlers are registered per method and receive the request "params" array;
|
||||||
|
/// whatever they return is serialised as the "result". "server.version" is
|
||||||
|
/// pre-registered. Calls are counted per method (cache/retry assertions).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeElectrumServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly TcpListener _listener;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly Task _acceptLoop;
|
||||||
|
private readonly X509Certificate2? _certificate;
|
||||||
|
private readonly ConcurrentDictionary<string, Func<JsonElement, object?>> _handlers = new();
|
||||||
|
private readonly ConcurrentDictionary<string, int> _callCounts = new();
|
||||||
|
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
|
||||||
|
|
||||||
|
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
|
||||||
|
public static readonly object NoResponse = new();
|
||||||
|
|
||||||
|
public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
|
||||||
|
{
|
||||||
|
_certificate = certificate;
|
||||||
|
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||||
|
_listener.Start();
|
||||||
|
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
|
||||||
|
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||||
|
public string Host => "127.0.0.1";
|
||||||
|
|
||||||
|
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
|
||||||
|
public void Handle(string method, Func<JsonElement, object?> handler) =>
|
||||||
|
_handlers[method] = handler;
|
||||||
|
|
||||||
|
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
|
||||||
|
|
||||||
|
public void ResetCallCounts() => _callCounts.Clear();
|
||||||
|
|
||||||
|
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
|
||||||
|
public async Task NotifyAsync(string method, object parameters)
|
||||||
|
{
|
||||||
|
var line = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
method,
|
||||||
|
@params = parameters,
|
||||||
|
});
|
||||||
|
foreach (var (stream, writeLock, _) in _clients)
|
||||||
|
await WriteLineAsync(stream, writeLock, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
|
||||||
|
public void DropClients()
|
||||||
|
{
|
||||||
|
foreach (var (_, _, tcp) in _clients)
|
||||||
|
tcp.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
|
||||||
|
public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var request = new CertificateRequest(
|
||||||
|
$"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
using var cert = request.CreateSelfSigned(
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
|
||||||
|
// PFX roundtrip so the private key is usable by SslStream on every platform.
|
||||||
|
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptLoopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
|
||||||
|
_ = Task.Run(() => ServeClientAsync(tcp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (SocketException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ServeClientAsync(TcpClient tcp)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Stream stream = tcp.GetStream();
|
||||||
|
if (_certificate is not null)
|
||||||
|
{
|
||||||
|
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||||
|
await ssl.AuthenticateAsServerAsync(_certificate);
|
||||||
|
stream = ssl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var writeLock = new SemaphoreSlim(1, 1);
|
||||||
|
_clients.Add((stream, writeLock, tcp));
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var line = await reader.ReadLineAsync(_cts.Token);
|
||||||
|
if (line is null) break;
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
await HandleRequestAsync(stream, writeLock, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Client went away or TLS failed (e.g. rejected pin): normal in tests.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(line);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var id = root.GetProperty("id").GetInt64();
|
||||||
|
var method = root.GetProperty("method").GetString()!;
|
||||||
|
var parameters = root.TryGetProperty("params", out var p) ? p : default;
|
||||||
|
|
||||||
|
_callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
|
||||||
|
|
||||||
|
byte[] payload;
|
||||||
|
if (!_handlers.TryGetValue(method, out var handler))
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
error = new { code = -32601, message = $"unknown method '{method}'" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = handler(parameters);
|
||||||
|
if (ReferenceEquals(result, NoResponse))
|
||||||
|
return;
|
||||||
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (FakeElectrumError err)
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
error = new { code = err.Code, message = err.Message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteLineAsync(stream, writeLock, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
|
||||||
|
{
|
||||||
|
await writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stream.WriteAsync(payload);
|
||||||
|
stream.WriteByte((byte)'\n');
|
||||||
|
await stream.FlushAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Connection already gone: irrelevant for the test in progress.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
writeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _cts.CancelAsync();
|
||||||
|
_listener.Stop();
|
||||||
|
DropClients();
|
||||||
|
try { await _acceptLoop; } catch { }
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,49 @@ public class ServerRegistryTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.peers.subscribe", _ => new object[]
|
||||||
|
{
|
||||||
|
// Full announcement, port-less announcement (→ profile defaults),
|
||||||
|
// and a bootstrap duplicate that must not be re-added.
|
||||||
|
new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
|
||||||
|
new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
|
||||||
|
new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
|
||||||
|
});
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||||
|
var bootstrapCount = registry.All.Count;
|
||||||
|
|
||||||
|
var added = await registry.DiscoverAsync(client);
|
||||||
|
|
||||||
|
Assert.Equal(2, added);
|
||||||
|
Assert.Equal(bootstrapCount + 2, registry.All.Count);
|
||||||
|
var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
|
||||||
|
Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
|
||||||
|
Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
|
||||||
|
|
||||||
|
// Persisted for the next session (bootstrap servers excluded from the file).
|
||||||
|
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||||
|
Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
|
||||||
|
var savedJson = File.ReadAllText(path);
|
||||||
|
Assert.DoesNotContain("173.212.224.67", savedJson);
|
||||||
|
|
||||||
|
// A second discovery of the same peers adds nothing.
|
||||||
|
Assert.Equal(0, await registry.DiscoverAsync(client));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the release-tag parsing used by the update check. The network
|
||||||
|
/// call itself is best-effort by design (any failure → null) and is not
|
||||||
|
/// exercised here: only the version-comparison logic is deterministic.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateCheckerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("v1.2.3", "1.2.3")]
|
||||||
|
[InlineData("V0.9.1", "0.9.1")]
|
||||||
|
[InlineData("1.2.3", "1.2.3")]
|
||||||
|
[InlineData("0.9.0-beta", "0.9.0")]
|
||||||
|
[InlineData("v2.0.0-rc.1", "2.0.0")]
|
||||||
|
[InlineData(" 1.4 ", "1.4")]
|
||||||
|
public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
|
||||||
|
{
|
||||||
|
Assert.True(UpdateChecker.TryParse(tag, out var version));
|
||||||
|
Assert.Equal(Version.Parse(expected), version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("main")]
|
||||||
|
[InlineData("v")]
|
||||||
|
[InlineData("1")]
|
||||||
|
[InlineData("non-una-versione")]
|
||||||
|
public void I_tag_non_versionati_vengono_rifiutati(string tag)
|
||||||
|
{
|
||||||
|
Assert.False(UpdateChecker.TryParse(tag, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
|
||||||
|
{
|
||||||
|
// The same comparison CheckAsync applies between remote tag and running app.
|
||||||
|
Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
|
||||||
|
Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
|
||||||
|
Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,121 @@ public class PropertyTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scripthash ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The scripthash must equal an independent SHA-256-and-reverse computation
|
||||||
|
/// for any script bytes (cross-check, not a re-run of the same code).
|
||||||
|
[Fact]
|
||||||
|
public void Scripthash_coincide_con_il_calcolo_indipendente_per_ogni_script()
|
||||||
|
{
|
||||||
|
Gen.Byte.Array[0, 64].Sample(bytes =>
|
||||||
|
{
|
||||||
|
var expected = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||||
|
Array.Reverse(expected);
|
||||||
|
Assert.Equal(
|
||||||
|
Convert.ToHexString(expected).ToLowerInvariant(),
|
||||||
|
Core.Spv.Scripthash.FromScript(Script.FromBytesUnsafe(bytes)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slip132 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly Gen<Core.Chain.ScriptKind> GenScriptKind = Gen.OneOfConst(
|
||||||
|
Core.Chain.ScriptKind.Legacy,
|
||||||
|
Core.Chain.ScriptKind.WrappedSegwit,
|
||||||
|
Core.Chain.ScriptKind.NativeSegwit,
|
||||||
|
Core.Chain.ScriptKind.WrappedSegwitMultisig,
|
||||||
|
Core.Chain.ScriptKind.NativeSegwitMultisig);
|
||||||
|
|
||||||
|
private static readonly Gen<Core.Chain.ChainProfile> GenProfile = Gen.OneOfConst(
|
||||||
|
Core.Chain.ChainProfiles.Mainnet, Core.Chain.ChainProfiles.Testnet);
|
||||||
|
|
||||||
|
/// Encode → TryDecodePrivate must roundtrip key bytes and header family
|
||||||
|
/// for any key, script kind, and network.
|
||||||
|
[Fact]
|
||||||
|
public void Slip132_roundtrip_chiave_privata_per_ogni_kind_e_rete()
|
||||||
|
{
|
||||||
|
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
|
||||||
|
{
|
||||||
|
var key = ExtKey.CreateFromSeed(seed);
|
||||||
|
var encoded = Core.Crypto.Slip132.Encode(key, kind, profile);
|
||||||
|
|
||||||
|
Assert.True(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out var decoded, out var decodedKind));
|
||||||
|
Assert.Equal(key.ToBytes(), decoded!.ToBytes());
|
||||||
|
// Legacy and Taproot share the BIP32 header: compare header families, not enum values.
|
||||||
|
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
|
||||||
|
// A private key must never decode as public.
|
||||||
|
Assert.False(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out _, out _));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same roundtrip for the public (watch-only import) side.
|
||||||
|
[Fact]
|
||||||
|
public void Slip132_roundtrip_chiave_pubblica_per_ogni_kind_e_rete()
|
||||||
|
{
|
||||||
|
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
|
||||||
|
{
|
||||||
|
var xpub = ExtKey.CreateFromSeed(seed).Neuter();
|
||||||
|
var encoded = Core.Crypto.Slip132.Encode(xpub, kind, profile);
|
||||||
|
|
||||||
|
Assert.True(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out var decoded, out var decodedKind));
|
||||||
|
Assert.Equal(xpub.ToBytes(), decoded!.ToBytes());
|
||||||
|
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
|
||||||
|
Assert.False(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out _, out _));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TryDecode must never throw on arbitrary input strings.
|
||||||
|
[Fact]
|
||||||
|
public void Slip132_TryDecode_non_lancia_mai_su_input_arbitrario()
|
||||||
|
{
|
||||||
|
Gen.String.Sample(text =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core.Crypto.Slip132.TryDecodePublic(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
|
||||||
|
Core.Crypto.Slip132.TryDecodePrivate(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Assert.Fail($"TryDecode threw {ex.GetType().Name} for '{text}'");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WalletDocument ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// ToJson → FromJson must preserve labels and contacts for arbitrary strings
|
||||||
|
/// (unicode, quotes, control characters…).
|
||||||
|
[Fact]
|
||||||
|
public void WalletDocument_roundtrip_json_con_etichette_e_contatti_arbitrari()
|
||||||
|
{
|
||||||
|
Gen.Select(Gen.Select(Gen.String, Gen.String).Array[0, 8], Gen.String).Sample((pairs, contactName) =>
|
||||||
|
{
|
||||||
|
// Lone UTF-16 surrogates are not representable in JSON (the writer
|
||||||
|
// replaces them): an exact roundtrip is impossible by design, skip.
|
||||||
|
if (pairs.Any(p => p.Item1.Any(char.IsSurrogate) || p.Item2.Any(char.IsSurrogate))
|
||||||
|
|| contactName.Any(char.IsSurrogate))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var doc = new WalletDocument
|
||||||
|
{
|
||||||
|
Network = "regtest",
|
||||||
|
ScriptKind = "NativeSegwit",
|
||||||
|
AccountPath = "84'/1'/0'",
|
||||||
|
AccountXpub = "vpub-test",
|
||||||
|
Contacts = { new StoredContact { Name = contactName, Address = "rplm1qtest" } },
|
||||||
|
};
|
||||||
|
foreach (var (k, v) in pairs)
|
||||||
|
doc.Labels[k] = v;
|
||||||
|
|
||||||
|
var restored = WalletDocument.FromJson(doc.ToJson());
|
||||||
|
|
||||||
|
Assert.Equal(doc.Labels, restored.Labels);
|
||||||
|
Assert.Equal(contactName, Assert.Single(restored.Contacts).Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// helper: builds the branch for the given position
|
// helper: builds the branch for the given position
|
||||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Spv;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Spv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end tests of the SPV synchroniser against the in-process fake server:
|
||||||
|
/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
|
||||||
|
/// path that must reject a lying server), busy retry, and the disk cache.
|
||||||
|
/// Every block in these scenarios contains a single transaction, so the Merkle
|
||||||
|
/// root is the txid itself and the branch is empty (the tree math has its own
|
||||||
|
/// dedicated tests in <see cref="MerkleProofTests"/>).
|
||||||
|
/// </summary>
|
||||||
|
public class WalletSynchronizerTests
|
||||||
|
{
|
||||||
|
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
||||||
|
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||||
|
|
||||||
|
private static HdAccount Account()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
out var mnemonic));
|
||||||
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side chain state: transactions, per-scripthash histories and
|
||||||
|
/// single-transaction block headers, wired onto a FakeElectrumServer.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Scenario
|
||||||
|
{
|
||||||
|
public int TipHeight = 200;
|
||||||
|
public readonly Dictionary<string, Transaction> Txs = [];
|
||||||
|
public readonly Dictionary<string, List<(string Txid, int Height)>> History = [];
|
||||||
|
public readonly Dictionary<int, string> Headers = [];
|
||||||
|
|
||||||
|
/// <summary>Creates a tx paying <paramref name="sats"/> to <paramref name="to"/> and registers it.</summary>
|
||||||
|
public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
|
||||||
|
bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
|
||||||
|
{
|
||||||
|
var tx = Net.CreateTransaction();
|
||||||
|
tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
|
||||||
|
tx.Outputs.Add(Money.Satoshis(sats), to);
|
||||||
|
if (changeTo is not null)
|
||||||
|
tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
|
||||||
|
Register(tx, height, to, changeTo);
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
|
||||||
|
{
|
||||||
|
var txid = tx.GetHash().ToString();
|
||||||
|
Txs[txid] = tx;
|
||||||
|
foreach (var addr in touchedAddresses)
|
||||||
|
{
|
||||||
|
if (addr is null) continue;
|
||||||
|
var sh = Scripthash.FromAddress(addr);
|
||||||
|
if (!History.TryGetValue(sh, out var list))
|
||||||
|
History[sh] = list = [];
|
||||||
|
if (!list.Contains((txid, height)))
|
||||||
|
list.Add((txid, height));
|
||||||
|
}
|
||||||
|
if (height > 0 && !Headers.ContainsKey(height))
|
||||||
|
Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
|
||||||
|
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null)
|
||||||
|
{
|
||||||
|
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
|
header.HashPrevBlock = uint256.Zero;
|
||||||
|
header.HashMerkleRoot = merkleRoot ?? txid;
|
||||||
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||||
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
|
header.Nonce = (uint)height;
|
||||||
|
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WireTo(FakeElectrumServer server)
|
||||||
|
{
|
||||||
|
server.Handle("blockchain.headers.subscribe",
|
||||||
|
_ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
|
||||||
|
server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||||
|
server.Handle("blockchain.scripthash.get_history", p =>
|
||||||
|
(History.GetValueOrDefault(p[0].GetString()!) ?? [])
|
||||||
|
.Select(h => new { tx_hash = h.Txid, height = h.Height }));
|
||||||
|
server.Handle("blockchain.transaction.get", p =>
|
||||||
|
Txs.TryGetValue(p[0].GetString()!, out var tx)
|
||||||
|
? tx.ToHex()
|
||||||
|
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||||
|
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||||
|
new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() });
|
||||||
|
server.Handle("blockchain.block.header", p =>
|
||||||
|
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
||||||
|
? hex
|
||||||
|
: throw new FakeElectrumError(-32600, "no such block"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
|
||||||
|
{
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
scenario.WireTo(server);
|
||||||
|
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
return (server, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- scansione ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
|
||||||
|
{
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(200, result.TipHeight);
|
||||||
|
Assert.Equal(0, result.ConfirmedSats);
|
||||||
|
Assert.Equal(0, result.UnconfirmedSats);
|
||||||
|
Assert.Equal(0, result.NextReceiveIndex);
|
||||||
|
Assert.Equal(0, result.NextChangeIndex);
|
||||||
|
// Default gap limit 20 on both chains.
|
||||||
|
Assert.Equal(40, result.Addresses.Count);
|
||||||
|
Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
|
||||||
|
Assert.Empty(result.Utxos);
|
||||||
|
Assert.Empty(result.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
|
||||||
|
|
||||||
|
// Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
|
||||||
|
Assert.Equal(4, result.NextReceiveIndex);
|
||||||
|
Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
|
||||||
|
Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
|
||||||
|
Assert.Equal(50_000, result.ConfirmedSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ricostruzione UTXO e storico ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
|
||||||
|
Assert.Equal(1, result.NextReceiveIndex);
|
||||||
|
|
||||||
|
var utxo = Assert.Single(result.Utxos);
|
||||||
|
Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
|
||||||
|
Assert.Equal(100, utxo.Height);
|
||||||
|
Assert.False(utxo.IsChange);
|
||||||
|
|
||||||
|
var entry = Assert.Single(result.History);
|
||||||
|
Assert.Equal(1_000_000, entry.DeltaSats);
|
||||||
|
Assert.True(entry.Verified); // Merkle proof checked against the header
|
||||||
|
|
||||||
|
var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
|
||||||
|
Assert.Equal(1_000_000, row.BalanceSats);
|
||||||
|
Assert.Equal(1, row.TxCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
|
||||||
|
// Spend: 600k to an external address, 390k change to change/0, 10k fee.
|
||||||
|
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
var spend = Net.CreateTransaction();
|
||||||
|
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
|
||||||
|
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||||
|
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||||
|
scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
// The funding UTXO is spent: only the change remains.
|
||||||
|
var utxo = Assert.Single(result.Utxos);
|
||||||
|
Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
|
||||||
|
Assert.True(utxo.IsChange);
|
||||||
|
Assert.Equal(390_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(1, result.NextChangeIndex);
|
||||||
|
|
||||||
|
// History newest-first with the net deltas.
|
||||||
|
Assert.Equal(2, result.History.Count);
|
||||||
|
Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
|
||||||
|
Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change − 1M spent
|
||||||
|
Assert.Equal(1_000_000, result.History[1].DeltaSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.ConfirmedSats);
|
||||||
|
Assert.Equal(250_000, result.UnconfirmedSats);
|
||||||
|
var entry = Assert.Single(result.History);
|
||||||
|
Assert.False(entry.Verified);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario { TipHeight = 200 };
|
||||||
|
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(5_000_000_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(5_000_000_000, result.ImmatureSats);
|
||||||
|
Assert.True(Assert.Single(result.Utxos).IsCoinbase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sicurezza SPV ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
// The server lies: the block 100 header commits to a different Merkle root.
|
||||||
|
scenario.Headers[100] = Scenario.SingleTxHeaderHex(
|
||||||
|
funding.GetHash(), 100, merkleRoot: uint256.One);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||||
|
() => new WalletSynchronizer(account, client).SyncOnceAsync());
|
||||||
|
Assert.Contains(funding.GetHash().ToString(), ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resilienza ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
scenario.WireTo(server);
|
||||||
|
// The first 3 history calls fail with the ElectrumX throttling error.
|
||||||
|
var failures = 3;
|
||||||
|
var histories = scenario.History;
|
||||||
|
server.Handle("blockchain.scripthash.get_history", p =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Decrement(ref failures) >= 0)
|
||||||
|
throw new FakeElectrumError(-102, "excessive resource usage");
|
||||||
|
return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
|
||||||
|
.Select(h => new { tx_hash = h.Txid, height = h.Height });
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var _ = server;
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cache su disco ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var first = new WalletSynchronizer(account, client);
|
||||||
|
var result1 = await first.SyncOnceAsync();
|
||||||
|
var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
|
||||||
|
|
||||||
|
Assert.Single(rawTx); // the confirmed tx is exported
|
||||||
|
Assert.Single(verifiedAt); // with its verified height
|
||||||
|
Assert.NotEmpty(headers);
|
||||||
|
|
||||||
|
// Fresh synchroniser (new launch), warm cache: no re-download.
|
||||||
|
server.ResetCallCounts();
|
||||||
|
var second = new WalletSynchronizer(account, client);
|
||||||
|
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||||
|
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
|
||||||
|
var result2 = await second.SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var sync = new WalletSynchronizer(account, client);
|
||||||
|
await sync.SyncOnceAsync();
|
||||||
|
var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
|
||||||
|
|
||||||
|
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
|
||||||
|
Assert.Empty(rawTx);
|
||||||
|
Assert.Empty(verifiedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account a indirizzi fissi (WIF importati) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
|
||||||
|
{
|
||||||
|
var key = new Key();
|
||||||
|
var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(address, 750_000, height: 150);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(750_000, result.ConfirmedSats);
|
||||||
|
Assert.Single(result.Addresses);
|
||||||
|
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
|
||||||
|
Assert.Equal(1, result.NextReceiveIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the data-path resolution (§8), pinned to a temporary root via
|
||||||
|
/// <see cref="AppPaths.OverrideDataRoot"/> — the same seam the Android head and
|
||||||
|
/// the CLI --data-dir use — so nothing outside the temp folder is touched.
|
||||||
|
/// The pointer-file and portable-mode branches read machine-global locations
|
||||||
|
/// and are deliberately not exercised here.
|
||||||
|
/// </summary>
|
||||||
|
public class AppPathsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly string? _savedOverride;
|
||||||
|
|
||||||
|
public AppPathsTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), $"plm-data-{Guid.NewGuid()}");
|
||||||
|
_savedOverride = AppPaths.OverrideDataRoot;
|
||||||
|
AppPaths.OverrideDataRoot = _root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AppPaths.OverrideDataRoot = _savedOverride;
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L_override_ha_priorita_su_tutto()
|
||||||
|
{
|
||||||
|
Assert.Equal(_root, AppPaths.DataRoot());
|
||||||
|
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ogni_rete_ha_la_propria_sottocartella_con_il_nome_del_profilo()
|
||||||
|
{
|
||||||
|
foreach (var net in new[] { NetKind.Mainnet, NetKind.Testnet, NetKind.Regtest })
|
||||||
|
{
|
||||||
|
var dir = AppPaths.ForNetwork(net);
|
||||||
|
Assert.Equal(Path.Combine(_root, ChainProfiles.For(net).NetName), dir);
|
||||||
|
Assert.True(Directory.Exists(dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void I_percorsi_dei_file_di_rete_stanno_sotto_la_cartella_della_rete()
|
||||||
|
{
|
||||||
|
var netDir = AppPaths.ForNetwork(NetKind.Mainnet);
|
||||||
|
|
||||||
|
Assert.Equal(Path.Combine(netDir, "server-certs.json"), AppPaths.CertificatePinsPath(NetKind.Mainnet));
|
||||||
|
Assert.Equal(Path.Combine(netDir, "servers.json"), AppPaths.ServersPath(NetKind.Mainnet));
|
||||||
|
Assert.Equal(Path.Combine(netDir, "wallets", "default.wallet.json"),
|
||||||
|
AppPaths.DefaultWalletPath(NetKind.Mainnet));
|
||||||
|
Assert.Equal(Path.Combine(_root, "config.json"), AppPaths.ConfigPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WalletFiles_elenca_solo_i_wallet_in_ordine_alfabetico()
|
||||||
|
{
|
||||||
|
var dir = AppPaths.WalletsDir(NetKind.Regtest);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "zeta.wallet.json"), "{}");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "alfa.wallet.json"), "{}");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "non-un-wallet.txt"), "x");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "default.wallet.json.lock"), "x");
|
||||||
|
|
||||||
|
var files = AppPaths.WalletFiles(NetKind.Regtest);
|
||||||
|
|
||||||
|
Assert.Equal(2, files.Count);
|
||||||
|
Assert.EndsWith("alfa.wallet.json", files[0]);
|
||||||
|
Assert.EndsWith("zeta.wallet.json", files[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Le_reti_non_condividono_le_cartelle_wallet()
|
||||||
|
{
|
||||||
|
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Mainnet), AppPaths.WalletsDir(NetKind.Testnet));
|
||||||
|
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,31 @@ public class StorageTests
|
|||||||
Assert.False(EncryptedFile.IsEncrypted("non è json"));
|
Assert.False(EncryptedFile.IsEncrypted("non è json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: valid JSON whose root is not an object (found by the
|
||||||
|
// property test) must not throw from TryGetProperty.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("5")]
|
||||||
|
[InlineData("true")]
|
||||||
|
[InlineData("\"stringa\"")]
|
||||||
|
[InlineData("[1, 2]")]
|
||||||
|
public void IsEncrypted_restituisce_false_per_json_con_radice_non_oggetto(string content)
|
||||||
|
{
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEncrypted_restituisce_false_per_utf16_invalido()
|
||||||
|
{
|
||||||
|
// Lone surrogate: cannot be transcoded to UTF-8 for JSON parsing.
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted("\ud800"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEncrypted_restituisce_false_se_Format_non_e_una_stringa()
|
||||||
|
{
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted("{\"Format\": 42}"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- WalletDocument JSON ----
|
// ---- WalletDocument JSON ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
utxos, txs, destination, amountSats: 400_000,
|
utxos, txs, destination, amountSats: 400_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
Assert.True(built.Signed);
|
Assert.True(built.Signed);
|
||||||
// Output: recipient + change.
|
// Output: recipient + change.
|
||||||
@@ -74,7 +74,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(account).Build(
|
var built = new TransactionFactory(account).Build(
|
||||||
utxos, txs, destination, amountSats: 0,
|
utxos, txs, destination, amountSats: 0,
|
||||||
feeRateSatPerVByte: 1, changeIndex: 0, sendAll: true);
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100, sendAll: true);
|
||||||
|
|
||||||
var output = Assert.Single(built.Transaction.Outputs);
|
var output = Assert.Single(built.Transaction.Outputs);
|
||||||
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
||||||
@@ -88,7 +88,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -100,8 +100,8 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
Assert.Contains("pending confirmation", ex.Message);
|
Assert.Contains("unconfirmed", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -113,7 +113,62 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_utxo_coinbase_immaturi_non_sono_spendibili()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
|
utxos[0].IsCoinbase = true;
|
||||||
|
utxos[0].Height = 100;
|
||||||
|
// Threshold = COINBASE_MATURITY + 1 = 121 (mirrors the Qt wallet: consensus rule is
|
||||||
|
// nSpendHeight - nHeight >= 120, plus one block of safety margin).
|
||||||
|
// At height=100, tip=219 → confs = 219-100+1 = 120 < 121 → immature.
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 219));
|
||||||
|
Assert.Contains("coinbase", ex.Message);
|
||||||
|
Assert.Contains("120/121", ex.Message);
|
||||||
|
|
||||||
|
// tip=220 → confs = 121 ≥ 121 → mature and spendable.
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 220);
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gli_utxo_con_meno_di_minconf_non_sono_spendibili_su_mainnet()
|
||||||
|
{
|
||||||
|
var mainnetAccount = HdAccount.FromMnemonic(
|
||||||
|
Bip39.TryParse("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", out var m) ? m! : throw new Exception(),
|
||||||
|
null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
|
||||||
|
var funding = PalladiumNetworks.Mainnet.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), mainnetAccount.GetReceiveAddress(0));
|
||||||
|
var txid = funding.GetHash().ToString();
|
||||||
|
var utxos = new List<CachedUtxo>
|
||||||
|
{
|
||||||
|
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
||||||
|
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
||||||
|
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
|
||||||
|
};
|
||||||
|
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
||||||
|
|
||||||
|
// 5 confirmations (tipHeight=104): below the mainnet minimum of 6.
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(mainnetAccount).Build(
|
||||||
|
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 104));
|
||||||
|
Assert.Contains("5 so far", ex.Message);
|
||||||
|
|
||||||
|
// 6 confirmations (tipHeight=105): exactly at the threshold → spendable.
|
||||||
|
var built = new TransactionFactory(mainnetAccount).Build(
|
||||||
|
utxos, txs, mainnetAccount.GetReceiveAddress(1), amountSats: 100_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 105);
|
||||||
|
Assert.True(built.Signed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -127,7 +182,7 @@ public class TransactionFactoryTests
|
|||||||
|
|
||||||
var built = new TransactionFactory(watchOnly).Build(
|
var built = new TransactionFactory(watchOnly).Build(
|
||||||
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
||||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
Assert.False(built.Signed);
|
Assert.False(built.Signed);
|
||||||
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
||||||
@@ -189,6 +244,104 @@ public class TransactionFactoryTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ScriptPubKeyType.Legacy)]
|
||||||
|
[InlineData(ScriptPubKeyType.SegwitP2SH)]
|
||||||
|
[InlineData(ScriptPubKeyType.Segwit)]
|
||||||
|
public void Si_puo_pagare_ogni_tipo_di_indirizzo_standard(ScriptPubKeyType kind)
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
|
var destination = new Key().PubKey.GetAddress(kind, Net);
|
||||||
|
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, destination, amountSats: 400_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
Assert.Contains(built.Transaction.Outputs,
|
||||||
|
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Piu_utxo_vengono_combinati_quando_uno_solo_non_basta()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var allUtxos = new List<CachedUtxo>();
|
||||||
|
var allTxs = new Dictionary<string, Transaction>();
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var funding = Net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)i)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(300_000), account.GetReceiveAddress(i));
|
||||||
|
var txid = funding.GetHash().ToString();
|
||||||
|
allTxs[txid] = funding;
|
||||||
|
allUtxos.Add(new CachedUtxo
|
||||||
|
{
|
||||||
|
Txid = txid, Vout = 0, ValueSats = 300_000,
|
||||||
|
Address = account.GetReceiveAddress(i).ToString(),
|
||||||
|
IsChange = false, AddressIndex = i, Height = 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
allUtxos, allTxs, account.GetReceiveAddress(5), amountSats: 700_000,
|
||||||
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
|
// 700k > any pair? No: it needs all three 300k coins (600k < 700k + fee).
|
||||||
|
Assert.Equal(3, built.Transaction.Inputs.Count);
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 100_000);
|
||||||
|
|
||||||
|
// Leaves ~200 sats after the fee: below the P2WPKH dust threshold,
|
||||||
|
// so no change output must be created and the remainder goes to the fee.
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(5), amountSats: 99_650,
|
||||||
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
|
var output = Assert.Single(built.Transaction.Outputs);
|
||||||
|
Assert.Equal(99_650, output.Value.Satoshi);
|
||||||
|
Assert.Equal(100_000 - 99_650, built.Fee.Satoshi);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void La_firma_di_una_tx_fissa_produce_il_txid_golden()
|
||||||
|
{
|
||||||
|
// Golden vector for the signing path (derivation → sighash → witness):
|
||||||
|
// the factory shuffles outputs for privacy, so the vector is anchored one
|
||||||
|
// level below, on a transaction with fixed structure signed through PSBT
|
||||||
|
// (the same flow used air-gapped, §6.5). RFC 6979 makes it deterministic:
|
||||||
|
// any change in this txid is a blocking regression.
|
||||||
|
var account = Account();
|
||||||
|
|
||||||
|
var funding = Net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var spend = Net.CreateTransaction();
|
||||||
|
spend.Version = 2;
|
||||||
|
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||||
|
spend.Outputs.Add(Money.Satoshis(600_000), account.GetReceiveAddress(5));
|
||||||
|
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||||
|
|
||||||
|
var psbt = PSBT.FromTransaction(spend, Net);
|
||||||
|
psbt.AddCoins(new Coin(funding, 0));
|
||||||
|
psbt.SignWithKeys(account.GetExtPrivateKey(isChange: false, 0));
|
||||||
|
psbt.Finalize();
|
||||||
|
var signed = psbt.ExtractTransaction();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"a943cf6bf606fa0050e490cb76ed9313959d228fb0ffa235b7e8b7f6834610b6",
|
||||||
|
signed.GetHash().ToString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Una_config_corrotta_torna_ai_default()
|
public void Una_config_corrotta_torna_ai_default()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Wallet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the transaction detail assembly (§10): fee from resolved inputs,
|
||||||
|
/// mine/theirs attribution, RBF flag, coinbase, and the degraded paths when the
|
||||||
|
/// server cannot provide a previous transaction.
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionInspectorTests
|
||||||
|
{
|
||||||
|
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||||
|
|
||||||
|
private static HdAccount Account()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
out var mnemonic));
|
||||||
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Funding (1M sats to receive/0) + spend (600k external, 390k change/0, 10k fee, RBF).</summary>
|
||||||
|
private static (Transaction Funding, Transaction Spend, BitcoinAddress External, HdAccount Account) SpendPair()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var funding = Net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
var spend = Net.CreateTransaction();
|
||||||
|
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||||
|
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||||
|
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||||
|
return (funding, spend, external, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(
|
||||||
|
params Transaction[] served)
|
||||||
|
{
|
||||||
|
var byId = served.ToDictionary(t => t.GetHash().ToString());
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.transaction.get", p =>
|
||||||
|
byId.TryGetValue(p[0].GetString()!, out var tx)
|
||||||
|
? tx.ToHex()
|
||||||
|
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||||
|
server.Handle("blockchain.block.header", p =>
|
||||||
|
{
|
||||||
|
var height = p[0].GetInt32();
|
||||||
|
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||||
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
|
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||||
|
});
|
||||||
|
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
return (server, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> Owned(HdAccount account) =>
|
||||||
|
[
|
||||||
|
account.GetReceiveAddress(0).ToString(),
|
||||||
|
account.GetChangeAddress(0).ToString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_spesa_confermata_riporta_fee_conferme_e_attribuzione_mine_theirs()
|
||||||
|
{
|
||||||
|
var (funding, spend, external, account) = SpendPair();
|
||||||
|
var (server, client) = await StartAsync(funding, spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: -610_000, verified: true);
|
||||||
|
|
||||||
|
Assert.Equal(10_000, details.FeeSats);
|
||||||
|
Assert.Equal(1_000_000, details.TotalInSats);
|
||||||
|
Assert.Equal(990_000, details.TotalOutSats);
|
||||||
|
Assert.Equal(100, details.Confirmations); // 200 − 101 + 1
|
||||||
|
Assert.True(details.RbfSignaled);
|
||||||
|
Assert.True(details.Verified);
|
||||||
|
Assert.False(details.IsCoinbase);
|
||||||
|
Assert.False(details.IsIncoming);
|
||||||
|
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_101), details.BlockTime);
|
||||||
|
Assert.Equal((double)10_000 / details.VirtualSize, details.FeeRateSatPerVb);
|
||||||
|
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.True(input.IsMine);
|
||||||
|
Assert.Equal(1_000_000, input.AmountSats);
|
||||||
|
Assert.Equal(account.GetReceiveAddress(0).ToString(), input.Address);
|
||||||
|
|
||||||
|
Assert.Equal(2, details.Outputs.Count);
|
||||||
|
Assert.Equal(600_000, details.SentToOthersSats);
|
||||||
|
Assert.Equal(390_000, details.ReceivedSats);
|
||||||
|
Assert.Contains(details.Outputs, o => o.IsMine && o.AmountSats == 390_000);
|
||||||
|
Assert.Contains(details.Outputs, o => !o.IsMine && o.AmountSats == 600_000);
|
||||||
|
|
||||||
|
// Outgoing tx: the counterparty is the external recipient.
|
||||||
|
Assert.Equal([external.ToString()], details.CounterpartyAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_ricezione_indica_il_mittente_come_controparte()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var senderKey = new Key();
|
||||||
|
var senderAddr = senderKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
|
||||||
|
// The sender's coin, then the payment to us spending it.
|
||||||
|
var senderFunding = Net.CreateTransaction();
|
||||||
|
senderFunding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||||
|
senderFunding.Outputs.Add(Money.Satoshis(2_000_000), senderAddr);
|
||||||
|
|
||||||
|
var payment = Net.CreateTransaction();
|
||||||
|
payment.Inputs.Add(new TxIn(new OutPoint(senderFunding, 0)));
|
||||||
|
payment.Outputs.Add(Money.Satoshis(1_500_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(senderFunding, payment);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, payment.GetHash().ToString(), tipHeight: 200, height: 150,
|
||||||
|
Owned(account), netSats: 1_500_000, verified: true);
|
||||||
|
|
||||||
|
Assert.True(details.IsIncoming);
|
||||||
|
Assert.Equal([senderAddr.ToString()], details.CounterpartyAddresses);
|
||||||
|
Assert.Equal(500_000, details.FeeSats); // 2M in − 1.5M out
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_coinbase_non_ha_fee_ne_importi_in_ingresso()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var coinbase = Net.CreateTransaction();
|
||||||
|
coinbase.Inputs.Add(new TxIn());
|
||||||
|
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(coinbase);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
|
||||||
|
Owned(account), netSats: 5_000_000_000, verified: true);
|
||||||
|
|
||||||
|
Assert.True(details.IsCoinbase);
|
||||||
|
Assert.Null(details.FeeSats);
|
||||||
|
Assert.Null(details.TotalInSats);
|
||||||
|
Assert.Null(details.FeeRateSatPerVb);
|
||||||
|
Assert.Equal(11, details.Confirmations);
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.True(input.IsCoinbase);
|
||||||
|
Assert.Null(input.AmountSats);
|
||||||
|
// No previous transactions to resolve.
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get") - 1); // only the coinbase itself
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Se_la_tx_precedente_non_e_recuperabile_la_fee_resta_ignota()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
// The server only knows the spend: the funding lookup fails.
|
||||||
|
var (server, client) = await StartAsync(spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: -610_000, verified: false);
|
||||||
|
|
||||||
|
Assert.Null(details.FeeSats);
|
||||||
|
Assert.Null(details.TotalInSats);
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.Null(input.AmountSats);
|
||||||
|
Assert.False(input.IsMine); // unresolvable → not attributable
|
||||||
|
Assert.Equal(funding.GetHash().ToString(), input.PrevTxid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_tx_in_mempool_non_ha_conferme_ne_timestamp()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
var (server, client) = await StartAsync(funding, spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||||
|
Owned(account), netSats: -610_000, verified: false);
|
||||||
|
|
||||||
|
Assert.Equal(0, details.Confirmations);
|
||||||
|
Assert.Null(details.BlockTime);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
var cache = new Dictionary<string, Transaction>
|
||||||
|
{
|
||||||
|
[funding.GetHash().ToString()] = funding,
|
||||||
|
[spend.GetHash().ToString()] = spend,
|
||||||
|
};
|
||||||
|
var (server, client) = await StartAsync();
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||||
|
Owned(account), netSats: -610_000, verified: false, cache);
|
||||||
|
|
||||||
|
Assert.Equal(10_000, details.FeeSats);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ public class WalletLoaderTests
|
|||||||
Assert.Equal("NativeSegwit", doc.ScriptKind);
|
Assert.Equal("NativeSegwit", doc.ScriptKind);
|
||||||
Assert.Equal(ValidMnemonic, doc.Mnemonic);
|
Assert.Equal(ValidMnemonic, doc.Mnemonic);
|
||||||
Assert.Null(doc.Passphrase);
|
Assert.Null(doc.Passphrase);
|
||||||
Assert.NotEmpty(doc.AccountXpub);
|
Assert.NotEmpty(doc.AccountXpub!);
|
||||||
Assert.NotEmpty(doc.MasterFingerprint);
|
Assert.NotEmpty(doc.MasterFingerprint!);
|
||||||
Assert.False(doc.IsWatchOnly);
|
Assert.False(doc.IsWatchOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ public class WalletLoaderTests
|
|||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
var (doc, _) = WalletLoader.NewFromMnemonic(
|
||||||
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
Assert.Equal("mainnet", doc.Network);
|
Assert.Equal("mainnet", doc.Network);
|
||||||
Assert.NotEmpty(doc.AccountXpub);
|
Assert.NotEmpty(doc.AccountXpub!);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bumps the app version everywhere it's tracked and stubs a CHANGELOG.md entry.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
|
APP_CSPROJ="src/App/PalladiumWallet.App.csproj"
|
||||||
|
ANDROID_CSPROJ="src/App.Android/PalladiumWallet.App.Android.csproj"
|
||||||
|
CHANGELOG="CHANGELOG.md"
|
||||||
|
|
||||||
|
current_version=$(grep -oP '(?<=<Version>)[^<]+(?=</Version>)' "$APP_CSPROJ")
|
||||||
|
echo "Current version: $current_version"
|
||||||
|
|
||||||
|
read -rp "New version (e.g. 1.0.0): " new_version
|
||||||
|
|
||||||
|
if [[ -z "$new_version" ]]; then
|
||||||
|
echo "No version entered, aborting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Invalid version format: '$new_version' (expected X.Y.Z)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$new_version" == "$current_version" ]]; then
|
||||||
|
echo "New version is the same as the current one ($current_version), aborting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Single source of truth: App csproj <Version>
|
||||||
|
sed -i "s|<Version>$current_version</Version>|<Version>$new_version</Version>|" "$APP_CSPROJ"
|
||||||
|
|
||||||
|
# 2. Android head: ApplicationDisplayVersion (versionName) must mirror it,
|
||||||
|
# and ApplicationVersion (versionCode) must strictly increase on every
|
||||||
|
# release or users can't update in place.
|
||||||
|
current_code=$(grep -oP '(?<=<ApplicationVersion>)[^<]+(?=</ApplicationVersion>)' "$ANDROID_CSPROJ")
|
||||||
|
new_code=$((current_code + 1))
|
||||||
|
|
||||||
|
sed -i "s|<ApplicationDisplayVersion>$current_version</ApplicationDisplayVersion>|<ApplicationDisplayVersion>$new_version</ApplicationDisplayVersion>|" "$ANDROID_CSPROJ"
|
||||||
|
sed -i "s|<ApplicationVersion>$current_code</ApplicationVersion>|<ApplicationVersion>$new_code</ApplicationVersion>|" "$ANDROID_CSPROJ"
|
||||||
|
|
||||||
|
# 3. CHANGELOG.md: stub a new entry above the previous top-most one.
|
||||||
|
today=$(date +%F)
|
||||||
|
if grep -q "^## \[$new_version\]" "$CHANGELOG"; then
|
||||||
|
echo "CHANGELOG.md already has an entry for $new_version, leaving it untouched."
|
||||||
|
else
|
||||||
|
awk -v ver="$new_version" -v date="$today" '
|
||||||
|
!done && /^## \[/ {
|
||||||
|
print "## [" ver "] — " date
|
||||||
|
print ""
|
||||||
|
print "TODO: describe the changes in this release."
|
||||||
|
print ""
|
||||||
|
done = 1
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$CHANGELOG" > "$CHANGELOG.tmp"
|
||||||
|
mv "$CHANGELOG.tmp" "$CHANGELOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Updated:"
|
||||||
|
echo " - $APP_CSPROJ: Version $current_version -> $new_version"
|
||||||
|
echo " - $ANDROID_CSPROJ: ApplicationDisplayVersion $current_version -> $new_version, ApplicationVersion (versionCode) $current_code -> $new_code"
|
||||||
|
echo " - $CHANGELOG: stubbed entry for $new_version (fill in the details manually)"
|
||||||
|
echo
|
||||||
|
echo "Review the diff, fill in the CHANGELOG entry, then commit and tag manually."
|
||||||
Reference in New Issue
Block a user