Compare commits
47 Commits
ef60ec0330
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c2f5e6cd | |||
| feb765663c | |||
| 322ce8f305 | |||
| 077835a30b | |||
| 663460d62e | |||
| be818a50a8 | |||
| 6f4ae679e5 | |||
| cdede17683 | |||
| a264669151 | |||
| d9c75eaec2 | |||
| c868c17505 | |||
| d8917fbd9a | |||
| 413392f608 | |||
| 970253cbbe | |||
| 31012ce825 | |||
| a46f5e6638 | |||
| 61ed0c0ed0 | |||
| 9097ca4abc | |||
| 2ed8c04064 | |||
| 56135c29f8 | |||
| 5d74038aad | |||
| db5b65ca0d | |||
| 45f7b1401e | |||
| e349a80ddc | |||
| 69be42aba0 | |||
| af2fdcc894 | |||
| 1f4421ae16 | |||
| b304996ff5 | |||
| c75e3921aa | |||
| 50d8e7f21a | |||
| 15978bf564 | |||
| 31aabd0856 | |||
| 56ce1d4259 | |||
| 448f7dc65d | |||
| e8ff99a768 | |||
| 27231c8eec | |||
| eb7ec9e835 | |||
| f0fb3fe05d | |||
| 3f26f52f8b | |||
| cb3ff714d9 | |||
| d0037747b5 | |||
| 34b4313a36 | |||
| 2d017231eb | |||
| e7797017f6 | |||
| 6c24a8bb46 | |||
| 9aecdb1aaa | |||
| 2be43d3177 |
@@ -28,17 +28,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: network
|
||||
attributes:
|
||||
label: Network
|
||||
options:
|
||||
- Mainnet
|
||||
- Testnet
|
||||
- Regtest
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/davide3011/PalladiumWallet/security/policy
|
||||
about: Please report security vulnerabilities privately, not as a public issue. See SECURITY.md.
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or improvement for Palladium Wallet
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an improvement.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What problem are you trying to solve? Is it related to something you find missing or frustrating?
|
||||
placeholder: "I'm always frustrated when …"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe what you'd like to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any alternative solutions or workarounds you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Affected platform(s)
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- CLI
|
||||
- All
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and this is not a duplicate
|
||||
required: true
|
||||
@@ -0,0 +1,26 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change and why? -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- Closes #... (if applicable) -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / cleanup
|
||||
- [ ] Documentation
|
||||
- [ ] Build / CI
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- How was this verified? e.g. `dotnet test`, manual run on Windows/Linux/Android, golden-vector comparison -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `dotnet test` passes
|
||||
- [ ] No seed/private key material logged or persisted in plaintext (if touching Crypto/Wallet/Storage)
|
||||
- [ ] Server responses still validated with Merkle + checkpoints (if touching Spv/Net)
|
||||
- [ ] Code/comments/commit messages are in English
|
||||
@@ -74,6 +74,8 @@ temp/
|
||||
*.key
|
||||
*.pfx
|
||||
*.p12
|
||||
*.keystore
|
||||
*.jks
|
||||
secrets/
|
||||
|
||||
# Wallet data and local runtime state
|
||||
@@ -91,3 +93,7 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
|
||||
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
|
||||
findings/
|
||||
crash-*.bin
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents when working with code in this repository.
|
||||
This file provides guidance to coding agents (OpenAI Codex and others following the AGENTS.md convention) when working with code in this repository.
|
||||
|
||||
> **Sync rule:** `CLAUDE.md` (read by Claude Code) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with 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.
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **All code, comments, commit messages, and documentation files**: English only.
|
||||
- **Code, comments, commit messages, 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.
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
||||
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
|
||||
|
||||
@@ -26,59 +30,75 @@ SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin
|
||||
```
|
||||
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/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.
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- Build: `dotnet build`
|
||||
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args -> usage)
|
||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
||||
- **Build:** `dotnet build`
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
|
||||
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||
(`JAVA_HOME`), and the Android SDK. 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.
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
## Architecture (points that require reading multiple files)
|
||||
|
||||
- **Layers:** GUI -> wallet domain -> SPV/Sync -> Network -> Cryptography -> Persistence; each layer depends only downward.
|
||||
- **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.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), 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`).
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- **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.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
# Changelog
|
||||
|
||||
Technical changelog for PalladiumWallet. Format loosely follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); entries are grouped
|
||||
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||
release and covers the full history from the initial commit.
|
||||
|
||||
## [1.0.0] — 2026-07-09
|
||||
|
||||
First stable release. Closes the last open security gap from 0.9.x (header
|
||||
trust was not actually anchored to any checkpoint), fixes several crash
|
||||
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
|
||||
decoding to the transaction detail view.
|
||||
|
||||
### Security
|
||||
|
||||
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
|
||||
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
|
||||
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
|
||||
plus one near tip). Previously the checkpoint array was empty and the
|
||||
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
|
||||
never called — on this LWMA chain, where PoW can't be recomputed
|
||||
client-side, a malicious or eclipsing server could hand back any
|
||||
internally-consistent header for a Merkle proof with nothing tying it to
|
||||
the real chain. For every header used in a Merkle proof, the intervening
|
||||
headers are now downloaded back to the nearest checkpoint and verified as
|
||||
an unbroken prev-hash chain terminating in that checkpoint's exact hash
|
||||
(memoized per sync session). Testnet/Regtest remain unanchored (no node
|
||||
available to source checkpoints from); a missing checkpoint is a no-op,
|
||||
not a failure.
|
||||
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
|
||||
target per untrusted-input parser (header, Merkle proof, peer list,
|
||||
wallet file, mnemonic/key/address/amount), each encoding that parser's
|
||||
documented error contract. Found and fixed:
|
||||
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
|
||||
wordlist instead of failing gracefully.
|
||||
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
|
||||
shape other than the expected `[ip, hostname, [features...]]`, and on a
|
||||
JSON string containing invalid UTF-8.
|
||||
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
|
||||
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
|
||||
the documented `WrongPasswordException`/`InvalidDataException`
|
||||
contract; worse, the PBKDF2 iteration count was read from the
|
||||
(attacker-controlled) container with no upper bound — a tampered file
|
||||
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
|
||||
count is now clamped to 10,000,000.
|
||||
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
|
||||
connection until manually deleted; now falls back to first-contact
|
||||
TOFU like `ServerRegistry` already did.
|
||||
- The seed corpus (incl. a regression input per fixed finding) replays
|
||||
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
|
||||
regress.
|
||||
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
|
||||
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
|
||||
and disclosure of the AI-assisted testing methodology used (adversarial
|
||||
fake-server simulation, property-based fuzzing, targeted security
|
||||
review) as a complement to, not a replacement for, independent review.
|
||||
|
||||
### Added
|
||||
|
||||
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
|
||||
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
|
||||
are each decoded independently) and coinbase scriptSig pool tags (e.g.
|
||||
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
|
||||
height/extranonce). Both were previously dropped entirely — discarded
|
||||
once no destination address could be derived from the script.
|
||||
- Help overlay: "User guide" button next to "Report a bug", linking to
|
||||
`USERGUIDE.md` on GitHub.
|
||||
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
|
||||
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
|
||||
exact numbers the software enforces.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
|
||||
in Italian regardless of the active language; routed through `Loc` with
|
||||
the missing keys added. `CertificatePinMismatchException` no longer
|
||||
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
|
||||
the UI to translate.
|
||||
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
|
||||
the code/docs-are-English-only policy; translated every user-facing
|
||||
string and comment.
|
||||
|
||||
### Testing
|
||||
|
||||
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
|
||||
checkpoint-anchoring (including the memoization and non-generic-retry
|
||||
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
|
||||
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
|
||||
wordlist languages plus the empty-input guard, SLIP-132 rejection of
|
||||
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
|
||||
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
|
||||
`WalletLoader`'s defensive branches for corrupted files,
|
||||
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
|
||||
precedence chain (via new internal override seams), `UpdateChecker`
|
||||
end-to-end via a stub-transport seam, and `ElectrumClient`'s
|
||||
multi-segment response dispatch.
|
||||
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
|
||||
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
|
||||
absence of false positives on standard outputs/inputs.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
|
||||
dense prose to scannable bullet lists; a stale `SECURITY.md` file
|
||||
reference fixed.
|
||||
- `README.md` test-coverage section and `SECURITY.md` updated to describe
|
||||
the checkpoint anchoring and fuzzing guarantees actually enforced now.
|
||||
|
||||
## [0.9.1] — 2026-07-02
|
||||
|
||||
### Testing
|
||||
|
||||
- Test suite expanded from 239 to 307 tests; `Core` line coverage raised
|
||||
from ~50% to ~92% (branch coverage from ~41% to ~79%).
|
||||
- In-process fake ElectrumX server (`tests/.../Net/FakeElectrumServer.cs`):
|
||||
a real loopback TCP socket speaking newline-delimited JSON-RPC 2.0,
|
||||
optionally TLS with a self-signed certificate, with per-method handlers
|
||||
and call counters — exercises the network stack against real socket code
|
||||
instead of mocks.
|
||||
- New end-to-end coverage for previously untested network/SPV code:
|
||||
`ElectrumClient` (request pipelining, error mapping, notifications,
|
||||
disconnection, cancellation, TLS/TOFU handshake), `WalletSynchronizer`
|
||||
(gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature
|
||||
balances, busy-retry, disk-cache reuse, and the security-critical path
|
||||
where a lying server fails Merkle verification and the sync aborts),
|
||||
`TransactionInspector` (fee calculation, mine/theirs attribution, RBF,
|
||||
coinbase handling), `CertificatePinStore` (TOFU pin/match/mismatch/reset),
|
||||
`ServerRegistry` peer discovery, and `UpdateChecker` tag parsing.
|
||||
- `TransactionFactory`: added coverage for legacy/P2SH/segwit destinations,
|
||||
multi-UTXO selection, dust change absorbed into the fee, and a golden
|
||||
txid anchoring the PSBT signing path (deterministic via RFC 6979).
|
||||
- Property-based tests extended: SLIP-132 roundtrip for every script kind
|
||||
and network, `WalletDocument` JSON roundtrip with arbitrary
|
||||
labels/contacts, `Scripthash` cross-checked against an independent
|
||||
SHA-256 computation.
|
||||
- `update-version.sh`: single script to bump the version across the project
|
||||
ahead of a release tag.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `CertificatePinStore.Load`: a corrupted pin file threw and blocked every
|
||||
SSL connection until the user manually deleted it; now falls back to
|
||||
first-contact TOFU, same as `ServerRegistry` already did for its own file.
|
||||
- `EncryptedFile.IsEncrypted`: threw on valid JSON with a non-object root
|
||||
(e.g. a bare number or array) or on invalid UTF-16 input, instead of
|
||||
returning `false`. Both bugs were found by the expanded property tests.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md`: "Running tests" section rewritten with a per-area coverage
|
||||
table, the coverage-measurement command, and a description of the fake
|
||||
ElectrumX server.
|
||||
- `CLAUDE.md`/`AGENTS.md` kept in sync, pointing future work at extending
|
||||
the fake ElectrumX server instead of mocking `ElectrumClient` (not an
|
||||
interface, by design).
|
||||
|
||||
## [0.9.0] — 2026-07-02
|
||||
|
||||
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
|
||||
Bitcoin-derived UTXO chain — targeting desktop (Windows/Linux) and Android
|
||||
from a single Avalonia UI codebase on .NET 10 + NBitcoin.
|
||||
|
||||
### Core — Chain
|
||||
|
||||
- Network profiles and consensus constants centralized in `Core/Chain`
|
||||
(`ChainProfiles`/`PalladiumNetworks`): address prefixes, BIP32 headers,
|
||||
bech32 HRP, genesis, ports, `coin_type` 746 — selectable per network
|
||||
(mainnet/testnet/regtest), no scattered magic numbers.
|
||||
- LWMA difficulty / 2-minute blocks: SPV cannot recompute LWMA retargeting,
|
||||
so PoW validation is skipped and trust is anchored to hardcoded
|
||||
checkpoints instead.
|
||||
|
||||
### Core — Crypto
|
||||
|
||||
- HD key derivation: BIP32/39/84, HD accounts, SLIP-132 extended key
|
||||
serialization.
|
||||
- Address types: P2PKH, P2SH-P2WPKH, and P2TR (Taproot/BIP86).
|
||||
- `IWalletAccount` abstraction with WIF/xpub/xprv keystore import (including
|
||||
watch-only accounts from public material only).
|
||||
|
||||
### Core — Net
|
||||
|
||||
- Custom `ElectrumClient`: JSON-RPC 2.0 client for the ElectrumX-like
|
||||
indexing server, with TLS support and TOFU certificate pinning.
|
||||
- `ServerRegistry`: bootstrap server list, peer discovery, persisted last-used
|
||||
server, fallback resolution for `--server`.
|
||||
- Batched writes, zero-allocation reads, bounded in-flight requests for
|
||||
network throughput.
|
||||
- Fix: allow changing the indexing server at any time, including during an
|
||||
active sync.
|
||||
|
||||
### Core — Spv
|
||||
|
||||
- Header sync with Merkle proof verification and scripthash subscriptions
|
||||
against hardcoded checkpoints (no full PoW recomputation, per the chain
|
||||
profile above).
|
||||
- Per-address balance and transaction-count aggregation in sync results.
|
||||
- Electrum-style continuous updates: incremental, parallelized sync across
|
||||
address chains, with a persistent header cache.
|
||||
- Fix: resilient sync — history discovery via `GetHistory`, transaction
|
||||
caching, automatic reconnect fallback.
|
||||
|
||||
### Core — Storage
|
||||
|
||||
- Versioned, encrypted JSON wallet file (`WalletDocument`) with dedicated
|
||||
persistence and loader layer.
|
||||
- `WalletLock`: prevents concurrent access to the same wallet file; acquired
|
||||
before load and before close (fixed a race where it wasn't).
|
||||
- XDG-compliant data paths (`AppPaths`) with per-platform override seam used
|
||||
by the Android head; English as default UI language.
|
||||
- Contacts list (name + address) persisted in `WalletDocument`.
|
||||
- Documented caveat: `WalletStore.Save` writes plaintext JSON when the
|
||||
wallet is unencrypted — seed/keys are only ever encrypted-at-rest when the
|
||||
user opts into a password.
|
||||
|
||||
### Core — Wallet
|
||||
|
||||
- Coin selection, PSBT-centric transaction factory, and wallet loader.
|
||||
- Confirmation-threshold enforcement before spending UTXOs; immature
|
||||
(coinbase) balance surfaced separately from confirmed balance; pending
|
||||
mempool balance shown, with unconfirmed UTXOs excluded from spending by
|
||||
default.
|
||||
- Fix: reject amounts with sub-satoshi precision.
|
||||
|
||||
### App — Avalonia UI (shared, `src/App`)
|
||||
|
||||
- Single shared `MainView` (UserControl) hosted by `MainWindow` on desktop
|
||||
and as the single-view root on Android; single `MainWindowViewModel`
|
||||
(CommunityToolkit.Mvvm), later split into partial files by area
|
||||
(Wizard/Settings/Sync/Send/Contacts/Receive/...).
|
||||
- Step-by-step setup wizard (replacing a single-form panel), including a
|
||||
first-run data-location step, multi-wallet chooser, confirm-password and
|
||||
encrypt toggle, and dedicated flows for creating a wallet vs. importing
|
||||
from xpub/xprv/WIF.
|
||||
- In-app overlay pattern for details/settings/help (`IsXxxOpen` flags, no OS
|
||||
windows) replacing earlier nested submenus and a separate
|
||||
`AddressInfoWindow`: server settings, app settings (language/unit),
|
||||
wallet info (xpub, password-gated seed reveal), address detail
|
||||
(password-gated private key reveal), transaction detail with full
|
||||
on-chain data (inputs/outputs, no truncation), and a Help overlay
|
||||
(Info/Donate tabs).
|
||||
- Connection-status indicator in the bottom bar; connect-before-wallet-open
|
||||
flow; persisted last-used server; server discovery split into its own
|
||||
always-usable button; mainnet hardcoded (network selector removed for the
|
||||
first release).
|
||||
- Localization: `Loc` key → 6-language dictionary (it/en/es/fr/pt/de) with
|
||||
live language switching.
|
||||
- Receive: QR code generation and copy-to-clipboard for the receive address.
|
||||
- Android: QR code scanner for the Send address field.
|
||||
- Centralized design system (color tokens, gradient hero, SVG tab icons);
|
||||
responsive layout for portrait mobile, unified tab bar (desktop +
|
||||
mobile), two-column desktop layout for Send/Receive; various mobile-only
|
||||
fixes (tab bar sizing/indicator overlap, text overflow, full-screen server
|
||||
overlay on mobile).
|
||||
- App version shown in window title and Help overlay, read from the single
|
||||
`<Version>` source in the App csproj.
|
||||
- In-app update check: compares the running version against the latest
|
||||
GitHub release tag on startup and shows an overlay with the new tag when
|
||||
one is available (best-effort — silent on network/parse failure).
|
||||
- In-app bug-report button (Help overlay) opening a pre-filled GitHub issue
|
||||
template; issue/PR templates completed.
|
||||
|
||||
### Android head (`src/App.Android`)
|
||||
|
||||
- Architecture split: shared UI library (`src/App`) + `src/App.Desktop` +
|
||||
`src/App.Android` heads from the same source (`refactor(arch)`), each
|
||||
carrying only the per-platform entry point and packages.
|
||||
- App logo as launcher icon.
|
||||
- Persistent release-signing keystore workflow
|
||||
(`docker/keystore/generate-keystore.sh`, git-ignored output): every
|
||||
release APK is signed with the same key so installing a newer build
|
||||
updates a previous install in place instead of requiring an uninstall.
|
||||
`versionCode` derived from `<Version>` instead of a fixed constant.
|
||||
|
||||
### CLI (`src/Cli`)
|
||||
|
||||
- Commands: `create`/`restore`/`restore-xpub`/`info`,
|
||||
`sync`/`send`/`servers`/`reset-certs`, `newseed`/`addresses`.
|
||||
- `servers` command, `info --addresses`, and registry-based fallback
|
||||
resolution for `--server`.
|
||||
|
||||
### Build & Distribution
|
||||
|
||||
- Docker-based reproducible build system (`docker/build.sh` +
|
||||
`docker/Dockerfile.*`): pinned toolchain (.NET 10 SDK, JDK, Android SDK),
|
||||
builds Windows/Linux single-file executables and a signed Android APK
|
||||
without any SDK installed on the host.
|
||||
- Android release signing wired into `build_android` (see Android head
|
||||
above): requires the persistent keystore, prompts for its passwords at
|
||||
build time, mounts it read-only into the build container.
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit test coverage across all Core modules, later expanded to 209 tests.
|
||||
- Property-based tests via CsCheck (`PropertyTests.cs`), bringing total
|
||||
coverage to 218 tests; dedicated `WalletLock` concurrency tests.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md` (project overview, quickstart, reproducible builds), `CLAUDE.md`
|
||||
(codebase guidance for AI tooling, kept in sync with the multi-head
|
||||
architecture and .NET 10 migration), `SECURITY.md` (threat model and SPV
|
||||
trust assumptions), coding-agent guide.
|
||||
- Code comments translated to English project-wide, per the language policy
|
||||
(Italian conversation, English code/docs).
|
||||
@@ -2,22 +2,26 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Sync rule:** `AGENTS.md` (read by other coding agents) carries the same content as this file, differing only in this header block. Any edit here must be mirrored there in the same commit.
|
||||
|
||||
## Role
|
||||
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with 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.
|
||||
Operate as an **expert in cryptocurrencies and cryptography**: reason with domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||
|
||||
## Language policy
|
||||
|
||||
- **Conversation with the user**: Italian.
|
||||
- **All code, comments, commit messages, and documentation files**: English only.
|
||||
- **Code, comments, commit messages, 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.
|
||||
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||
|
||||
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||
|
||||
## What it is
|
||||
|
||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
||||
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
|
||||
|
||||
@@ -32,33 +36,40 @@ 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.
|
||||
- The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the per-platform entry point and packages.
|
||||
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||
|
||||
## Commands
|
||||
|
||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
||||
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||
|
||||
- Build: `dotnet build`
|
||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **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`).
|
||||
- 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)
|
||||
- **Build:** `dotnet build`
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
|
||||
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||
|
||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||
(`JAVA_HOME`), and the Android SDK. 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.
|
||||
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||
|
||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||
|
||||
@@ -66,21 +77,28 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
||||
|
||||
- **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.
|
||||
- **LWMA / skip PoW:** LWMA difficulty, 2-minute blocks; an SPV client cannot recompute it → `SkipPowValidation = true`, trust anchored to **hardcoded checkpoints**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
||||
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||
|
||||
## GUI conventions (`src/App`)
|
||||
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), 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`).
|
||||
- **Shared `MainView` + heads:** the whole UI is a single `MainView` (UserControl), working both as a desktop window's content and as Android's single-view root.
|
||||
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||
- **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.
|
||||
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **App version:** single source = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
## Working rules
|
||||
|
||||
- **Cross-implementation tests:** compare addresses, txids, and PSBTs against a reference wallet (golden vectors). A different address or txid is a blocking bug.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only.
|
||||
- **Security:** seed and private keys never in plaintext on disk/logs/network; every server response validated with Merkle + checkpoints; watch-only truly read-only. `SECURITY.md` is the published threat model (SPV trust boundaries, encryption parameters, key handling) — any change to crypto, SPV validation, or key/seed handling must keep it accurate in the same commit.
|
||||
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||
|
||||
+134
-64
@@ -1,64 +1,134 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{84E60614-5042-48EC-B349-290FB0CA7BA8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Core", "src\Core\PalladiumWallet.Core.csproj", "{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App", "src\App\PalladiumWallet.App.csproj", "{13EE9780-5810-4229-BFCF-6003172534DD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Cli", "src\Cli\PalladiumWallet.Cli.csproj", "{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FDF1822C-58D6-4B35-93EA-6A85E1292933}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Tests", "tests\PalladiumWallet.Tests\PalladiumWallet.Tests.csproj", "{C7E79E8E-B1DE-4053-9FB4-853814766CE0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop", "src\App.Desktop\PalladiumWallet.App.Desktop.csproj", "{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{13EE9780-5810-4229-BFCF-6003172534DD} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -139,9 +139,46 @@ Run only the tests in one project:
|
||||
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.
|
||||
|
||||
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 and the `INetworkSet` plumbing |
|
||||
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation, all 8 supported wordlist languages), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode including corrupted-payload rejection, HD and imported-key accounts (fund-safety fallbacks for out-of-range indices), watch-only isolation |
|
||||
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing and PoW target validation, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the paths where a lying server fails Merkle verification or serves a header chain that does not anchor to a hardcoded checkpoint, and the sync must abort |
|
||||
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation, oversized responses), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), the update check end-to-end via a stubbed HTTP transport (newer/equal/older tags, HTTP errors, malformed JSON, offline — all best-effort to null) |
|
||||
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data-path resolution with its full precedence chain (override → portable → pointer file → default) |
|
||||
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path, standardness-policy rejection of absurd fees), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting, corrupted-wallet-file rejection |
|
||||
|
||||
Network-facing code is tested against an **in-process fake ElectrumX server**
|
||||
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
|
||||
socket speaking newline-delimited JSON-RPC (optionally TLS with a self-signed
|
||||
certificate), with per-method handlers and call counters. Client, synchroniser
|
||||
and inspector therefore exercise the same code paths used in production —
|
||||
framing, retries, TLS pinning included — without any external dependency.
|
||||
|
||||
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||
|
||||
### Fuzzing
|
||||
|
||||
`tests/PalladiumWallet.Fuzz` fuzzes every parser that consumes untrusted input
|
||||
(server-supplied headers/proofs/peer lists, wallet files, user-pasted
|
||||
keys/mnemonics/addresses/amounts) via [SharpFuzz](https://github.com/Metalnem/sharpfuzz):
|
||||
each target enforces the parser's documented error contract, so any other
|
||||
exception escaping is a finding. The seed corpus — including a regression input
|
||||
for every crash found so far — replays automatically inside `dotnet test`;
|
||||
coverage-guided campaigns run separately with afl++ (`tests/PalladiumWallet.Fuzz/fuzz.sh`),
|
||||
and a built-in random-mutation mode (`dotnet run -- <target> --random N`) needs
|
||||
no external tooling. See `tests/PalladiumWallet.Fuzz/README.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -218,8 +255,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
|
||||
`AndroidSupportedAbis` property is deprecated and ignored.)
|
||||
|
||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) Release signing with your own
|
||||
keystore is not set up yet; the debug apk is fine for personal sideloading.
|
||||
(Set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag.) This debug apk is fine for personal
|
||||
sideloading, but debug builds are signed with a key regenerated per build machine/container, so a
|
||||
newer debug apk won't install over an older one without an uninstall first. For a stable signature
|
||||
across releases (needed to update an existing install in place), build via
|
||||
`./docker/build.sh android` instead — see [docker/keystore/README.md](docker/keystore/README.md).
|
||||
|
||||
> **Verification status.** The default multi-ABI apk is verified running on the x86_64 emulator
|
||||
> (UI renders, connects to a server over TLS). The arm64-only apk builds correctly (41 MB,
|
||||
@@ -332,6 +372,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
|
||||
|
||||
Released under the MIT License. See the [LICENSE](LICENSE) file.
|
||||
|
||||
+30
-3
@@ -21,8 +21,8 @@ It does **not** protect against:
|
||||
|
||||
This wallet is an SPV client, not a full node. It validates:
|
||||
|
||||
- Block headers (proof of work checked up to the last checkpoint; `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side — trust is anchored to hardcoded checkpoints in `Core/Chain/ChainProfiles.cs`)
|
||||
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleVerifier.cs`)
|
||||
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) back to the nearest hardcoded checkpoint at or below its height, and that checkpoint's hash must match exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`). Currently populated for mainnet only (every 20,000 blocks); testnet/regtest have no checkpoints yet, so headers there are trusted at face value
|
||||
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
|
||||
|
||||
It does **not** validate:
|
||||
|
||||
@@ -41,6 +41,20 @@ It cannot (given correct Merkle verification):
|
||||
- Fabricate a confirmed transaction with a valid Merkle proof
|
||||
- Forge a payment to a wrong address
|
||||
|
||||
**Progressive verification (mobile-friendly sync).** On a wallet with many historical
|
||||
transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof:
|
||||
balance and history are shown as soon as transaction downloads finish (`PartialResult`,
|
||||
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
|
||||
each transaction's `Verified` flag catches up progressively. This means the UI can display a
|
||||
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
|
||||
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
|
||||
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
|
||||
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
|
||||
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
|
||||
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
|
||||
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
|
||||
a sync, never a partial one, so no unverified data survives a restart.
|
||||
|
||||
---
|
||||
|
||||
## Key and seed management
|
||||
@@ -55,8 +69,9 @@ It cannot (given correct Merkle verification):
|
||||
## Encryption at rest
|
||||
|
||||
- Algorithm: AES-256-GCM
|
||||
- Key derivation: PBKDF2-HMAC-SHA512, 100 000 iterations, 32-byte random salt
|
||||
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible — bounded at 10 000 000 on read, since the count is attacker-controlled in a tampered file and an absurd value would hang the wallet at open)
|
||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
||||
- A malformed container (broken JSON, missing fields, bad base64, wrong nonce/tag size) always fails with a typed `InvalidDataException`, never a raw parser exception — the decrypt path is fuzz-tested (`tests/PalladiumWallet.Fuzz`)
|
||||
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
||||
|
||||
---
|
||||
@@ -73,6 +88,18 @@ The wallet file is the only thing that needs to be backed up. For encrypted wall
|
||||
|
||||
---
|
||||
|
||||
## AI-assisted testing and vulnerability discovery
|
||||
|
||||
Part of the test suite and security review for this project is produced with **Claude Fable 5** (Anthropic), used as a targeted tool rather than a blanket "AI-reviewed" stamp. Concretely:
|
||||
|
||||
- **Adversarial network simulation**: the SPV/network layer (`ElectrumClient`, `WalletSynchronizer`, `CertificatePinStore`) is tested against an in-process fake ElectrumX server that can be programmed to lie — serve a transaction with a Merkle proof that doesn't match its claimed block header, drop connections mid-request, return malformed or throttling responses. These are exactly the behaviors a malicious or compromised indexing server would exhibit; the suite asserts the wallet detects and rejects them (see `SpvVerificationException`) instead of trusting unverified server data.
|
||||
- **Property-based fuzzing** (CsCheck): parsers and cryptographic roundtrips (amount parsing, SLIP-132 key encoding, Merkle proof verification, AES-GCM encrypt/decrypt) are exercised against hundreds of generated inputs per run, checking invariants — no crash on arbitrary input, correct rejection of a wrong password, no false-negative Merkle verification — rather than a handful of hand-picked cases. This methodology has already found and fixed two real defects: a corrupted TLS pin file that permanently blocked reconnection, and a wallet-detection routine that threw on unexpected but valid JSON instead of failing safe.
|
||||
- **Targeted security-focused code review** over the areas where a bug has direct financial impact: key derivation, transaction signing, coin selection/spendability rules, and encryption at rest — cross-checked against the invariants stated in this document (e.g. "the server cannot fabricate a confirmed transaction with a valid Merkle proof").
|
||||
|
||||
This is a complement to, not a substitute for, independent human or third-party security review — which is still recommended before relying on this wallet for significant mainnet funds, particularly ahead of a 1.0 release.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations and out-of-scope for v1
|
||||
|
||||
- No Tor/proxy support (network traffic reveals which addresses are being queried)
|
||||
|
||||
+858
@@ -0,0 +1,858 @@
|
||||
# Palladium Wallet — User Guide
|
||||
|
||||
This guide covers **every user-facing feature** of Palladium Wallet (GUI and CLI), including
|
||||
default values, validation rules, limits, and edge cases. It is written so that no behavior
|
||||
has to be guessed: when the wallet enforces a rule, the rule is stated here with its exact
|
||||
numbers.
|
||||
|
||||
Applies to version **0.9.x**. Where the GUI and the CLI differ, both are described.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [What Palladium Wallet is (and is not)](#1-what-palladium-wallet-is-and-is-not)
|
||||
2. [Key concepts you must understand before holding funds](#2-key-concepts)
|
||||
3. [Installation and platform differences](#3-installation-and-platform-differences)
|
||||
4. [First launch: the setup wizard](#4-first-launch-the-setup-wizard)
|
||||
5. [Opening, closing and managing multiple wallets](#5-opening-closing-and-managing-multiple-wallets)
|
||||
6. [The main screen](#6-the-main-screen)
|
||||
7. [Receiving funds](#7-receiving-funds)
|
||||
8. [Sending funds](#8-sending-funds)
|
||||
9. [Transaction history and details](#9-transaction-history-and-details)
|
||||
10. [The Addresses tab](#10-the-addresses-tab)
|
||||
11. [Contacts](#11-contacts)
|
||||
12. [Connection, servers and certificate pinning](#12-connection-servers-and-certificate-pinning)
|
||||
13. [Settings](#13-settings)
|
||||
14. [Wallet Information (xpub, seed, fingerprint)](#14-wallet-information)
|
||||
15. [Backup and recovery](#15-backup-and-recovery)
|
||||
16. [Security model and known limitations](#16-security-model-and-known-limitations)
|
||||
17. [Command-line interface (CLI)](#17-command-line-interface-cli)
|
||||
18. [Troubleshooting and error messages](#18-troubleshooting-and-error-messages)
|
||||
19. [Glossary](#19-glossary)
|
||||
|
||||
---
|
||||
|
||||
## 1. What Palladium Wallet is (and is not)
|
||||
|
||||
Palladium Wallet is a **self-custody SPV (Simplified Payment Verification) wallet** for the
|
||||
Palladium (PLM) cryptocurrency, a Bitcoin-derived UTXO chain with 2-minute blocks. It runs on
|
||||
Windows, Linux and Android from the same codebase, plus a separate command-line interface.
|
||||
|
||||
**Self-custody** means: *you* hold the keys. There is no account, no server-side backup, no
|
||||
password recovery service. If you lose your seed phrase (and your wallet file plus its
|
||||
password), your funds are permanently unrecoverable. Nobody — including the developer — can
|
||||
restore them.
|
||||
|
||||
**SPV** means the wallet does **not** download or validate the full blockchain. It connects to
|
||||
an **indexing server** (ElectrumX-compatible, ports 50001 TCP / 50002 SSL) and downloads only
|
||||
the data relevant to your addresses. Every *confirmed* transaction the server reports is
|
||||
independently verified with a **Merkle proof** against the block header; a server that lies
|
||||
about confirmed transactions is detected and the synchronization aborts. See
|
||||
[section 16](#16-security-model-and-known-limitations) for exactly what the server can and
|
||||
cannot do to you.
|
||||
|
||||
What this wallet intentionally does **not** include (as of this version):
|
||||
|
||||
- Lightning Network
|
||||
- Hardware-wallet integration
|
||||
- Coin control (manual UTXO selection) — coin selection is always automatic
|
||||
- RBF fee bumping (transactions *signal* RBF, but there is no "bump fee" button)
|
||||
- Tor/proxy support — the indexing server can observe which addresses you query
|
||||
- Fiat currency conversion
|
||||
- Network selection in the GUI — the **GUI operates on mainnet only**; testnet and regtest
|
||||
are available through the CLI (`--net`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Key concepts
|
||||
|
||||
Read this section once, carefully. Every rule below is enforced by the software exactly as
|
||||
written.
|
||||
|
||||
### 2.1 The seed phrase (BIP39 mnemonic)
|
||||
|
||||
- When you create a new wallet in the GUI, it generates a **12-word English** BIP39 mnemonic.
|
||||
It is shown **once**, during the wizard, and you must write it down **on paper, in order**.
|
||||
- Whoever knows these words controls the funds — from any device, forever, with no further
|
||||
information needed (unless you also set a passphrase, see below).
|
||||
- When *restoring*, the wallet accepts any valid BIP39 mnemonic (12 or 24 words in the GUI
|
||||
prompt; the parser also accepts 15/18/21-word mnemonics and auto-detects the wordlist
|
||||
language). The checksum is validated: a mistyped word is rejected, not silently accepted.
|
||||
|
||||
### 2.2 The optional passphrase ("25th word")
|
||||
|
||||
During creation or restore you may set a **BIP39 passphrase**. Understand precisely what it
|
||||
does:
|
||||
|
||||
- The passphrase is combined with the seed words to derive the keys. Seed + passphrase
|
||||
produce a **completely different wallet** than the same seed without a passphrase (or with
|
||||
any other passphrase, including one differing by a single character or by case).
|
||||
- There is **no validity check possible**: entering the "wrong" passphrase during a restore
|
||||
does not produce an error — it silently opens a different, empty wallet. If you restore and
|
||||
see a zero balance where you expected funds, the first thing to check is the passphrase
|
||||
(then the script type, see 2.3).
|
||||
- Store the passphrase **separately** from the seed words. Losing the passphrase makes the
|
||||
funds unrecoverable even if you still have the 12 words.
|
||||
|
||||
### 2.3 Script type (address format)
|
||||
|
||||
Each wallet is created with exactly one **script type**, which determines the address format
|
||||
and the derivation path. It cannot be changed later; to switch, create a new wallet and move
|
||||
the funds.
|
||||
|
||||
| Script type | Standard | Derivation | Mainnet addresses look like |
|
||||
|---|---|---|---|
|
||||
| Legacy | BIP44 | `m/44'/746'/0'` | start with `P` |
|
||||
| Wrapped SegWit | BIP49 | `m/49'/746'/0'` | start with `3` |
|
||||
| **Native SegWit** (default, recommended) | BIP84 | `m/84'/746'/0'` | start with `plm1q` |
|
||||
| Taproot | BIP86 | `m/86'/746'/0'` | start with `plm1p` |
|
||||
|
||||
**Restoring with the wrong script type is the same trap as the wrong passphrase**: the seed
|
||||
is accepted, but different addresses are derived and the balance shows zero. When restoring,
|
||||
select the same script type the wallet was created with. If unsure, try Native SegWit first
|
||||
(the default), then the others.
|
||||
|
||||
The coin type in the derivation path is **746** on mainnet (1 on testnet/regtest).
|
||||
|
||||
### 2.4 Wallet types
|
||||
|
||||
| Type | Created via | Can sign/spend | Notes |
|
||||
|---|---|---|---|
|
||||
| HD (BIP39 seed) | Create new / Restore from seed | Yes | The normal case |
|
||||
| HD (imported xprv) | Import extended key (xprv/yprv/zprv) | Yes | Full account key, no seed words |
|
||||
| Watch-only (xpub) | Import extended key (xpub/ypub/zpub) | **No** | Sees balances/history; cannot sign. In the GUI it can *prepare* a transaction preview but not sign or broadcast it. The CLI produces an unsigned PSBT for offline signing |
|
||||
| Imported WIF keys | Import WIF key(s) | Yes | Fixed address list, **no HD derivation and no change chain — change always returns to the first imported address** (this links your coins together; prefer an HD wallet for privacy) |
|
||||
|
||||
The extended-key format is **SLIP-132**: `xpub`/`xprv` (Legacy and Taproot), `ypub`/`yprv`
|
||||
(Wrapped SegWit), `zpub`/`zprv` (Native SegWit). The script type is auto-detected from the
|
||||
prefix when importing.
|
||||
|
||||
### 2.5 The wallet file and its encryption
|
||||
|
||||
Everything the wallet knows is stored in **one file per wallet**:
|
||||
`<data folder>/mainnet/wallets/<name>.wallet.json` (default name `default.wallet.json`).
|
||||
It contains the seed (or xpub/xprv/WIF keys), the derivation settings, your **contacts**, and
|
||||
a sync cache (balances, history, verified proofs).
|
||||
|
||||
- If you set a password, the file is encrypted with **AES-256-GCM**; the key is derived with
|
||||
**PBKDF2-HMAC-SHA512, 600,000 iterations, random 16-byte salt** (fresh salt and nonce on
|
||||
every save). Tampering with the file is detected before decryption.
|
||||
- If you **decline encryption** (the wizard checkbox, or omitting `--password` in the CLI),
|
||||
**the seed is stored in plaintext on disk**. The wizard warns you explicitly. Only do this
|
||||
on a disk you fully trust (e.g. an encrypted volume).
|
||||
- There are **no password strength requirements and no attempt limits**. An empty password is
|
||||
not allowed when encryption is enabled. Choose a strong password yourself: an attacker who
|
||||
copies the file can try passwords offline at their leisure.
|
||||
- There is currently **no "change password" function** in the GUI. To change the password,
|
||||
restore the wallet from its seed into a new wallet file with the new password, verify the
|
||||
new file opens and syncs, then delete the old file. (Contacts must be re-entered — see
|
||||
[section 15](#15-backup-and-recovery).)
|
||||
|
||||
### 2.6 Confirmations and spendability
|
||||
|
||||
Incoming funds are not immediately spendable. The exact rules on mainnet:
|
||||
|
||||
- **Unconfirmed (mempool)** transactions: shown as *"pending confirmation … not yet
|
||||
spendable"*. Never spendable, and — important — unconfirmed amounts are reported by the
|
||||
server **without cryptographic proof**, so treat them as provisional until confirmed.
|
||||
- **Regular outputs**: spendable after **6 confirmations** (~12 minutes at 2-minute blocks).
|
||||
- **Mining (coinbase) outputs**: spendable after **121 confirmations** (~4 hours). Until
|
||||
then they appear as *"maturing … not yet spendable"*.
|
||||
|
||||
The main balance shown is *confirmed minus immature*; pending and maturing amounts are shown
|
||||
as separate amber lines under the balance when present.
|
||||
|
||||
---
|
||||
|
||||
## 3. Installation and platform differences
|
||||
|
||||
### 3.1 Desktop (Windows / Linux)
|
||||
|
||||
Release binaries are self-contained — no runtime to install. Run the single executable
|
||||
(`PalladiumWallet.exe` on Windows, `PalladiumWallet` on Linux).
|
||||
|
||||
### 3.2 Android
|
||||
|
||||
Install the `.apk` (sideloading must be allowed). Minimum Android version: 6.0 (API 23).
|
||||
The app requests the **camera** permission only if you use the QR scanner; **internet** is
|
||||
required for synchronization.
|
||||
|
||||
> **Updating in place**: releases built with the project's official signing key update over
|
||||
> the installed app. If Android refuses to install an update ("app not installed" /
|
||||
> signature mismatch), the new apk was signed with a different key — you would have to
|
||||
> uninstall first, **which deletes the wallet file**. Back up your seed before uninstalling,
|
||||
> always.
|
||||
|
||||
### 3.3 What differs between desktop and Android
|
||||
|
||||
| Aspect | Desktop | Android |
|
||||
|---|---|---|
|
||||
| Data location | Chosen at first run (default or custom folder) | Fixed: the app's private sandbox (step skipped) |
|
||||
| Menu bar | File / Network / Settings / Wallet / Help | No menu bar; same functions reachable from the UI |
|
||||
| Close overlays | `Esc` key or click the dark backdrop | Hardware/gesture **Back** button or tap the backdrop |
|
||||
| QR code **scanning** (Send) | Not available | Camera scan button in the Send form |
|
||||
| CLI | Available | Not available |
|
||||
|
||||
Everything else — wallet formats, security, sync, features — is identical.
|
||||
|
||||
### 3.4 Where your data lives (desktop)
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. A `palladium-data/` folder next to the executable, if present (**portable mode** — create
|
||||
the folder manually before first launch to get a fully portable install).
|
||||
2. The custom folder you picked in the first-run wizard (remembered via a small pointer file
|
||||
in the default location).
|
||||
3. The default: `%APPDATA%\PalladiumWallet` on Windows, `~/.palladium-wallet` on Linux.
|
||||
|
||||
Inside the data root: `mainnet/wallets/*.wallet.json` (your wallets),
|
||||
`mainnet/server-certs.json` (pinned TLS certificates), `mainnet/servers.json` (discovered
|
||||
servers), `config.json` (language, unit, last server — shared across wallets).
|
||||
|
||||
---
|
||||
|
||||
## 4. First launch: the setup wizard
|
||||
|
||||
### 4.1 Step: data location (desktop only, very first run)
|
||||
|
||||
Choose where the wallet stores its data: **"Use the default path"** (shown on screen) or
|
||||
**"Choose a folder…"** (native folder picker). This step never reappears once configured.
|
||||
On Android it is skipped entirely.
|
||||
|
||||
### 4.2 Step: start
|
||||
|
||||
Five options:
|
||||
|
||||
1. **Open existing wallet** — see [section 5](#5-opening-closing-and-managing-multiple-wallets).
|
||||
2. **Create a new wallet**
|
||||
3. **Restore from seed**
|
||||
4. **Import xpub / xprv**
|
||||
5. **Import WIF key**
|
||||
|
||||
### 4.3 Creating a new wallet
|
||||
|
||||
1. **Your seed (12 words)** — the freshly generated mnemonic is displayed. Write the words
|
||||
on paper, in order. *They are never shown in full again except through the password-gated
|
||||
"Show seed" function ([section 14](#14-wallet-information)).* Do not photograph them, do
|
||||
not store them in a cloud note.
|
||||
2. **Confirm seed** — retype the 12 words, space-separated. Comparison is case-insensitive
|
||||
and tolerant of extra spaces. A mismatch shows *"The words do not match: check what you
|
||||
wrote on paper."* and lets you retry.
|
||||
3. **Optional passphrase** — leave empty to skip. Re-read [section 2.2](#22-the-optional-passphrase-25th-word)
|
||||
before setting one.
|
||||
4. **Script type** — default Native SegWit. If unsure, keep the default.
|
||||
5. **Password step** — see 4.7.
|
||||
|
||||
### 4.4 Restoring from seed
|
||||
|
||||
1. Enter the mnemonic, words separated by spaces. Invalid words or a bad checksum produce
|
||||
*"Invalid mnemonic (wrong words or checksum): check again."*
|
||||
2. Enter the **same passphrase** used originally (empty if none was used).
|
||||
3. Select the **same script type** used originally.
|
||||
4. Password step (4.7).
|
||||
|
||||
After the first synchronization the balance and full history reappear. If the balance is
|
||||
zero when it shouldn't be: wrong passphrase or wrong script type
|
||||
([sections 2.2–2.3](#22-the-optional-passphrase-25th-word)).
|
||||
|
||||
### 4.5 Importing an extended key (xpub / xprv)
|
||||
|
||||
Paste a SLIP-132 extended key:
|
||||
|
||||
- **Public key** (`xpub`/`ypub`/`zpub`) → **watch-only** wallet: monitors balances and
|
||||
history, cannot spend.
|
||||
- **Private key** (`xprv`/`yprv`/`zprv`) → fully spendable HD wallet (account level, no seed
|
||||
words).
|
||||
|
||||
The script type is detected automatically from the key prefix and shown for confirmation
|
||||
(e.g. *"NativeSegwit (watch-only)"*). An unrecognized key shows *"Extended key not
|
||||
recognised for this network."* — check that it is a Palladium key of a supported format, not
|
||||
a key from another coin.
|
||||
|
||||
### 4.6 Importing WIF private keys
|
||||
|
||||
Paste one or more WIF keys, **one per line**. Each key controls exactly one address; there
|
||||
is no HD derivation. Select the script type (which address form the keys map to), then the
|
||||
password step. Remember the change-address caveat from
|
||||
[section 2.4](#24-wallet-types).
|
||||
|
||||
### 4.7 The password step (all flows)
|
||||
|
||||
- **Wallet name** (optional): letters/numbers recommended; invalid filesystem characters are
|
||||
replaced with `_`. Left blank → automatic name (`default`, then `wallet-2`, `wallet-3`, …).
|
||||
A name that already exists is rejected: *"A wallet with this name already exists."*
|
||||
- **"Encrypt the wallet file with the password"** — checked by default. If you uncheck it,
|
||||
the file (including the seed) is written **in plaintext** and the UI warns you.
|
||||
- **Password + confirmation** — must match and be non-empty when encryption is on. No other
|
||||
rules are imposed; strength is your responsibility.
|
||||
- **Create wallet** finalizes: the file is written, locked against concurrent access, opened,
|
||||
and the first connection/sync starts automatically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Opening, closing and managing multiple wallets
|
||||
|
||||
- You can keep **any number of wallets**: each is a separate `*.wallet.json` file in the
|
||||
wallets folder. With more than one file, "Open existing wallet" shows a chooser list; with
|
||||
exactly one, it goes straight to the password prompt.
|
||||
- **Password prompt**: leave empty for an unencrypted wallet. Wrong password → *"Wrong
|
||||
password."* — retries are unlimited.
|
||||
- The GUI **only lists wallets inside its own wallets folder**. To use a wallet file from
|
||||
elsewhere, copy it into `<data folder>/mainnet/wallets/` first (while the app is closed,
|
||||
or before pressing Open).
|
||||
- **Single-instance lock**: a wallet file can be open in only one running instance at a
|
||||
time. A second attempt fails with *"Wallet already open in another instance of the
|
||||
application."* A stale `.lock` file left by a crash is released automatically when the
|
||||
owning process is gone.
|
||||
- **Switching wallets**: menu *File → Close wallet* (returns to the wizard), then *Open
|
||||
existing wallet*. *File → New / restore wallet…* also closes the current wallet first.
|
||||
- **Deleting a wallet**: there is no in-app delete. Close the app and delete the
|
||||
`.wallet.json` file manually — **only after verifying you have the seed on paper** (or,
|
||||
for imported wallets, the original keys). Deleting the file of an unbacked-up wallet
|
||||
destroys the funds' keys.
|
||||
|
||||
After opening, the header shows the wallet's identity, e.g.
|
||||
`Mainnet · NativeSegwit · m/84'/746'/0'`, with a ` · watch-only` or ` · imported` tag when
|
||||
applicable.
|
||||
|
||||
---
|
||||
|
||||
## 6. The main screen
|
||||
|
||||
Five tabs: **History**, **Send**, **Receive**, **Addresses**, **Contacts**.
|
||||
|
||||
- **Balance** (top): confirmed, spendable balance in your chosen unit. Below it, when
|
||||
applicable, two amber advisory lines: *pending confirmation* (mempool, unproven, not
|
||||
spendable) and *maturing* (young coinbase, not spendable — see
|
||||
[section 2.6](#26-confirmations-and-spendability)).
|
||||
- **Status bar** (bottom): left, the sync status (e.g. *"Synchronized: height 123456, 42
|
||||
SPV-verified transactions. Real-time updates active."*); right, the **connection
|
||||
indicator** (*connected host:port* / *connecting…* / *not connected* / *reconnecting…*).
|
||||
**Click/tap the connection indicator to open the server settings.**
|
||||
- All secondary screens (settings, details, wallet info, help) are **in-app overlays**, not
|
||||
separate windows. Close them with the ✕ button, by clicking the dark backdrop, with `Esc`
|
||||
(desktop) or Back (Android).
|
||||
|
||||
---
|
||||
|
||||
## 7. Receiving funds
|
||||
|
||||
The **Receive** tab always shows the **next unused receive address**, as text and as a QR
|
||||
code (the QR encodes the bare address).
|
||||
|
||||
- **Copy** puts the address on the clipboard (*"Address copied to clipboard"*).
|
||||
- Give a **fresh address to each payer**. After an address receives a payment and a sync
|
||||
runs, the tab automatically advances to the next unused address. Reusing addresses is not
|
||||
blocked, but it degrades your privacy by linking payments together.
|
||||
- Incoming payments appear in History **at the next synchronization** — normally within
|
||||
seconds, because the wallet subscribes to server push notifications. A payment shows as
|
||||
*mempool* first, then with its block height once mined.
|
||||
- The address format is fixed by the wallet's script type
|
||||
([section 2.3](#23-script-type-address-format)); senders must support that format (in
|
||||
particular, very old software may not send to `plm1…` bech32 addresses).
|
||||
|
||||
**Gap limit — the one receiving rule you must know.** The wallet derives addresses on
|
||||
demand and, when restoring from seed, scans forward until it finds **20 consecutive unused
|
||||
addresses** and then stops. Consequence: if you hand out many addresses that never receive
|
||||
funds, do not skip ahead by more than 20 — a payment to address #30, with #10–#29 unused,
|
||||
would **not be found after a restore from seed**. Under normal use (addresses handed out
|
||||
sequentially, most getting used) this never triggers. The limit (20) is stored per wallet
|
||||
and is not editable in the GUI.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sending funds
|
||||
|
||||
Prerequisites: the wallet must be **connected and synchronized** (otherwise: *"Connect to
|
||||
the server and synchronize before sending"* / *"Synchronize before sending"*), and the
|
||||
wallet must be spendable (not watch-only) to actually broadcast.
|
||||
|
||||
### 8.1 The form
|
||||
|
||||
- **From contacts** — optional dropdown that fills the recipient address from your address
|
||||
book.
|
||||
- **Recipient address** — validated when you prepare: an address of the wrong network or
|
||||
with a typo is rejected (checksums make silent typos virtually impossible).
|
||||
- **Amount** — interpreted in the **currently selected display unit** (PLM by default — check
|
||||
the unit in Settings before typing!). Rejected if negative, malformed, or more precise
|
||||
than 1 satoshi (0.00000001 PLM).
|
||||
- **Send all** — checkbox; disables the amount field and sends the entire spendable balance,
|
||||
**with the fee deducted from the amount** (the recipient receives balance − fee).
|
||||
- **Fee (sat/vB)** — a single manually-entered fee rate. **Default: 1 sat/vB.** There are no
|
||||
presets and no automatic fee estimation. On the Palladium chain, blocks are rarely full,
|
||||
so 1 sat/vB normally confirms in the next few blocks; raise it if a transaction lingers in
|
||||
the mempool. Must be a number greater than zero.
|
||||
- **QR scan** (Android only) — camera button; reads a plain address or a
|
||||
`palladium:` payment URI (the address part is used).
|
||||
|
||||
### 8.2 Prepare, then confirm
|
||||
|
||||
Sending is a **two-step** operation:
|
||||
|
||||
1. **Prepare transaction** — builds and signs the transaction locally, without sending
|
||||
anything. The summary card shows the txid (truncated), the **exact fee**, and the virtual
|
||||
size, e.g. `txid 4f3a…| fee 0.00000141 PLM (141 vB)`. Nothing has left your wallet yet;
|
||||
you can change the fields and prepare again, or simply navigate away to abandon it.
|
||||
2. **CONFIRM AND BROADCAST** — transmits the prepared transaction to the server. Success
|
||||
shows the full txid; the form clears and a re-sync starts. After broadcasting, **a
|
||||
transaction cannot be cancelled** — it is in the network's mempool and will confirm.
|
||||
|
||||
### 8.3 How the transaction is built (facts you may need)
|
||||
|
||||
- **Coin selection is automatic** (there is no coin control). The wallet spends from its
|
||||
spendable UTXOs and sends any **change back to its own internal change chain** — this is
|
||||
why, in transaction details, one output to an address you don't recognize as "yours" may
|
||||
still be marked as yours: it is your change.
|
||||
- All transactions **signal RBF** (BIP125) and use transaction version 2. The wallet does
|
||||
not offer fee bumping; if you underpay the fee, wait — with RBF signaled, other wallets
|
||||
could in principle replace it, but this wallet has no UI for that.
|
||||
- **Dust change is absorbed into the fee**: if the change output would be uneconomically
|
||||
small, it is added to the fee instead of creating a dust output. The fee shown in the
|
||||
summary already accounts for this — always check the summary fee before confirming.
|
||||
- A prepared transaction is fully verified against standardness policy before it can be
|
||||
confirmed; a policy failure surfaces as *"Invalid transaction: …"* instead of a broadcast.
|
||||
|
||||
### 8.4 Why "insufficient funds" can appear despite a visible balance
|
||||
|
||||
The error message itemizes the reasons. Funds may be temporarily unspendable because they
|
||||
are:
|
||||
|
||||
- **in the mempool** (unconfirmed — never spendable),
|
||||
- **recently confirmed** (fewer than 6 confirmations),
|
||||
- **immature coinbase** (mining rewards younger than 121 blocks).
|
||||
|
||||
Wait for confirmations and retry. Also remember that the fee itself must fit: sending your
|
||||
exact full balance fails unless you use **Send all**.
|
||||
|
||||
### 8.5 Watch-only wallets and offline signing
|
||||
|
||||
A watch-only (xpub) wallet in the **GUI** can prepare a transaction to preview the fee and
|
||||
size — the summary is tagged *"unsigned (watch-only)"* — but the confirm button stays
|
||||
disabled: **the GUI cannot export the unsigned transaction**. To actually use the
|
||||
watch-only + offline-signing workflow, use the **CLI**, whose `send` command prints an
|
||||
**unsigned PSBT (base64)** for a watch-only wallet
|
||||
([section 17](#17-command-line-interface-cli)). Sign that PSBT on the offline machine with
|
||||
software of your choice, then broadcast the finalized transaction.
|
||||
|
||||
### 8.6 The Donate tab (Help → Donate)
|
||||
|
||||
The Help overlay includes a donation form (developer address
|
||||
`plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay`) using the same prepare/confirm flow and the
|
||||
fee rate from the Send tab (falling back to 1 sat/vB if invalid). Entirely optional.
|
||||
|
||||
---
|
||||
|
||||
## 9. Transaction history and details
|
||||
|
||||
The **History** tab lists all wallet transactions, newest first; unconfirmed ones are
|
||||
labeled **mempool** and sorted on top, confirmed ones show their **block height**. The
|
||||
amount is the transaction's net effect on your wallet (`+` incoming).
|
||||
|
||||
**Double-click** (desktop) or **tap** (Android) a row to open the full details. This
|
||||
requires a live server connection (details are fetched on demand): otherwise *"Connect to
|
||||
the server to view transaction details."*
|
||||
|
||||
The details overlay shows:
|
||||
|
||||
- **Overview** — status (*"N confirmations (block H)"* or *"0 confirmations · in
|
||||
mempool"*), local date/time, counterparty addresses, and the signed net amount. Mining
|
||||
rewards show *"Newly generated (mining)"* as the sender.
|
||||
- **Amounts & fees** — the absolute fee, your net amount, and the fee rate in sat/vB.
|
||||
- **Technical details** — full transaction ID, total size, virtual size, **Replaceable
|
||||
(RBF)** yes/no, version, locktime.
|
||||
- **Inputs** — each spent outpoint with its address and amount; your own inputs are
|
||||
highlighted. Coinbase inputs display the miner's **scriptSig message** when it is readable
|
||||
text.
|
||||
- **Outputs** — each output with index, address and amount; your own outputs (including
|
||||
change) are highlighted. **OP_RETURN** outputs display their decoded text message when the
|
||||
payload is valid readable text; non-standard outputs show their script type.
|
||||
|
||||
Notes and limits:
|
||||
|
||||
- Confirmation counts advance automatically as blocks arrive (real-time header
|
||||
subscription).
|
||||
- Every **confirmed** transaction in your history has been verified with a Merkle proof
|
||||
during sync. **Unconfirmed** entries are the server's word only — do not treat a mempool
|
||||
payment as received until it confirms.
|
||||
- There are no per-transaction labels and no block-explorer links in this version. The
|
||||
public explorer is at `https://explorer.palladium-coin.com/` — paste the txid there
|
||||
manually if needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. The Addresses tab
|
||||
|
||||
A complete table of every derived address: **type** (receive/change), **index**,
|
||||
**address**, **balance**, and **transaction count**. Before the first sync it shows the
|
||||
first 10 receive addresses with no data.
|
||||
|
||||
Tap (or right-click) a row to open the **address details** overlay:
|
||||
|
||||
- the full address and its **derivation path** (e.g. `m/84'/746'/0'/0/3`),
|
||||
- the **public key** (hex),
|
||||
- the **private key (WIF)**, hidden behind a **"Show"** button. For an encrypted wallet,
|
||||
revealing it requires re-entering the wallet password (*"Enter the wallet password to
|
||||
reveal the private key."*); for an unencrypted wallet it is shown directly. **Never
|
||||
reveal a private key with anyone watching your screen** — one key controls that one
|
||||
address's funds. Watch-only wallets have no private keys to show.
|
||||
|
||||
`change` addresses are where your own transactions send their change
|
||||
([section 8.3](#83-how-the-transaction-is-built-facts-you-may-need)) — seeing balance on
|
||||
them is normal.
|
||||
|
||||
---
|
||||
|
||||
## 11. Contacts
|
||||
|
||||
A simple per-wallet address book, on its own tab:
|
||||
|
||||
- **Add**: name + address, both required (whitespace is trimmed). Note: the address format
|
||||
is **not validated when saving** — it is only validated when you actually send to it. Copy
|
||||
addresses carefully.
|
||||
- **Remove selected**: deletes the highlighted contact. There is no edit function — remove
|
||||
and re-add.
|
||||
- Contacts are used by the **From contacts** dropdown on the Send tab.
|
||||
- Contacts are stored **inside the wallet file**. They travel with a file backup, but are
|
||||
**not** recoverable from the seed phrase ([section 15](#15-backup-and-recovery)).
|
||||
|
||||
---
|
||||
|
||||
## 12. Connection, servers and certificate pinning
|
||||
|
||||
### 12.1 Server settings
|
||||
|
||||
Open by clicking the **connection indicator** in the status bar (or Settings → *Indexing
|
||||
server…*, or menu *Network* on desktop). Fields:
|
||||
|
||||
- **Host** and **Port** — defaults: port **50001** (plain TCP) or **50002** (SSL). Editing
|
||||
one of the known ports auto-toggles the SSL switch to match, and toggling SSL swaps the
|
||||
port. Note these are the *indexing server* ports — not the Palladium node's P2P port
|
||||
(2333), which this wallet never uses.
|
||||
- **Use SSL** — default **on**. Strongly recommended: without SSL, traffic (including which
|
||||
addresses you query) is readable on the wire.
|
||||
- **Known servers** list — click an entry to select it. The wallet ships with four bootstrap
|
||||
mainnet servers and remembers any servers learned via discovery.
|
||||
- **Discover servers (peers)** — asks the connected server for its known peers and adds new
|
||||
ones to the list.
|
||||
|
||||
Connection behavior: the wallet tries your selected server first, then walks the rest of
|
||||
the known list until one answers. The last working server is remembered and reused at next
|
||||
launch. A 20-second keep-alive ping detects drops and reconnects automatically;
|
||||
server push notifications trigger instant re-syncs when a new block or a relevant
|
||||
transaction appears (*"Real-time updates active"*).
|
||||
|
||||
### 12.2 TLS certificate pinning (TOFU) — read before "fixing" a certificate error
|
||||
|
||||
SSL connections use **Trust On First Use**: the first time you connect to a server, its
|
||||
certificate's fingerprint is saved (in `server-certs.json`). Every later connection must
|
||||
present the **same** certificate. If it doesn't, the connection is refused with a message
|
||||
stating that the TLS certificate of that server **has changed**.
|
||||
|
||||
This is a security feature, not a bug. A changed certificate means one of two things:
|
||||
|
||||
1. **Benign**: the server operator renewed/rotated the certificate (common, e.g. Let's
|
||||
Encrypt renewals).
|
||||
2. **Hostile**: someone is intercepting your connection (man-in-the-middle).
|
||||
|
||||
You cannot distinguish the two from the wallet alone. If the change is expected or you can
|
||||
verify it out-of-band, clear the pins: menu **Network → Reset SSL certificates** (desktop),
|
||||
the **Reset certs** button in server settings, or `reset-certs` in the CLI. This deletes
|
||||
**all** pinned certificates for the network; the next connection re-pins whatever it sees.
|
||||
If you have any reason to suspect interception, switch networks (e.g. off a public Wi-Fi)
|
||||
or servers instead of resetting.
|
||||
|
||||
### 12.3 What synchronization actually does
|
||||
|
||||
Each sync: gets the chain tip → scans your receive and change address chains (gap limit
|
||||
20) → downloads your transactions → **verifies a Merkle proof for every confirmed
|
||||
transaction** against its block header → rebuilds your UTXO set and balances locally.
|
||||
On mainnet, each block header used for a proof is also chained back (via its previous-block
|
||||
hash) to the nearest built-in checkpoint, so a server cannot substitute a fabricated header
|
||||
for a real one. Verified data is cached in the wallet file, so later syncs only fetch what
|
||||
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
|
||||
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
|
||||
unverified data; switch servers.
|
||||
|
||||
If the server is overloaded (busy responses), the wallet retries automatically up to 8
|
||||
times with increasing back-off — a large wallet's first sync may take a little while, but it
|
||||
resumes from the cache instead of restarting.
|
||||
|
||||
---
|
||||
|
||||
## 13. Settings
|
||||
|
||||
The Settings overlay (gear icon / menu *Settings*) contains exactly two preferences, plus
|
||||
the door to the server settings:
|
||||
|
||||
- **Language** — Italiano, English, Español, Français, Português, Deutsch. Applies
|
||||
immediately. Default: English.
|
||||
- **Amount unit** — how amounts are displayed *and interpreted when you type them*:
|
||||
|
||||
| Unit | In satoshi | Decimals shown |
|
||||
|---|---|---|
|
||||
| PLM (default) | 100,000,000 | 8 |
|
||||
| mPLM | 100,000 | 5 |
|
||||
| µPLM | 100 | 2 |
|
||||
| sat | 1 | 0 |
|
||||
|
||||
⚠ The Send form's amount field uses this unit. If you switch to `sat` and forget, typing
|
||||
`100` means 100 satoshi, not 100 PLM. Double-check the unit before sending.
|
||||
|
||||
Settings are saved globally (per data folder, shared by all wallets), not per wallet.
|
||||
There is no theme selector (the app uses its built-in dark theme) and no fiat display.
|
||||
|
||||
---
|
||||
|
||||
## 14. Wallet Information
|
||||
|
||||
Menu **Wallet → Wallet Information** (desktop) or the equivalent button. Shows:
|
||||
|
||||
- **File name**, **network**, **wallet type** (*HD (BIP39 seed)* / *HD (imported xprv)* /
|
||||
*Imported WIF key* / *Watch-only (xpub)*), **script type**, **derivation path**.
|
||||
- **Extended public key (xpub)** — displayed as selectable text. This is how you *export*
|
||||
the xpub: select and copy it. Give the xpub to another device/app to create a
|
||||
**watch-only** copy of this wallet — it reveals your entire address history and balance to
|
||||
whoever holds it (privacy risk), but can never spend.
|
||||
- **Master fingerprint** — 4-byte identifier of the master key, used in PSBT workflows.
|
||||
- **BIP39 passphrase** — shows only whether one is *set*; the passphrase itself is never
|
||||
displayed anywhere.
|
||||
- **Show seed** — reveals the mnemonic, after re-entering the wallet password (for
|
||||
encrypted wallets). Use it to re-verify your paper backup. The on-screen warning is
|
||||
literal: *never share these words; whoever holds them controls the funds.* Watch-only and
|
||||
imported wallets have no seed to show.
|
||||
|
||||
---
|
||||
|
||||
## 15. Backup and recovery
|
||||
|
||||
### 15.1 What to back up
|
||||
|
||||
There are two complementary backups; know exactly what each one restores:
|
||||
|
||||
| Backup | Restores | Does NOT restore |
|
||||
|---|---|---|
|
||||
| **Seed words on paper** (+ passphrase if set, + script type) | All keys, addresses, balance and on-chain history, on any device, forever | Contacts; wallet name; settings |
|
||||
| **The wallet file** (+ its password) | Everything, including contacts | — |
|
||||
|
||||
Recommended: **always** have the paper seed (it survives disk failure, device loss, and
|
||||
software obsolescence); *additionally* copy the `.wallet.json` file if you care about your
|
||||
contacts list. An encrypted wallet file is safe to store on ordinary media — but it is only
|
||||
as strong as its password, and it is useless without it.
|
||||
|
||||
For a **watch-only** wallet, the file only restores the watching capability; the actual
|
||||
keys live wherever you keep the corresponding seed/xprv — back *that* up.
|
||||
|
||||
For an **imported WIF** wallet, back up the WIF keys themselves; they are not derivable from
|
||||
anything else.
|
||||
|
||||
### 15.2 Restoring, step by step
|
||||
|
||||
1. Install the wallet on the new device.
|
||||
2. Wizard → **Restore from seed** → enter the words → enter the **same passphrase** (or
|
||||
leave empty if none) → choose the **same script type** → set a (new) password.
|
||||
3. Let it synchronize. Balance and history are rebuilt from the chain.
|
||||
|
||||
If the balance is zero: wrong passphrase, or wrong script type, or (rare) funds beyond the
|
||||
gap limit ([section 7](#7-receiving-funds)). Nothing is lost — the coins are still on the
|
||||
chain; you are simply looking at the wrong derived wallet. Retry with the right parameters.
|
||||
|
||||
Restoring from a **file copy** instead: place the `.wallet.json` into
|
||||
`<data folder>/mainnet/wallets/` and open it with its password.
|
||||
|
||||
### 15.3 Unrecoverable situations — be aware
|
||||
|
||||
- Seed lost **and** wallet file (or its password) lost → funds gone. No exceptions.
|
||||
- Passphrase forgotten → funds gone, even with the 12 words in hand.
|
||||
- Encrypted file's password forgotten, no seed backup → funds gone (the encryption has no
|
||||
backdoor).
|
||||
|
||||
---
|
||||
|
||||
## 16. Security model and known limitations
|
||||
|
||||
A condensed version of the project's published threat model (`SECURITY.md`).
|
||||
|
||||
**The wallet protects you against:**
|
||||
|
||||
- Theft of the wallet file at rest (AES-256-GCM + PBKDF2, [section 2.5](#25-the-wallet-file-and-its-encryption)) —
|
||||
provided you set a good password.
|
||||
- A **lying indexing server**: it cannot fabricate a confirmed transaction or forge a
|
||||
payment to a wrong address, because every confirmed transaction must carry a valid Merkle
|
||||
proof, which the wallet checks itself.
|
||||
- **Silent TLS interception after first contact** (certificate pinning,
|
||||
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
|
||||
|
||||
**The server can still (semi-trusted component):**
|
||||
|
||||
- lie about **unconfirmed** transactions (which is why they are marked not-spendable and
|
||||
should not be trusted until confirmed);
|
||||
- **withhold** information — hide transactions, delay new blocks, refuse to broadcast;
|
||||
- **observe** which addresses belong to you (no Tor/proxy support in this version; using
|
||||
your own indexing server is the strongest mitigation).
|
||||
|
||||
**The wallet cannot protect you against:**
|
||||
|
||||
- malware on your device (anything that can read process memory can take the keys once the
|
||||
wallet is unlocked);
|
||||
- an attacker holding both your wallet file **and** its password (or your seed);
|
||||
- yourself: there are no confirmation "cool-downs", no address whitelists, and broadcast is
|
||||
irreversible.
|
||||
|
||||
**Honest limitations of this version** (also listed in [section 1](#1-what-palladium-wallet-is-and-is-not)):
|
||||
single server connection (no pooling), no coin control, no fee estimation, no RBF bumping,
|
||||
no hardware wallets, no Tor, GUI mainnet-only, and PSBT export only via the CLI.
|
||||
|
||||
---
|
||||
|
||||
## 17. Command-line interface (CLI)
|
||||
|
||||
The CLI (`src/Cli`) runs on the same core as the GUI: same wallet files, same validation,
|
||||
same security. It is desktop-only and its console output is in English. Run it
|
||||
as `dotnet run --project src/Cli -- <command>` from the repository (or the published `Cli`
|
||||
binary); with no arguments it prints usage.
|
||||
|
||||
**Shared conventions.** Default wallet file:
|
||||
`~/.palladium-wallet/<network>/wallets/default.wallet.json` — override with `--file PATH`.
|
||||
Default network mainnet — override with `--net testnet|regtest` (the CLI is the only way to
|
||||
use test networks). `--password P` both decrypts an existing file and encrypts on save;
|
||||
**omitting it on `create`/`restore` writes the seed in plaintext** (a warning is printed).
|
||||
`--kind legacy|wrapped|segwit` selects the script type (default Native SegWit — note that
|
||||
only the literal values `legacy` and `wrapped` switch away from the default). Errors print
|
||||
to stderr and set exit code 1.
|
||||
|
||||
### Wallet commands
|
||||
|
||||
```
|
||||
newseed [--words 24]
|
||||
```
|
||||
Prints a fresh mnemonic (12 words unless `--words 24`) and writes nothing to disk.
|
||||
|
||||
```
|
||||
create [--words 24] [--kind K] [--net N] [--passphrase W] [--password P] [--file PATH] [--path m/...]
|
||||
```
|
||||
Generates a mnemonic, prints it once, saves the wallet, prints the file path and the first
|
||||
receive address. `--path` overrides the derivation path (advanced; you must remember it to
|
||||
restore).
|
||||
|
||||
```
|
||||
restore "<mnemonic>" [same options as create]
|
||||
```
|
||||
Restores from an existing mnemonic (quoted, words space-separated).
|
||||
|
||||
```
|
||||
restore-xpub <slip132-key> [--net N] [--password P] [--file PATH]
|
||||
```
|
||||
Creates a **watch-only** wallet from an xpub/ypub/zpub; the script type is inferred from
|
||||
the key prefix.
|
||||
|
||||
```
|
||||
info [--net N] [--password P] [--file PATH] [--addresses]
|
||||
```
|
||||
Prints the wallet's identity and, if it has ever synced, the balance breakdown (spendable /
|
||||
maturing / pending), tip height, transaction count and next receive address.
|
||||
`--addresses` adds the per-address list with balances.
|
||||
|
||||
```
|
||||
addresses "<mnemonic>" [--kind K] [--net N] [--count N] [--passphrase W] [--path m/...]
|
||||
```
|
||||
Stateless tool: derives and prints receive (default 5) and change addresses from a mnemonic
|
||||
without creating any file. Useful to check "which script type was this seed used with"
|
||||
before restoring.
|
||||
|
||||
### Network commands
|
||||
|
||||
```
|
||||
sync [--server host[:port]] [--ssl] [--net N] [--password P] [--file PATH]
|
||||
```
|
||||
Connects (default: the first known server; port defaults to 50001, or 50002 with `--ssl`),
|
||||
synchronizes with full Merkle verification, persists the cache into the wallet file, and
|
||||
prints balance and history. Transactions the server reported but that are unconfirmed are
|
||||
marked as unverified.
|
||||
|
||||
```
|
||||
send --to ADDRESS (--amount X | --all) [--feerate R] [--broadcast]
|
||||
[--server ...] [--ssl] [--net N] [--password P] [--file PATH]
|
||||
```
|
||||
Builds a transaction from the synced cache (`sync` must have been run first). `--amount` is
|
||||
in **PLM**; `--all` sends everything minus the fee. `--feerate` is in sat/vB, **default 1**.
|
||||
Behavior by wallet type and flags:
|
||||
|
||||
- **Watch-only** wallet → prints the **unsigned PSBT in base64** and stops. This is the
|
||||
air-gapped workflow: carry the PSBT to the offline signer, sign it there, broadcast the
|
||||
final transaction by any means.
|
||||
- Spendable wallet, **without `--broadcast`** → signs and prints the raw transaction hex,
|
||||
explicitly marked as *not transmitted*. Nothing is sent — a dry run you can inspect or
|
||||
broadcast elsewhere.
|
||||
- Spendable wallet, **with `--broadcast`** → signs, transmits, prints the txid. Irreversible
|
||||
from this point.
|
||||
|
||||
```
|
||||
servers [--discover] [--server ...] [--ssl] [--net N]
|
||||
```
|
||||
Lists known servers; with `--discover`, connects and asks the server for its peers, adding
|
||||
new ones.
|
||||
|
||||
```
|
||||
reset-certs [--net N]
|
||||
```
|
||||
Deletes all pinned TLS certificates for the network (the CLI counterpart of *Network →
|
||||
Reset SSL certificates* — see
|
||||
[section 12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error)).
|
||||
|
||||
---
|
||||
|
||||
## 18. Troubleshooting and error messages
|
||||
|
||||
| Symptom / message | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| *Wrong password.* | The password does not decrypt the file (or the file was tampered with — the two are indistinguishable by design). | Retry; check keyboard layout/caps lock. If truly lost, restore from seed ([15.2](#152-restoring-step-by-step)). |
|
||||
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
|
||||
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
|
||||
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
|
||||
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
|
||||
| Restored wallet shows zero balance | Wrong passphrase, wrong script type, or funds beyond the gap limit. | See [15.2](#152-restoring-step-by-step) — the coins are not lost. |
|
||||
| *Invalid mnemonic (wrong words or checksum)* | A word is misspelled, not on the BIP39 list, or the checksum fails. | Compare against your paper backup, word by word. |
|
||||
| *Extended key not recognised for this network.* | The pasted xpub/xprv is not a SLIP-132 key of this network. | Check the key's prefix and the source wallet's export format. |
|
||||
| *A wallet with this name already exists.* | Filename collision in the wallets folder. | Pick another name, or leave blank for an automatic one. |
|
||||
| Payment sent to me doesn't appear | Not yet synced/connected, or sender hasn't broadcast. | Check the connection indicator; mempool entries appear within seconds of broadcast when connected. |
|
||||
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
|
||||
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
|
||||
| First sync is slow / server busy errors | Server throttling; the wallet retries automatically (up to 8 attempts, growing back-off). | Wait; progress is cached, so restarting resumes rather than repeats. |
|
||||
|
||||
---
|
||||
|
||||
## 19. Glossary
|
||||
|
||||
- **SPV** — Simplified Payment Verification: validating that transactions are included in
|
||||
blocks via Merkle proofs, without running a full node.
|
||||
- **Seed / mnemonic** — the 12 (or 24) BIP39 words that deterministically generate every key
|
||||
in the wallet.
|
||||
- **Passphrase (BIP39)** — optional extra secret combined with the seed; a different
|
||||
passphrase yields a different wallet.
|
||||
- **Script type** — the address format standard (Legacy/BIP44, Wrapped SegWit/BIP49, Native
|
||||
SegWit/BIP84, Taproot/BIP86).
|
||||
- **xpub / xprv** — extended public/private key of the wallet's account; the xpub watches,
|
||||
the xprv spends. SLIP-132 variants: ypub/zpub etc.
|
||||
- **WIF** — Wallet Import Format: a single address's private key as text.
|
||||
- **UTXO** — Unspent Transaction Output: a discrete "coin" the wallet can spend.
|
||||
- **Change** — the portion of spent UTXOs returned to your own (internal) addresses.
|
||||
- **Coinbase** — the transaction paying the miner of a block; spendable after 121
|
||||
confirmations.
|
||||
- **Mempool** — the set of broadcast-but-unconfirmed transactions.
|
||||
- **Gap limit** — how many consecutive unused addresses the wallet scans past before
|
||||
stopping (20).
|
||||
- **PSBT** — Partially Signed Bitcoin Transaction: the standard interchange format for
|
||||
signing a transaction on a device other than the one that built it.
|
||||
- **RBF** — Replace-By-Fee: a signal that a transaction may be replaced by a higher-fee
|
||||
version while unconfirmed.
|
||||
- **TOFU** — Trust On First Use: pinning a server's TLS certificate at first contact and
|
||||
refusing silent changes afterwards.
|
||||
- **Watch-only** — a wallet holding only public keys: full visibility, zero spending
|
||||
ability.
|
||||
|
||||
---
|
||||
|
||||
*This guide documents observed behavior of the software and is kept in sync with the code.
|
||||
For the formal threat model see [SECURITY.md](SECURITY.md); for per-release changes see
|
||||
[CHANGELOG.md](CHANGELOG.md).*
|
||||
+25
-9
@@ -46,6 +46,16 @@ 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
|
||||
@@ -64,7 +74,7 @@ Running without arguments shows an interactive menu — pick a single target or
|
||||
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 (debug-signed)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All three targets
|
||||
|
||||
Options:
|
||||
@@ -109,16 +119,16 @@ 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 debug-signed APK for sideloading: transfer it to the phone
|
||||
**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 caveat:** debug-signed APKs built on different machines carry
|
||||
> different keys. If a previous build is already installed, Android refuses
|
||||
> the update (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) — uninstall the old app
|
||||
> first. **Uninstalling deletes the app's data: back up the wallet seed
|
||||
> before doing this.** Release signing with a stable key is not set up yet.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -166,8 +176,14 @@ docker volume rm plm-nuget-cache
|
||||
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** — signature mismatch between
|
||||
debug keys; see the signature caveat above.
|
||||
- **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).
|
||||
|
||||
+37
-2
@@ -29,7 +29,7 @@ $(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 (debug-signed) → dist/android/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All three targets
|
||||
|
||||
$(bold "Options:")
|
||||
@@ -112,6 +112,10 @@ ensure_android_image() {
|
||||
# $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"
|
||||
@@ -123,6 +127,7 @@ run_build() {
|
||||
--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
|
||||
@@ -169,16 +174,46 @@ build_linux() {
|
||||
|
||||
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:AndroidSdkDirectory=\${ANDROID_HOME} \
|
||||
-p:ApplicationVersion=${VERSION_CODE} \
|
||||
-p:AndroidKeyStore=true \
|
||||
-p:AndroidSigningKeyStore=/keystore/release.keystore \
|
||||
-p:AndroidSigningKeyAlias=palladiumwallet \
|
||||
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
|
||||
-p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS}
|
||||
cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \
|
||||
\"/output/PalladiumWallet-${VERSION}.apk\"" \
|
||||
"${DIST_DIR}/android"
|
||||
EXTRA_DOCKER_ARGS=()
|
||||
ok "Android → dist/android/PalladiumWallet-${VERSION}.apk"
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>3</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||
|
||||
@@ -63,6 +63,11 @@ public sealed class Loc
|
||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
||||
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch"],
|
||||
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
||||
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
||||
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
||||
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen"],
|
||||
["donate.desc"] = [
|
||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
||||
"If you find this wallet useful, consider a small donation to the developer.",
|
||||
@@ -169,9 +174,9 @@ public sealed class Loc
|
||||
// Wallet panel
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
||||
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
|
||||
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
||||
["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
|
||||
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
||||
@@ -389,6 +394,9 @@ public sealed class Loc
|
||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
||||
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft"],
|
||||
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||
["msg.certreset"] = [
|
||||
"Certificati SSL azzerati: riprova la connessione.",
|
||||
@@ -398,6 +406,24 @@ public sealed class Loc
|
||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
||||
["msg.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler"],
|
||||
["msg.peer.discovery.error"] = ["Errore nella scoperta peer", "Peer discovery error", "Error al descubrir peers", "Erreur de découverte des pairs", "Erro na descoberta de peers", "Fehler bei der Peer-Suche"],
|
||||
["msg.peer.discovery.found"] = ["Trovati {0} nuovi server dai peer (totale {1}).", "Found {0} new servers from peers (total {1}).", "Se encontraron {0} nuevos servidores de peers (total {1}).", "{0} nouveaux serveurs trouvés via les pairs (total {1}).", "Encontrados {0} novos servidores dos peers (total {1}).", "{0} neue Server von Peers gefunden (insgesamt {1})."],
|
||||
["msg.peer.discovery.none"] = ["Nessun nuovo server annunciato (totale {0}).", "No new servers announced (total {0}).", "No se anunciaron nuevos servidores (total {0}).", "Aucun nouveau serveur annoncé (total {0}).", "Nenhum novo servidor anunciado (total {0}).", "Keine neuen Server angekündigt (insgesamt {0})."],
|
||||
["msg.send.sync.first"] = ["Sincronizza prima di inviare.", "Synchronize before sending.", "Sincroniza antes de enviar.", "Synchronisez avant d'envoyer.", "Sincronize antes de enviar.", "Vor dem Senden synchronisieren."],
|
||||
["msg.send.connect.first"] = ["Connettiti al server e sincronizza prima di inviare.", "Connect to the server and synchronize before sending.", "Conéctate al servidor y sincroniza antes de enviar.", "Connectez-vous au serveur et synchronisez avant d'envoyer.", "Conecte-se ao servidor e sincronize antes de enviar.", "Mit dem Server verbinden und vor dem Senden synchronisieren."],
|
||||
["msg.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag."],
|
||||
["msg.feerate.invalid"] = ["Fee rate non valido.", "Invalid fee rate.", "Tarifa no válida.", "Taux de frais invalide.", "Taxa inválida.", "Ungültige Gebührenrate."],
|
||||
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen"],
|
||||
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid"],
|
||||
["msg.unsigned.watchonly"] = [" · NON firmata (watch-only)", " · NOT signed (watch-only)", " · NO firmada (watch-only)", " · NON signée (watch-only)", " · NÃO assinada (watch-only)", " · NICHT signiert (watch-only)"],
|
||||
["msg.cert.mismatch"] = [
|
||||
"Il certificato TLS di {0} è cambiato rispetto a quello salvato. Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.",
|
||||
"The TLS certificate of {0} has changed from the one saved. If the server renewed its certificate, reset the SSL certificates.",
|
||||
"El certificado TLS de {0} ha cambiado respecto al guardado. Si el servidor renovó su certificado, restablece los certificados SSL.",
|
||||
"Le certificat TLS de {0} a changé par rapport à celui enregistré. Si le serveur a renouvelé son certificat, réinitialisez les certificats SSL.",
|
||||
"O certificado TLS de {0} mudou em relação ao salvo. Se o servidor renovou o certificado, redefina os certificados SSL.",
|
||||
"Das TLS-Zertifikat von {0} hat sich gegenüber dem gespeicherten geändert. Wenn der Server das Zertifikat erneuert hat, setzen Sie die SSL-Zertifikate zurück."],
|
||||
|
||||
// Contacts
|
||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>0.9.0</Version>
|
||||
<Version>1.0.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
@@ -32,7 +33,7 @@ public partial class MainWindowViewModel
|
||||
|
||||
if (_account is null || _doc?.Cache is null || _lastTransactions is null)
|
||||
{
|
||||
DonatePreview = "Sincronizza prima di inviare.";
|
||||
DonatePreview = Loc.Tr("msg.send.sync.first");
|
||||
return;
|
||||
}
|
||||
try
|
||||
@@ -40,7 +41,7 @@ public partial class MainWindowViewModel
|
||||
var destination = BitcoinAddress.Create(DevAddress, PalladiumNetworks.For(Net));
|
||||
if (!CoinAmount.TryParseIn(DonateAmount.Trim(), _config.Unit, out var amount) || amount <= 0)
|
||||
{
|
||||
DonatePreview = "Importo non valido.";
|
||||
DonatePreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
||||
@@ -49,7 +50,7 @@ public partial class MainWindowViewModel
|
||||
|
||||
_pendingDonate = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, sendAll: false);
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, sendAll: false);
|
||||
|
||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||
@@ -61,7 +62,7 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
DonatePreview = $"Errore: {ex.Message}";
|
||||
DonatePreview = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
@@ -74,7 +75,7 @@ public partial class MainWindowViewModel
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingDonate.ToHex());
|
||||
DonatePreview = $"Grazie! txid: {txid}";
|
||||
DonatePreview = $"{Loc.Tr("msg.donate.thanks")}: {txid}";
|
||||
DonateAmount = "";
|
||||
_pendingDonate = null;
|
||||
HasPendingDonate = false;
|
||||
@@ -82,7 +83,7 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DonatePreview = $"Errore broadcast: {ex.Message}";
|
||||
DonatePreview = $"{Loc.Tr("msg.broadcast.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private string unconfirmedText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string immatureText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string verifyingText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
@@ -249,6 +255,8 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||
UnconfirmedText = "";
|
||||
ImmatureText = "";
|
||||
VerifyingText = "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||
History.Clear();
|
||||
Addresses.Clear();
|
||||
@@ -261,18 +269,32 @@ public partial class MainWindowViewModel
|
||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||
return;
|
||||
}
|
||||
BalanceText = Fmt(cache.ConfirmedSats);
|
||||
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
|
||||
// immature coinbase can also be unverified), which double-subtracts and can go
|
||||
// negative. SpendableSats is computed directly from the same IsSpendable gate
|
||||
// coin selection uses, so it's always correct.
|
||||
BalanceText = Fmt(cache.SpendableSats);
|
||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||
UnconfirmedText = pending != 0
|
||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
ImmatureText = cache.ImmatureSats != 0
|
||||
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
|
||||
// proof background verification hasn't reached yet — same "not spendable" treatment
|
||||
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
|
||||
VerifyingText = cache.PendingVerificationSats != 0
|
||||
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||
History.Clear();
|
||||
foreach (var tx in cache.History)
|
||||
History.Add(new HistoryRow(
|
||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||
tx.Txid));
|
||||
tx.Txid,
|
||||
tx.Verified));
|
||||
|
||||
Addresses.Clear();
|
||||
foreach (var a in cache.Addresses)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.App.Services;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
@@ -47,14 +48,14 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
if (_account is null || _doc?.Cache is null)
|
||||
{
|
||||
SendPreview = "Sincronizza prima di inviare.";
|
||||
SendPreview = Loc.Tr("msg.send.sync.first");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (_lastTransactions is null)
|
||||
{
|
||||
SendPreview = "Connettiti al server e sincronizza prima di inviare.";
|
||||
SendPreview = Loc.Tr("msg.send.connect.first");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,30 +63,30 @@ public partial class MainWindowViewModel
|
||||
long amount = 0;
|
||||
if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
|
||||
{
|
||||
SendPreview = "Importo non valido.";
|
||||
SendPreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
|
||||
{
|
||||
SendPreview = "Fee rate non valido.";
|
||||
SendPreview = Loc.Tr("msg.feerate.invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingSend = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
_doc.Cache.NextChangeIndex, SendAll);
|
||||
_doc.Cache.NextChangeIndex, _doc.Cache.TipHeight, SendAll);
|
||||
|
||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
|
||||
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
||||
HasPendingSend = _pendingSend.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
SendPreview = $"Errore: {ex.Message}";
|
||||
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
@@ -98,7 +99,7 @@ public partial class MainWindowViewModel
|
||||
try
|
||||
{
|
||||
var txid = await _client.BroadcastAsync(_pendingSend.ToHex());
|
||||
SendPreview = $"Trasmessa: {txid}";
|
||||
SendPreview = $"{Loc.Tr("msg.broadcasted")}: {txid}";
|
||||
SendTo = SendAmount = "";
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
@@ -106,7 +107,7 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendPreview = $"Errore broadcast: {ex.Message}";
|
||||
SendPreview = $"{Loc.Tr("msg.broadcast.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -55,6 +56,8 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private bool isSyncing;
|
||||
|
||||
private CancellationTokenSource _syncCts = new();
|
||||
|
||||
public ObservableCollection<KnownServer> KnownServers { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -165,11 +168,23 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
_syncCts = new CancellationTokenSource();
|
||||
var ct = _syncCts.Token;
|
||||
IsSyncing = true;
|
||||
StatusMessage = "";
|
||||
bool cancelled = false;
|
||||
try
|
||||
{
|
||||
var (host, port) = ParseServer();
|
||||
@@ -194,12 +209,13 @@ public partial class MainWindowViewModel
|
||||
Exception? lastError = null;
|
||||
foreach (var (h, p) in candidates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {h}:{p}…";
|
||||
ConnectionStatusShort = Loc.Tr("conn.connectingto") + "…";
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||
_client.NotificationReceived += OnServerNotification;
|
||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
@@ -226,6 +242,10 @@ public partial class MainWindowViewModel
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
@@ -253,50 +273,62 @@ public partial class MainWindowViewModel
|
||||
_doc.Cache?.NextChangeIndex ?? 0,
|
||||
net);
|
||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||
// Progressive verification (§7.4): the wallet becomes usable as soon as
|
||||
// transaction downloads finish, without waiting for every historical Merkle
|
||||
// proof — critical on mobile, where verifying thousands of proofs can take far
|
||||
// longer than the download itself. Never persisted to disk on its own (only the
|
||||
// final, fully-verified snapshot below is saved); coin selection still refuses
|
||||
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
|
||||
// early can't be exploited to spend a server-fabricated balance.
|
||||
_synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r));
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
_resyncRequested = false;
|
||||
var result = await _synchronizer.SyncOnceAsync();
|
||||
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
|
||||
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
|
||||
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
|
||||
var (result, rawHex, verifiedAt, blockHeaders) = await Task.Run(async () =>
|
||||
{
|
||||
var r = await _synchronizer.SyncOnceAsync(ct);
|
||||
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
|
||||
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders);
|
||||
}, ct);
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||
PalladiumNetworks.For(_account.Profile.Kind));
|
||||
_doc.Cache = new SyncCache
|
||||
{
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
Utxos = [.. result.Utxos],
|
||||
Addresses = [.. result.AddressRows],
|
||||
RawTxHex = rawHex,
|
||||
VerifiedAt = verifiedAt,
|
||||
BlockHeaders = blockHeaders,
|
||||
};
|
||||
WalletStore.Save(_doc, _walletPath!, _password);
|
||||
var cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
|
||||
FillDisplayFields(cache, result);
|
||||
_doc.Cache = cache;
|
||||
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
||||
ApplyCache(_doc.Cache);
|
||||
_syncFailed = false;
|
||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||
} while (_resyncRequested);
|
||||
} while (_resyncRequested && !ct.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Intentional cancellation due to server change request — not an error.
|
||||
cancelled = true;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
StatusMessage = "";
|
||||
}
|
||||
catch (CertificatePinMismatchException ex)
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
StatusMessage = ex.Message;
|
||||
StatusMessage = DescribeError(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsConnected = _client?.IsConnected == true;
|
||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
if (_account is not null)
|
||||
{
|
||||
_syncFailed = true;
|
||||
@@ -309,33 +341,127 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
IsSyncing = false;
|
||||
}
|
||||
|
||||
// If cancelled due to a server change, restart immediately with the new server.
|
||||
if (cancelled)
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
|
||||
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
|
||||
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
|
||||
/// resume without re-downloading) were never written.
|
||||
/// </summary>
|
||||
private void ApplyPartialResult(SyncResult result)
|
||||
{
|
||||
if (_doc is null)
|
||||
return;
|
||||
var previous = _doc.Cache;
|
||||
var cache = new SyncCache
|
||||
{
|
||||
RawTxHex = previous?.RawTxHex,
|
||||
VerifiedAt = previous?.VerifiedAt,
|
||||
BlockHeaders = previous?.BlockHeaders,
|
||||
};
|
||||
FillDisplayFields(cache, result);
|
||||
_doc.Cache = cache;
|
||||
_lastTransactions = result.Transactions;
|
||||
ApplyCache(cache);
|
||||
}
|
||||
|
||||
private static void FillDisplayFields(SyncCache cache, SyncResult result)
|
||||
{
|
||||
cache.TipHeight = result.TipHeight;
|
||||
cache.ConfirmedSats = result.ConfirmedSats;
|
||||
cache.UnconfirmedSats = result.UnconfirmedSats;
|
||||
cache.ImmatureSats = result.ImmatureSats;
|
||||
cache.PendingVerificationSats = result.PendingVerificationSats;
|
||||
cache.SpendableSats = result.SpendableSats;
|
||||
cache.NextReceiveIndex = result.NextReceiveIndex;
|
||||
cache.NextChangeIndex = result.NextChangeIndex;
|
||||
cache.History = [.. result.History];
|
||||
cache.Utxos = [.. result.Utxos];
|
||||
cache.Addresses = [.. result.AddressRows];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
||||
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||
/// just to query its peer list, without touching the wallet's connection state.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscoverServers()
|
||||
{
|
||||
if (_client is 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 = $"{Loc.Tr("msg.peer.discovery.error")}: " +
|
||||
(lastError is null ? Loc.Tr("conn.none") : DescribeError(lastError));
|
||||
return;
|
||||
}
|
||||
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;
|
||||
RefreshServers();
|
||||
SelectedKnownServer = KnownServers.FirstOrDefault(s => s.Host == selected?.Host)
|
||||
?? KnownServers.FirstOrDefault();
|
||||
StatusMessage = added > 0
|
||||
? $"Trovati {added} nuovi server dai peer (totale {KnownServers.Count})."
|
||||
: $"Nessun nuovo server annunciato (totale {KnownServers.Count}).";
|
||||
? string.Format(Loc.Tr("msg.peer.discovery.found"), added, KnownServers.Count)
|
||||
: string.Format(Loc.Tr("msg.peer.discovery.none"), KnownServers.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore nella scoperta peer: {ex.Message}";
|
||||
StatusMessage = $"{Loc.Tr("msg.peer.discovery.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates known Core exception types to the current UI language; falls back to the
|
||||
/// exception's own message (Core has no localization, so it is English by default).
|
||||
/// </summary>
|
||||
private static string DescribeError(Exception ex) => ex switch
|
||||
{
|
||||
CertificatePinMismatchException cert =>
|
||||
string.Format(Loc.Tr("msg.cert.mismatch"), $"{cert.Host}:{cert.Port}"),
|
||||
_ => ex.Message,
|
||||
};
|
||||
|
||||
private void OnServerNotification(string method, System.Text.Json.JsonElement payload)
|
||||
{
|
||||
if (method is "blockchain.scripthash.subscribe" or "blockchain.headers.subscribe")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -416,7 +416,7 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@ public partial class MainWindowViewModel
|
||||
catch (Exception ex)
|
||||
{
|
||||
newLock.Dispose();
|
||||
StatusMessage = $"Errore: {ex.Message}";
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>Transaction history row for the view.</summary>
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
|
||||
|
||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||
public sealed record AddressRow(
|
||||
@@ -158,6 +158,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_keepAliveTimer = new DispatcherTimer { Interval = System.TimeSpan.FromSeconds(20) };
|
||||
_keepAliveTimer.Tick += async (_, _) => await KeepAliveTickAsync();
|
||||
_keepAliveTimer.Start();
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||
|
||||
@@ -64,13 +64,17 @@ public sealed class TransactionDetailsViewModel
|
||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
||||
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
||||
i.IsCoinbase
|
||||
? i.CoinbaseTag is { Length: > 0 } tag
|
||||
? $"{loc["tx.coinbase.newcoins"]} — {tag}"
|
||||
: loc["tx.coinbase.newcoins"]
|
||||
: i.Address ?? "—",
|
||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||
i.IsMine)));
|
||||
|
||||
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
||||
$"#{o.Index}",
|
||||
o.Address ?? $"({o.ScriptType})",
|
||||
o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"),
|
||||
CoinAmount.FormatIn(o.AmountSats, unit),
|
||||
o.IsMine)));
|
||||
}
|
||||
@@ -98,7 +102,8 @@ public sealed class TransactionDetailsViewModel
|
||||
{
|
||||
if (d.Confirmations <= 0)
|
||||
return loc["tx.status.mempool"];
|
||||
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
|
||||
}
|
||||
|
||||
private string Signed(long sats)
|
||||
|
||||
@@ -314,6 +314,12 @@
|
||||
<TextBlock Text="{Binding UnconfirmedText}" Foreground="#FCD34D"
|
||||
TextWrapping="Wrap"
|
||||
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 VerifyingText}" Foreground="#FCD34D"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding VerifyingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
||||
Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
@@ -370,13 +376,16 @@
|
||||
<DataTemplate x:DataType="vm:HistoryRow">
|
||||
<Panel Cursor="Hand">
|
||||
<!-- Desktop: 3 fixed columns -->
|
||||
<Grid ColumnDefinitions="90,160,*"
|
||||
<Grid ColumnDefinitions="90,160,*,Auto"
|
||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||
FontFamily="monospace" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
|
||||
IsVisible="{Binding !Verified}"/>
|
||||
</Grid>
|
||||
<!-- Mobile: vertical card -->
|
||||
<StackPanel Spacing="2"
|
||||
@@ -386,6 +395,9 @@
|
||||
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
||||
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||
Foreground="#FCD34D" FontSize="11"
|
||||
IsVisible="{Binding !Verified}"/>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
@@ -1233,14 +1245,19 @@
|
||||
<!-- Actions — Desktop: in a row -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding IsDesktop}">
|
||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
|
||||
Command="{Binding ConnectAndSyncCommand}"/>
|
||||
<Button Content="{Binding Loc[wallet.discover]}"
|
||||
Command="{Binding DiscoverServersCommand}"/>
|
||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||
Command="{Binding ResetCertificatesCommand}"/>
|
||||
</StackPanel>
|
||||
<!-- Actions — Mobile: stacked -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsMobile}">
|
||||
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
|
||||
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"
|
||||
Command="{Binding ConnectAndSyncCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Content="{Binding Loc[wallet.discover]}"
|
||||
Command="{Binding DiscoverServersCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Content="{Binding Loc[wallet.resetcert]}"
|
||||
Command="{Binding ResetCertificatesCommand}"
|
||||
@@ -1555,9 +1572,14 @@
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
|
||||
<Button Content="{Binding Loc[help.bug.report]}"
|
||||
Click="OnOpenBugReportClick"
|
||||
HorizontalAlignment="Left"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="{Binding Loc[help.bug.report]}"
|
||||
Click="OnOpenBugReportClick"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Button Content="{Binding Loc[help.user.guide]}"
|
||||
Click="OnOpenUserGuideClick"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
<Border BorderBrush="{DynamicResource BorderSubtleBrush}"
|
||||
BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
||||
<StackPanel Spacing="4">
|
||||
@@ -1630,5 +1652,34 @@
|
||||
</StackPanel>
|
||||
</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>
|
||||
</UserControl>
|
||||
|
||||
@@ -129,16 +129,38 @@ public partial class MainView : UserControl
|
||||
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)
|
||||
{
|
||||
// TODO: replace with the final GitHub URL once the repo is on GitHub
|
||||
// e.g. https://github.com/davide3011/PalladiumWallet/issues/new?assignees=&labels=bug&template=bug_report.yml
|
||||
const string issueUrl = "https://santantonio.sytes.net/davide/PalladiumWallet/issues/new";
|
||||
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||
}
|
||||
|
||||
private async void OnOpenUserGuideClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
const string userGuideUrl = "https://github.com/davide3011/PalladiumWallet/blob/main/USERGUIDE.md";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(userGuideUrl));
|
||||
}
|
||||
|
||||
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(vm.UpdateReleaseUrl));
|
||||
vm.IsUpdateAvailableOpen = false;
|
||||
}
|
||||
|
||||
// Esc (desktop) or Back (Android) closes the topmost open overlay.
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
@@ -151,6 +173,7 @@ public partial class MainView : UserControl
|
||||
if (vm.IsWalletInfoOpen) { vm.CloseWalletInfoCommand.Execute(null); e.Handled = true; return; }
|
||||
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsHelpOpen) { vm.IsHelpOpen = false; e.Handled = true; return; }
|
||||
if (vm.IsUpdateAvailableOpen) { vm.IsUpdateAvailableOpen = false; e.Handled = true; return; }
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
+56
-49
@@ -6,8 +6,8 @@ using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
// CLI del wallet (blueprint §13): i casi d'uso del Core esposti da riga di
|
||||
// comando, per scripting, test e confronto col wallet di riferimento.
|
||||
// Wallet CLI (blueprint §13): Core use cases exposed via command line,
|
||||
// for scripting, testing and comparison against the reference wallet.
|
||||
|
||||
try
|
||||
{
|
||||
@@ -29,7 +29,7 @@ try
|
||||
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
|
||||
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
|
||||
{
|
||||
Console.Error.WriteLine($"Errore: {ex.Message}");
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ static int Addresses(string words, string[] o)
|
||||
if (account is null)
|
||||
return 1;
|
||||
var count = int.TryParse(Opt(o, "--count"), out var n) ? n : 5;
|
||||
Console.WriteLine($"rete: {account.Profile.NetName} tipo: {account.Kind} path: m/{account.AccountPath}");
|
||||
Console.WriteLine($"network: {account.Profile.NetName} kind: {account.Kind} path: m/{account.AccountPath}");
|
||||
Console.WriteLine($"account: {account.ToSlip132()}");
|
||||
for (var i = 0; i < count; i++)
|
||||
Console.WriteLine($" receiving/{i}: {account.GetReceiveAddress(i)}");
|
||||
@@ -58,7 +58,7 @@ static int Addresses(string words, string[] o)
|
||||
static int Create(string[] o)
|
||||
{
|
||||
var mnemonic = Bip39.Generate(Opt(o, "--words") == "24" ? MnemonicLength.TwentyFour : MnemonicLength.Twelve);
|
||||
Console.WriteLine("Nuova mnemonica (scrivila su carta, NON viene rimostrata):");
|
||||
Console.WriteLine("New mnemonic (write it on paper, it is NOT shown again):");
|
||||
Console.WriteLine($" {mnemonic}");
|
||||
return SaveWallet(mnemonic.ToString(), o);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ static int Restore(string words, string[] o)
|
||||
{
|
||||
if (!Bip39.TryParse(words, out _))
|
||||
{
|
||||
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
|
||||
Console.Error.WriteLine("Invalid mnemonic (wrong words or checksum).");
|
||||
return 1;
|
||||
}
|
||||
return SaveWallet(words.Trim(), o);
|
||||
@@ -78,7 +78,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
||||
var profile = Profile(o);
|
||||
if (!Slip132.TryDecodePublic(xpubText, profile, out var xpub, out var kind))
|
||||
{
|
||||
Console.Error.WriteLine("Chiave estesa non riconosciuta per questa rete.");
|
||||
Console.Error.WriteLine("Extended key not recognised for this network.");
|
||||
return 1;
|
||||
}
|
||||
var account = HdAccount.FromAccountXpub(xpub!, kind, profile);
|
||||
@@ -91,7 +91,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
||||
};
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
Console.WriteLine($"Wallet watch-only salvato in {path}");
|
||||
Console.WriteLine($"Watch-only wallet saved to {path}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -99,26 +99,28 @@ static int Info(string[] o)
|
||||
{
|
||||
var (doc, account, path) = OpenWallet(o);
|
||||
Console.WriteLine($"file: {path}");
|
||||
Console.WriteLine($"rete: {doc.Network} tipo: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
|
||||
Console.WriteLine($"network: {doc.Network} kind: {doc.ScriptKind} watch-only: {doc.IsWatchOnly}");
|
||||
Console.WriteLine($"path: m/{doc.AccountPath}");
|
||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||
if (doc.Cache is { } cache)
|
||||
{
|
||||
Console.WriteLine($"saldo: {CoinAmount.Format(cache.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} in attesa (non spendibile)." : ""));
|
||||
Console.WriteLine($"sync: altezza {cache.TipHeight}, {cache.History.Count} transazioni");
|
||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||
Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
|
||||
+ (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
||||
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
||||
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"ricezione: {account.GetReceiveAddress(0)} (mai sincronizzato)");
|
||||
Console.WriteLine($"receive: {account.GetReceiveAddress(0)} (never synchronized)");
|
||||
}
|
||||
|
||||
if (o.Contains("--addresses") && doc.Cache is { } c)
|
||||
{
|
||||
Console.WriteLine("indirizzi:");
|
||||
Console.WriteLine("addresses:");
|
||||
foreach (var a in c.Addresses)
|
||||
Console.WriteLine($" {(a.IsChange ? "change " : "ricev. ")}{a.Index,3} {a.Address} " +
|
||||
Console.WriteLine($" {(a.IsChange ? "change " : "receive")}{a.Index,3} {a.Address} " +
|
||||
$"{CoinAmount.Format(a.BalanceSats),18} ({a.TxCount} tx)");
|
||||
}
|
||||
return 0;
|
||||
@@ -147,6 +149,9 @@ static async Task<int> Sync(string[] o)
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
ImmatureSats = result.ImmatureSats,
|
||||
PendingVerificationSats = result.PendingVerificationSats,
|
||||
SpendableSats = result.SpendableSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
@@ -158,13 +163,15 @@ static async Task<int> Sync(string[] o)
|
||||
};
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
|
||||
Console.WriteLine($"Saldo: {CoinAmount.Format(result.ConfirmedSats, account.Profile.CoinUnit)} confermato"
|
||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} in attesa di conferma (non spendibile)" : ""));
|
||||
Console.WriteLine($"Storico ({result.History.Count}):");
|
||||
Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
||||
+ (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
||||
Console.WriteLine($"History ({result.History.Count}):");
|
||||
foreach (var tx in result.History)
|
||||
Console.WriteLine($" {(tx.Height > 0 ? tx.Height.ToString() : "mempool"),7} " +
|
||||
$"{(tx.DeltaSats >= 0 ? "+" : "")}{CoinAmount.Format(tx.DeltaSats)} {tx.Txid}" +
|
||||
(tx.Verified ? "" : " (non verificata)"));
|
||||
(tx.Verified ? "" : " (unverified)"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -173,18 +180,18 @@ static async Task<int> Send(string[] o)
|
||||
var (doc, account, path) = OpenWallet(o);
|
||||
if (doc.Cache is null)
|
||||
{
|
||||
Console.Error.WriteLine("Wallet mai sincronizzato: esegui prima 'sync'.");
|
||||
Console.Error.WriteLine("Wallet never synchronized: run 'sync' first.");
|
||||
return 1;
|
||||
}
|
||||
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to mancante.");
|
||||
var to = Opt(o, "--to") ?? throw new WalletSpendException("--to missing.");
|
||||
var destination = BitcoinAddress.Create(to, PalladiumNetworks.For(account.Profile.Kind));
|
||||
var sendAll = o.Contains("--all");
|
||||
long amount = 0;
|
||||
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
|
||||
throw new WalletSpendException("--amount mancante o non valido (oppure usa --all).");
|
||||
throw new WalletSpendException("--amount missing or invalid (or use --all).");
|
||||
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
|
||||
|
||||
// Tx di provenienza degli UTXO: servono per importi e firma.
|
||||
// Source transactions of the UTXOs: needed for amounts and signing.
|
||||
await using var client = await Connect(o, account.Profile);
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var transactions = new Dictionary<string, Transaction>();
|
||||
@@ -193,7 +200,7 @@ static async Task<int> Send(string[] o)
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
doc.Cache.Utxos, transactions, destination, amount, feeRate,
|
||||
doc.Cache.NextChangeIndex, sendAll);
|
||||
doc.Cache.NextChangeIndex, doc.Cache.TipHeight, sendAll);
|
||||
|
||||
Console.WriteLine($"txid: {built.Txid}");
|
||||
Console.WriteLine($"fee: {CoinAmount.Format(built.Fee.Satoshi, account.Profile.CoinUnit)} " +
|
||||
@@ -201,20 +208,20 @@ static async Task<int> Send(string[] o)
|
||||
|
||||
if (!built.Signed)
|
||||
{
|
||||
Console.WriteLine("Wallet watch-only: PSBT da firmare offline (§6.5):");
|
||||
Console.WriteLine("Watch-only wallet: PSBT to sign offline (§6.5):");
|
||||
Console.WriteLine(built.Psbt.ToBase64());
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!o.Contains("--broadcast"))
|
||||
{
|
||||
Console.WriteLine("Transazione firmata (NON trasmessa, aggiungi --broadcast):");
|
||||
Console.WriteLine("Signed transaction (NOT broadcast, add --broadcast):");
|
||||
Console.WriteLine(built.ToHex());
|
||||
return 0;
|
||||
}
|
||||
|
||||
var txid2 = await client.BroadcastAsync(built.ToHex());
|
||||
Console.WriteLine($"Trasmessa: {txid2}");
|
||||
Console.WriteLine($"Broadcast: {txid2}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -222,7 +229,7 @@ static int ResetCerts(string[] o)
|
||||
{
|
||||
var profile = Profile(o);
|
||||
new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind)).ResetAll();
|
||||
Console.WriteLine($"Certificati SSL salvati per {profile.NetName} azzerati.");
|
||||
Console.WriteLine($"Saved SSL certificates for {profile.NetName} cleared.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -235,16 +242,16 @@ static async Task<int> Servers(string[] o)
|
||||
{
|
||||
await using var client = await Connect(o, profile);
|
||||
var added = await registry.DiscoverAsync(client);
|
||||
Console.WriteLine($"Scoperti {added} nuovi server dai peer.");
|
||||
Console.WriteLine($"Discovered {added} new servers from peers.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Server noti ({profile.NetName}):");
|
||||
Console.WriteLine($"Known servers ({profile.NetName}):");
|
||||
foreach (var server in registry.All)
|
||||
Console.WriteLine($" {server}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ----- helper comuni -----
|
||||
// ----- shared helpers -----
|
||||
|
||||
static ChainProfile Profile(string[] o) => Opt(o, "--net") switch
|
||||
{
|
||||
@@ -267,7 +274,7 @@ static HdAccount? AccountFromWords(string words, string[] o)
|
||||
{
|
||||
if (!Bip39.TryParse(words, out var mnemonic))
|
||||
{
|
||||
Console.Error.WriteLine("Mnemonica non valida (parole o checksum errati).");
|
||||
Console.Error.WriteLine("Invalid mnemonic (wrong words or checksum).");
|
||||
return null;
|
||||
}
|
||||
var profile = Profile(o);
|
||||
@@ -286,11 +293,11 @@ static int SaveWallet(string words, string[] o)
|
||||
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
|
||||
var password = Opt(o, "--password");
|
||||
if (string.IsNullOrEmpty(password))
|
||||
Console.WriteLine("ATTENZIONE: nessuna --password, il seed sarà in chiaro su disco (§17).");
|
||||
Console.WriteLine("WARNING: no --password, the seed will be stored in plaintext on disk (§17).");
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, password);
|
||||
Console.WriteLine($"Wallet salvato in {path}");
|
||||
Console.WriteLine($"Primo indirizzo: {account.GetReceiveAddress(0)}");
|
||||
Console.WriteLine($"Wallet saved to {path}");
|
||||
Console.WriteLine($"First address: {account.GetReceiveAddress(0)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -298,7 +305,7 @@ static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
||||
{
|
||||
var path = WalletPath(o, Profile(o));
|
||||
if (!WalletStore.Exists(path))
|
||||
throw new WalletSpendException($"Nessun wallet in {path}: usa 'create' o 'restore'.");
|
||||
throw new WalletSpendException($"No wallet at {path}: use 'create' or 'restore'.");
|
||||
var doc = WalletStore.Load(path, Opt(o, "--password"));
|
||||
return (doc, WalletLoader.ToAccount(doc), path);
|
||||
}
|
||||
@@ -317,15 +324,15 @@ static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Senza --server si usa il primo server noto (bootstrap §3 o scoperto §9).
|
||||
// Without --server, use the first known server (bootstrap §3 or discovered §9).
|
||||
var known = new ServerRegistry(profile, AppPaths.ServersPath(profile.Kind)).Default
|
||||
?? throw new WalletSpendException("Nessun server noto per questa rete: usa --server host:porta.");
|
||||
?? throw new WalletSpendException("No known server for this network: use --server host:port.");
|
||||
host = known.Host;
|
||||
port = known.PortFor(useSsl);
|
||||
}
|
||||
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
|
||||
Console.WriteLine($"Connessione a {host}:{port}{(useSsl ? " (TLS)" : "")}…");
|
||||
Console.WriteLine($"Connecting to {host}:{port}{(useSsl ? " (TLS)" : "")}…");
|
||||
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
|
||||
}
|
||||
|
||||
@@ -343,20 +350,20 @@ static int Usage()
|
||||
Wallet:
|
||||
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
|
||||
[--passphrase W] [--password P] [--file PATH]
|
||||
restore "<mnemonica>" [stesse opzioni di create] [--path m/...]
|
||||
restore-xpub <xpub slip132> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||
restore "<mnemonic>" [same options as create] [--path m/...]
|
||||
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||
info [--net ...] [--password P] [--file PATH]
|
||||
|
||||
Rete (server di indicizzazione; senza --server usa il primo server noto):
|
||||
sync [--server host[:porta]] [--ssl] [--net ...] [--password P] [--file PATH]
|
||||
send --to INDIRIZZO (--amount X | --all) [--feerate sat/vB]
|
||||
[--server host[:porta]] [--ssl] [--broadcast] [...]
|
||||
servers [--discover] [--server host[:porta]] [--ssl] [--net ...]
|
||||
Network (indexing server; without --server the first known server is used):
|
||||
sync [--server host[:port]] [--ssl] [--net ...] [--password P] [--file PATH]
|
||||
send --to ADDRESS (--amount X | --all) [--feerate sat/vB]
|
||||
[--server host[:port]] [--ssl] [--broadcast] [...]
|
||||
servers [--discover] [--server host[:port]] [--ssl] [--net ...]
|
||||
reset-certs [--net ...]
|
||||
|
||||
Strumenti:
|
||||
Tools:
|
||||
newseed [--words 12|24]
|
||||
addresses "<mnemonica>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
|
||||
addresses "<mnemonic>" [--kind ...] [--net ...] [--count N] [--passphrase W] [--path m/...]
|
||||
""");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,12 @@ public sealed record ChainProfile
|
||||
/// <summary>Target block time in seconds (LWMA v2: 120s).</summary>
|
||||
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>
|
||||
public required IReadOnlyDictionary<ScriptKind, ExtKeyHeaders> ExtKeyHeaders { get; init; }
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ public static class ChainProfiles
|
||||
ExplorerUrl = "https://explorer.palladium-coin.com/",
|
||||
SkipPowValidation = true,
|
||||
BlockTimeSeconds = 120,
|
||||
CoinbaseMaturity = 120,
|
||||
MinConfirmations = 6,
|
||||
ExtKeyHeaders = new Dictionary<ScriptKind, ExtKeyHeaders>
|
||||
{
|
||||
[ScriptKind.Legacy] = new(0x0488ade4, 0x0488b21e), // xprv / xpub
|
||||
@@ -45,14 +47,47 @@ public static class ChainProfiles
|
||||
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||
],
|
||||
// TODO: populate with the chain's real [hash, bits] (§7.3) before release.
|
||||
Checkpoints = [],
|
||||
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||
// RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
|
||||
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
|
||||
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||
// bounds how far back a forged header chain must be walked to be caught, since
|
||||
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
||||
Checkpoints =
|
||||
[
|
||||
new Checkpoint(20000, "00000000000018ffaa9a332cfb418b5c8c3f988cf26598e378bbea9e93d26f74", 0x1b00ffff),
|
||||
new Checkpoint(40000, "000000000000011ad2ae53d9647a2130d2b1c41b18d200455211f1fef2a4ffb7", 0x1a013d28),
|
||||
new Checkpoint(60000, "000000000000034288c737d62011855fb598cd5c6ebb5c46c08acdec17620c8b", 0x1a0390b5),
|
||||
new Checkpoint(80000, "00000000000003662534639c7eb1dd7166efd85c308be19b367467c015279361", 0x1a0577b1),
|
||||
new Checkpoint(100000, "0000000000000850eba93bbc491f085e2c79c0c30c497292858c72e90cae69a5", 0x1a2c39e4),
|
||||
new Checkpoint(120000, "0000000000004c421e06c84f08a947d994cb801b8ac7cade12d616209d851d43", 0x1a510836),
|
||||
new Checkpoint(140000, "00000000000075b1a095a5969a2ca729b646ab0e2b9a9bd72aa603b3b889c398", 0x1b00a257),
|
||||
new Checkpoint(160000, "000000000000028c8ba89e695f80fc78491bcf7e583fc7cd868e0a9c2973dfbe", 0x1a0ed8bb),
|
||||
new Checkpoint(180000, "00000000000003632ffdcf60ce3892f44613dbbfe761b14522e91a1e650c092f", 0x1a064544),
|
||||
new Checkpoint(200000, "000000000000221a9e16556453fc86308b260d95d80c14bafaf053a09374e7eb", 0x1a22c142),
|
||||
new Checkpoint(220000, "0000000000001f63d259df5b9b20182dbaea92f2858fe836b895b2a33430c6dd", 0x1a3311af),
|
||||
new Checkpoint(240000, "0000000000004c8a80484a1d6ab8a08460ac688445ccafe5cbac8b11bc471f11", 0x1a764de8),
|
||||
new Checkpoint(260000, "000000000000ab1b71140485359633ef991588613f520052ce87acb70a36b4de", 0x1b022691),
|
||||
new Checkpoint(280000, "00000000000678b07eda63cf01b099737ce832470d0e71769d157190f4d9ac9b", 0x1b118047),
|
||||
new Checkpoint(300000, "0000000000013acdf07a4fb988bbe9824c36eb421478a71c8196cf524dcba143", 0x1b01ddc1),
|
||||
new Checkpoint(320000, "000000000000079f2fb9866f1bb452ee5f47a35e0d494c6bc90331f582b07991", 0x1a0e8592),
|
||||
new Checkpoint(340000, "000000000000000e45f7fbcff239da7965e1bd58aea3a10aef2bc8afbca822be", 0x1a0328e2),
|
||||
new Checkpoint(360000, "000000000000016d60397423447eb42f8b4ba693fe16f1e64e9fdf58c94ca2a6", 0x1a020861),
|
||||
new Checkpoint(380000, "000000000000004ba0d45a0462501a12251947d27e52ec810edd69007b7acf90", 0x1a01568d),
|
||||
new Checkpoint(400000, "0000000000000010ac708514e8b837703233161099bf55400433cef32311f495", 0x1a01ce70),
|
||||
new Checkpoint(420000, "00000000000000351392487709e637bc7a9b0b2296a0e443f54e2af5b3f00e16", 0x1a01197e),
|
||||
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
||||
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
||||
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
||||
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
|
||||
],
|
||||
};
|
||||
|
||||
public static ChainProfile Testnet { get; } = Mainnet with
|
||||
{
|
||||
Kind = NetKind.Testnet,
|
||||
NetName = "testnet",
|
||||
MinConfirmations = 1,
|
||||
WifPrefix = 0xff,
|
||||
AddrP2pkh = 127,
|
||||
AddrP2sh = 115,
|
||||
@@ -75,6 +110,9 @@ public static class ChainProfiles
|
||||
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
||||
},
|
||||
BootstrapServers = [],
|
||||
// TODO: populate from a synced testnet palladiumd (§7.3) — left empty because
|
||||
// WalletSynchronizer already treats "no checkpoint at or below this height" as
|
||||
// a no-op, so an empty array is safe, just unanchored.
|
||||
Checkpoints = [],
|
||||
};
|
||||
|
||||
@@ -87,6 +125,7 @@ public static class ChainProfiles
|
||||
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
||||
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
||||
NodeP2pPort = 28444,
|
||||
// Regtest is regenerated locally on demand: hardcoded checkpoints make no sense here.
|
||||
Checkpoints = [],
|
||||
};
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ public static class PalladiumNetworks
|
||||
MajorityRejectBlockOutdated = 950,
|
||||
MajorityWindow = 1000,
|
||||
MinimumChainWork = uint256.Zero,
|
||||
CoinbaseMaturity = 100,
|
||||
CoinbaseMaturity = 120,
|
||||
SupportSegwit = true,
|
||||
SupportTaproot = true,
|
||||
ConsensusFactory = new ConsensusFactory(),
|
||||
|
||||
+31
-14
@@ -58,28 +58,45 @@ public static class Bip39
|
||||
// ideographic spaces — the text must not be altered further (§4.1).
|
||||
text = text.Trim();
|
||||
|
||||
IEnumerable<Wordlist> candidates = language is not null
|
||||
? [ToWordlist(language.Value)]
|
||||
: [Wordlist.AutoDetect(text)];
|
||||
|
||||
foreach (var wordlist in candidates)
|
||||
Wordlist wordlist;
|
||||
if (language is not null)
|
||||
{
|
||||
wordlist = ToWordlist(language.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = new Mnemonic(text, wordlist);
|
||||
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||
{
|
||||
mnemonic = parsed;
|
||||
return true;
|
||||
}
|
||||
wordlist = Wordlist.AutoDetect(text);
|
||||
}
|
||||
catch (FormatException)
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Words outside the wordlist or invalid count — try the next candidate.
|
||||
// AutoDetect lands on NBitcoin's internal "Unknown" language for
|
||||
// text resembling no wordlist and throws instead of returning a
|
||||
// default (found by fuzzing) — not a valid mnemonic, plain and simple.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = new Mnemonic(text, wordlist);
|
||||
// The constructor does NOT verify the checksum — explicit check is mandatory.
|
||||
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
|
||||
{
|
||||
mnemonic = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Words outside the wordlist or invalid count.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Defensive: same NBitcoin failure mode surfacing from the constructor.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Load() =>
|
||||
File.Exists(filePath)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
|
||||
: [];
|
||||
private Dictionary<string, string> Load()
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A corrupt pin file must not make every SSL connection fail forever:
|
||||
// fall back to first-contact TOFU (same trust level as the bootstrap).
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(Dictionary<string, string> pins)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
|
||||
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||
|
||||
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
|
||||
public readonly record struct HeaderRangeResponse(int Count, string Hex);
|
||||
|
||||
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
||||
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||
|
||||
@@ -82,6 +85,23 @@ public static class ElectrumApi
|
||||
return r.GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
|
||||
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
|
||||
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
|
||||
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
|
||||
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
|
||||
/// callers must loop until the full range is covered.
|
||||
/// </summary>
|
||||
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
|
||||
int startHeight, int count, CancellationToken ct = default)
|
||||
{
|
||||
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
|
||||
return new HeaderRangeResponse(
|
||||
r.GetProperty("count").GetInt32(),
|
||||
r.GetProperty("hex").GetString()!);
|
||||
}
|
||||
|
||||
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -124,17 +144,22 @@ public static class ElectrumApi
|
||||
/// Parses the server.peers.subscribe response: a list of
|
||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
||||
/// "t"/"s" without a number = network default port (resolved by the caller).
|
||||
/// The response is untrusted server data: any unexpected shape (non-array,
|
||||
/// wrong element types) is skipped, never thrown on.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
||||
{
|
||||
var peers = new List<PeerInfo>();
|
||||
if (response.ValueKind != JsonValueKind.Array)
|
||||
return peers;
|
||||
foreach (var entry in response.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3)
|
||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3
|
||||
|| entry[2].ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
var host = entry[1].GetString();
|
||||
var host = AsString(entry[1]);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
host = entry[0].GetString();
|
||||
host = AsString(entry[0]);
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
continue;
|
||||
|
||||
@@ -142,7 +167,7 @@ public static class ElectrumApi
|
||||
string? version = null;
|
||||
foreach (var feature in entry[2].EnumerateArray())
|
||||
{
|
||||
var f = feature.GetString();
|
||||
var f = AsString(feature);
|
||||
if (string.IsNullOrEmpty(f))
|
||||
continue;
|
||||
switch (f[0])
|
||||
@@ -157,4 +182,21 @@ public static class ElectrumApi
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return element.GetString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Syntactically valid JSON string that cannot be transcoded to UTF-16
|
||||
// (invalid UTF-8 bytes / lone surrogates) — untrusted server data,
|
||||
// treat as absent (found by fuzzing).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,5 +284,9 @@ public sealed class ElectrumServerException(string error) : Exception(error);
|
||||
/// It is unlocked with an explicit reset of the certificates.
|
||||
/// </summary>
|
||||
public sealed class CertificatePinMismatchException(string host, int port) : Exception(
|
||||
$"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " +
|
||||
"Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.");
|
||||
$"The TLS certificate of {host}:{port} has changed from the one saved. " +
|
||||
"If the server renewed its certificate, reset the SSL certificates.")
|
||||
{
|
||||
public string Host { get; } = host;
|
||||
public int Port { get; } = port;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PalladiumWallet.Core.Net;
|
||||
|
||||
/// <summary>A GitHub release newer than the running app.</summary>
|
||||
public sealed record LatestRelease(string Tag, string HtmlUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Checks the latest GitHub release for the project against the running app version.
|
||||
/// Best-effort only: any network/parse failure or an up-to-date app both resolve to null,
|
||||
/// so callers never need to distinguish "no update" from "couldn't check".
|
||||
/// </summary>
|
||||
public static class UpdateChecker
|
||||
{
|
||||
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
|
||||
|
||||
private sealed class GitHubReleaseResponse
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
|
||||
[JsonPropertyName("html_url")]
|
||||
public string? HtmlUrl { get; set; }
|
||||
}
|
||||
|
||||
public static Task<LatestRelease?> CheckAsync(string currentVersion, CancellationToken ct = default) =>
|
||||
CheckAsync(currentVersion, handler: null, ct);
|
||||
|
||||
/// <summary>Test seam: <paramref name="handler"/> replaces the real HTTP transport.</summary>
|
||||
internal static async Task<LatestRelease?> CheckAsync(string currentVersion,
|
||||
HttpMessageHandler? handler, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = handler is null
|
||||
? new HttpClient()
|
||||
: new HttpClient(handler, disposeHandler: false);
|
||||
http.Timeout = TimeSpan.FromSeconds(10);
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("PalladiumWallet");
|
||||
using var response = await http.GetAsync(ReleasesApiUrl, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var release = await response.Content
|
||||
.ReadFromJsonAsync<GitHubReleaseResponse>(ct)
|
||||
.ConfigureAwait(false);
|
||||
if (release?.TagName is not { Length: > 0 } tag) return null;
|
||||
|
||||
if (!TryParse(tag, out var remote) || !TryParse(currentVersion, out var local))
|
||||
return null;
|
||||
if (remote <= local) return null;
|
||||
|
||||
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
||||
internal static bool TryParse(string raw, out Version version)
|
||||
{
|
||||
var s = raw.Trim();
|
||||
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
||||
var dash = s.IndexOf('-');
|
||||
if (dash >= 0) s = s[..dash];
|
||||
return Version.TryParse(s, out version!);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,7 @@ using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
@@ -21,6 +22,30 @@ public sealed class SyncResult
|
||||
public required int TipHeight { get; init; }
|
||||
public required long ConfirmedSats { get; init; }
|
||||
public required long UnconfirmedSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed but not yet spendable: coinbase outputs below maturity or regular
|
||||
/// outputs below <see cref="Chain.ChainProfile.MinConfirmations"/>. Subset of
|
||||
/// <see cref="ConfirmedSats"/>.
|
||||
/// </summary>
|
||||
public required long ImmatureSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
|
||||
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
|
||||
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
|
||||
/// (an immature coinbase can also be unverified), so never subtract both from
|
||||
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
|
||||
/// </summary>
|
||||
public required long PendingVerificationSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
|
||||
/// now — the true spendable balance. Computed directly from the same gate coin selection
|
||||
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
|
||||
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
|
||||
/// </summary>
|
||||
public required long SpendableSats { get; init; }
|
||||
public required int NextReceiveIndex { get; init; }
|
||||
public required int NextChangeIndex { get; init; }
|
||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||
@@ -38,10 +63,37 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
||||
public event Action<string>? Progress;
|
||||
|
||||
/// <summary>
|
||||
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
|
||||
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
|
||||
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
|
||||
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
|
||||
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
|
||||
/// only completes once verification is fully done, for callers that need the final state.
|
||||
/// </summary>
|
||||
public event Action<SyncResult>? PartialResult;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||
|
||||
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
||||
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||
|
||||
// checkpoint height -> highest height already proven to hash-chain back to it
|
||||
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
||||
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||
|
||||
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
|
||||
// being verified) would otherwise race on overlapping ranges and issue duplicate range
|
||||
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
|
||||
// serializing them costs nothing that matters.
|
||||
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
|
||||
|
||||
// Max headers requested per blockchain.block.headers call. The server may return fewer
|
||||
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
|
||||
private const int HeaderBatchSize = 2016;
|
||||
|
||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||
// discovery — already-used addresses are fetched in a single burst instead of
|
||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
||||
@@ -150,7 +202,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||
txHeights[item.TxHash] = item.Height;
|
||||
|
||||
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
||||
// 4. Download missing transactions — needed to compute amounts/UTXOs locally.
|
||||
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
|
||||
// wallet with thousands of transactions, downloads finish in seconds while proofs can
|
||||
// take much longer over a high-latency link, and the wallet has everything it needs to
|
||||
// show balance/history the moment downloads are done. Verification then continues in
|
||||
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
|
||||
// while coin selection stays locked out of any UTXO until its own proof is checked
|
||||
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
|
||||
// cannot get a fabricated balance spent just because it was shown early.
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||
var toVerify = txHeights
|
||||
@@ -159,45 +219,81 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
.ToList();
|
||||
|
||||
if (missing.Count > 0 || toVerify.Count > 0)
|
||||
{
|
||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
||||
var dlDone = 0;
|
||||
var merkDone = 0;
|
||||
|
||||
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
if (n % 50 == 0 || n == missing.Count)
|
||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
|
||||
}, ct));
|
||||
var dlDone = 0;
|
||||
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
if (n % 50 == 0 || n == missing.Count)
|
||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
|
||||
}, ct)));
|
||||
|
||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
var headerTask = _headerFetches.GetOrAdd(height,
|
||||
h => client.GetBlockHeaderAsync(h, ct));
|
||||
var proof = await proofTask;
|
||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
if (n % 50 == 0 || n == toVerify.Count)
|
||||
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||
}, ct));
|
||||
SyncResult BuildSnapshot() =>
|
||||
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
|
||||
|
||||
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
||||
foreach (var (txid, height) in toVerify)
|
||||
_verifiedAtHeight[txid] = height;
|
||||
}
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
|
||||
var merkDone = 0;
|
||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
// Anchor first: on a checkpointed height this fills _headerFetches[height]
|
||||
// via the batched range fetch (§7.3), so the header lookup below is a cache
|
||||
// hit instead of a second individual blockchain.block.header RPC per tx —
|
||||
// halves round-trips for this stage on mainnet, where it matters most on
|
||||
// high-latency mobile links. Falls back to an individual fetch when no
|
||||
// checkpoint covers this height (testnet/regtest today).
|
||||
await AnchorToCheckpointAsync(height, ct);
|
||||
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
|
||||
var header = BlockHeaderInfo.Parse(headerHex);
|
||||
var proof = await proofTask;
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||
_verifiedAtHeight[txid] = height;
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
if (n % 50 == 0 || n == toVerify.Count)
|
||||
Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||
if (n % PartialResultBatchSize == 0)
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
}, ct)).ToList();
|
||||
|
||||
if (merkTasks.Count > 0)
|
||||
await Task.WhenAll(merkTasks);
|
||||
|
||||
return BuildSnapshot();
|
||||
}
|
||||
|
||||
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
|
||||
// every single verified proof would make the background verification phase itself O(n²) for
|
||||
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
|
||||
// visibly without that cost.
|
||||
private const int PartialResultBatchSize = 200;
|
||||
|
||||
private bool IsTxVerified(string txid, int height) =>
|
||||
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
|
||||
|
||||
/// <summary>
|
||||
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
|
||||
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
|
||||
/// transaction downloads finish (proofs still pending), and again as verification progresses,
|
||||
/// each time reflecting whichever transactions have been proof-checked so far.
|
||||
/// </summary>
|
||||
private SyncResult BuildResult(
|
||||
int tipHeight,
|
||||
List<TrackedAddress> tracked,
|
||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
|
||||
Dictionary<string, int> txHeights,
|
||||
int nextReceive,
|
||||
int nextChange)
|
||||
{
|
||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||
|
||||
// 6. Local UTXO reconstruction.
|
||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||
@@ -209,6 +305,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
var utxos = new List<CachedUtxo>();
|
||||
foreach (var (txid, tx) in transactions)
|
||||
{
|
||||
var height = txHeights[txid];
|
||||
var verifiedTx = IsTxVerified(txid, height);
|
||||
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
||||
{
|
||||
var output = tx.Outputs[vout];
|
||||
@@ -224,7 +322,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
Address = addr.Address.ToString(),
|
||||
IsChange = addr.IsChange,
|
||||
AddressIndex = addr.Index,
|
||||
Height = txHeights[txid],
|
||||
Height = height,
|
||||
IsCoinbase = tx.IsCoinBase,
|
||||
Verified = verifiedTx,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -233,6 +333,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
var history = new List<CachedTx>();
|
||||
foreach (var (txid, tx) in transactions)
|
||||
{
|
||||
var height = txHeights[txid];
|
||||
var received = tx.Outputs
|
||||
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
||||
.Sum(o => o.Value.Satoshi);
|
||||
@@ -243,9 +344,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
history.Add(new CachedTx
|
||||
{
|
||||
Txid = txid,
|
||||
Height = txHeights[txid],
|
||||
Height = height,
|
||||
DeltaSats = received - sentSats,
|
||||
Verified = verified[txid],
|
||||
Verified = IsTxVerified(txid, height),
|
||||
});
|
||||
}
|
||||
history.Sort((a, b) =>
|
||||
@@ -272,9 +373,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
|
||||
return new SyncResult
|
||||
{
|
||||
TipHeight = tip.Height,
|
||||
TipHeight = tipHeight,
|
||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||
ImmatureSats = utxos.Where(u =>
|
||||
u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile))
|
||||
.Sum(u => u.ValueSats),
|
||||
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
|
||||
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
|
||||
NextReceiveIndex = nextReceive,
|
||||
NextChangeIndex = nextChange,
|
||||
History = history,
|
||||
@@ -285,6 +391,82 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anchors a header at <paramref name="height"/> to the nearest hardcoded checkpoint at
|
||||
/// or below it (§7.3): downloads every intervening header and verifies an unbroken
|
||||
/// prev-hash chain from the checkpoint's known-good hash up to this height. Without this,
|
||||
/// the Merkle proof above only proves a transaction belongs to *some* header the server
|
||||
/// handed over — on this LWMA chain the wallet cannot recompute PoW to catch a forged one,
|
||||
/// so the checkpoint is the only fixed point of truth. A no-op when the network profile has
|
||||
/// no checkpoint at or below <paramref name="height"/> (e.g. testnet/regtest today, or
|
||||
/// mainnet heights below the first checkpoint).
|
||||
/// </summary>
|
||||
private async Task AnchorToCheckpointAsync(int height, CancellationToken ct)
|
||||
{
|
||||
var profile = account.Profile;
|
||||
Checkpoint? checkpoint = null;
|
||||
foreach (var c in profile.Checkpoints)
|
||||
if (c.Height <= height && (checkpoint is not { } best || c.Height > best.Height))
|
||||
checkpoint = c;
|
||||
if (checkpoint is not { } cp)
|
||||
return;
|
||||
|
||||
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
||||
return;
|
||||
|
||||
await FetchHeaderRangeAsync(cp.Height, height, ct);
|
||||
|
||||
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
|
||||
.ToArray();
|
||||
|
||||
if (!headers[0].MatchesCheckpoint(cp))
|
||||
throw new SpvVerificationException(
|
||||
$"Header at checkpoint height {cp.Height} does not match the hardcoded hash: server is not trustworthy.");
|
||||
|
||||
for (var i = 1; i < headers.Length; i++)
|
||||
if (!headers[i].IsValidChild(headers[i - 1].Hash, profile))
|
||||
throw new SpvVerificationException(
|
||||
$"Broken header chain at height {cp.Height + i}: server is not trustworthy.");
|
||||
|
||||
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
|
||||
/// is present in <see cref="_headerFetches"/>, downloading gaps with
|
||||
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
|
||||
/// </summary>
|
||||
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
|
||||
{
|
||||
await _headerRangeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var h = fromHeight;
|
||||
while (h <= toHeightInclusive)
|
||||
{
|
||||
if (_headerFetches.ContainsKey(h)) { h++; continue; }
|
||||
|
||||
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
|
||||
var range = await client.GetBlockHeadersAsync(h, requested, ct);
|
||||
if (range.Count == 0)
|
||||
throw new SpvVerificationException(
|
||||
$"Server returned no headers starting at height {h}: server is not trustworthy.");
|
||||
|
||||
for (var i = 0; i < range.Count; i++)
|
||||
{
|
||||
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
|
||||
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
|
||||
}
|
||||
h += range.Count;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_headerRangeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans one chain (receiving or change).
|
||||
///
|
||||
@@ -358,7 +540,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
return (firstUnused, tracked, history);
|
||||
}
|
||||
|
||||
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||
// Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
|
||||
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
|
||||
// rather than guessed at from wall-clock numbers alone.
|
||||
private int _busyRetries;
|
||||
|
||||
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||
{
|
||||
var delay = 200;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
@@ -367,13 +554,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
catch (ElectrumServerException ex)
|
||||
when (IsBusy(ex) && attempt < 7)
|
||||
{
|
||||
ReportBusyRetry(delay);
|
||||
await Task.Delay(delay, ct);
|
||||
delay = Math.Min(delay * 2, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||
private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||
{
|
||||
var delay = 200;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
@@ -382,12 +570,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
catch (ElectrumServerException ex)
|
||||
when (IsBusy(ex) && attempt < 7)
|
||||
{
|
||||
ReportBusyRetry(delay);
|
||||
await Task.Delay(delay, ct);
|
||||
delay = Math.Min(delay * 2, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportBusyRetry(int delayMs)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _busyRetries);
|
||||
if (n == 1 || n % 20 == 0)
|
||||
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
|
||||
}
|
||||
|
||||
private static bool IsBusy(ElectrumServerException ex) =>
|
||||
ex.Message.Contains("-102") ||
|
||||
ex.Message.Contains("-101") ||
|
||||
|
||||
@@ -21,6 +21,13 @@ public static class AppPaths
|
||||
/// <summary>Explicit override of the data root (e.g. CLI --data-dir). Takes priority over everything.</summary>
|
||||
public static string? OverrideDataRoot { get; set; }
|
||||
|
||||
// Test seams: redirect the machine-global bootstrap locations (APPDATA pointer
|
||||
// dir, executable dir, per-user default root) to a sandbox so the resolution
|
||||
// precedence can be exercised without touching real user data.
|
||||
internal static string? BootstrapDirOverride { get; set; }
|
||||
internal static string? PortableBaseOverride { get; set; }
|
||||
internal static string? DefaultRootOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default data root, following each platform's convention:
|
||||
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, like Electrum/Bitcoin);
|
||||
@@ -29,6 +36,8 @@ public static class AppPaths
|
||||
/// </summary>
|
||||
public static string DefaultDataRoot()
|
||||
{
|
||||
if (DefaultRootOverride is { } o)
|
||||
return o;
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
@@ -41,11 +50,12 @@ public static class AppPaths
|
||||
/// bootstrap location that is always writable and independent of the data root.</summary>
|
||||
private static string LocationPointerPath() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
AppDirName, "data-location");
|
||||
BootstrapDirOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDirName),
|
||||
"data-location");
|
||||
|
||||
private static string PortableRoot() =>
|
||||
Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
||||
Path.Combine(PortableBaseOverride ?? AppContext.BaseDirectory, PortableDirName);
|
||||
|
||||
private static bool HasData(string root) =>
|
||||
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
|
||||
|
||||
@@ -13,6 +13,10 @@ public static class EncryptedFile
|
||||
{
|
||||
private const string Format = "plm-wallet-aesgcm-v1";
|
||||
private const int DefaultIterations = 600_000;
|
||||
// Upper bound on the iteration count read from the container: the value is
|
||||
// attacker-controlled in a tampered file, and an absurd count would hang the
|
||||
// wallet at open (PBKDF2 DoS). Leaves ample room for future increases.
|
||||
private const int MaxIterations = 10_000_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
@@ -26,13 +30,21 @@ public static class EncryptedFile
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(fileContent);
|
||||
return doc.RootElement.TryGetProperty("Format", out var f)
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("Format", out var f)
|
||||
&& f.ValueKind == JsonValueKind.String
|
||||
&& f.GetString() == Format;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Invalid UTF-16 (lone surrogates) cannot be transcoded for parsing:
|
||||
// certainly not an encrypted container.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Encrypt(string plaintext, string password)
|
||||
@@ -54,23 +66,51 @@ public static class EncryptedFile
|
||||
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||
}
|
||||
|
||||
/// <summary>Throws <see cref="WrongPasswordException"/> if the password is wrong or the file is tampered with.</summary>
|
||||
/// <summary>
|
||||
/// Throws <see cref="WrongPasswordException"/> if the password is wrong or the
|
||||
/// ciphertext is tampered with, <see cref="InvalidDataException"/> for any
|
||||
/// malformed container (broken JSON, missing fields, bad base64, wrong
|
||||
/// nonce/tag size, out-of-range iteration count) — never a raw parsing exception.
|
||||
/// </summary>
|
||||
public static string Decrypt(string fileContent, string password)
|
||||
{
|
||||
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
||||
?? throw new InvalidDataException("Contenitore cifrato non valido.");
|
||||
Container? container;
|
||||
try
|
||||
{
|
||||
container = JsonSerializer.Deserialize<Container>(fileContent);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
}
|
||||
if (container is null)
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
if (container.Format != Format)
|
||||
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
|
||||
throw new InvalidDataException($"Unknown container format: {container.Format}");
|
||||
if (container.Iterations is <= 0 or > MaxIterations)
|
||||
throw new InvalidDataException($"Iteration count out of range: {container.Iterations}");
|
||||
|
||||
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
|
||||
var cipher = Convert.FromBase64String(container.Data);
|
||||
byte[] salt, nonce, tag, cipher;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(container.Salt);
|
||||
nonce = Convert.FromBase64String(container.Nonce);
|
||||
tag = Convert.FromBase64String(container.Tag);
|
||||
cipher = Convert.FromBase64String(container.Data);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||
{
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
}
|
||||
if (nonce.Length != NonceSize || tag.Length != TagSize)
|
||||
throw new InvalidDataException("Invalid encrypted container.");
|
||||
|
||||
var key = DeriveKey(password, salt, container.Iterations);
|
||||
var plain = new byte[cipher.Length];
|
||||
try
|
||||
{
|
||||
using var aes = new AesGcm(key, TagSize);
|
||||
aes.Decrypt(
|
||||
Convert.FromBase64String(container.Nonce), cipher,
|
||||
Convert.FromBase64String(container.Tag), plain);
|
||||
aes.Decrypt(nonce, cipher, tag, plain);
|
||||
}
|
||||
catch (AuthenticationTagMismatchException)
|
||||
{
|
||||
|
||||
@@ -95,6 +95,19 @@ public sealed class SyncCache
|
||||
public int TipHeight { get; set; }
|
||||
public long ConfirmedSats { get; set; }
|
||||
public long UnconfirmedSats { get; set; }
|
||||
|
||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||
public long ImmatureSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
|
||||
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
|
||||
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
|
||||
/// </summary>
|
||||
public long PendingVerificationSats { get; set; }
|
||||
|
||||
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
|
||||
public long SpendableSats { get; set; }
|
||||
public int NextReceiveIndex { get; set; }
|
||||
public int NextChangeIndex { get; set; }
|
||||
public List<CachedTx> History { get; set; } = [];
|
||||
@@ -138,6 +151,13 @@ public sealed class CachedTx
|
||||
public required string Txid { get; set; }
|
||||
public int Height { get; set; }
|
||||
public long DeltaSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once this tx's Merkle proof has actually been checked against a
|
||||
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
|
||||
/// be pending its own proof while background verification catches up). Always
|
||||
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -150,5 +170,13 @@ public sealed class CachedUtxo
|
||||
public bool IsChange { get; set; }
|
||||
public int AddressIndex { get; set; }
|
||||
public int Height { get; set; }
|
||||
public bool IsCoinbase { get; set; }
|
||||
public bool Frozen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
|
||||
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
|
||||
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -37,4 +37,12 @@ public static class WalletStore
|
||||
File.WriteAllText(tmp, content);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
|
||||
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
|
||||
/// transactions/headers), where the synchronous version would block the UI.
|
||||
/// </summary>
|
||||
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
|
||||
Task.Run(() => Save(doc, path, password));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
/// <param name="amountSats">Amount; ignored when <paramref name="sendAll"/> is true.</param>
|
||||
/// <param name="feeRateSatPerVByte">Fixed fee rate in sat/vByte (§6.4).</param>
|
||||
/// <param name="changeIndex">Index of the next change address (internal chain).</param>
|
||||
/// <param name="tipHeight">Current chain tip height, used to enforce confirmation thresholds.</param>
|
||||
/// <param name="sendAll">Send all: fee subtracted from the amount (§6.1).</param>
|
||||
public BuiltTransaction Build(
|
||||
IReadOnlyList<CachedUtxo> utxos,
|
||||
@@ -48,16 +49,48 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
long amountSats,
|
||||
decimal feeRateSatPerVByte,
|
||||
int changeIndex,
|
||||
int tipHeight,
|
||||
bool sendAll = false)
|
||||
{
|
||||
// Only confirmed UTXOs are spent: mempool funds appear in the "pending"
|
||||
// balance but are not spendable until confirmed.
|
||||
var spendable = utxos.Where(u => !u.Frozen && u.Height > 0).ToList();
|
||||
var profile = account.Profile;
|
||||
var spendable = utxos.Where(u => u.IsSpendable(profile, tipHeight)).ToList();
|
||||
|
||||
if (spendable.Count == 0)
|
||||
{
|
||||
var pending = utxos.Where(u => !u.Frozen && u.Height <= 0).Sum(u => u.ValueSats);
|
||||
throw new WalletSpendException(pending > 0
|
||||
? $"No confirmed funds: {CoinAmount.Format(pending)} pending confirmation (not spendable)."
|
||||
var reasons = new System.Text.StringBuilder();
|
||||
|
||||
var mempool = utxos.Where(u => !u.Frozen && u.Height <= 0).ToList();
|
||||
if (mempool.Count > 0)
|
||||
reasons.Append($"{mempool.Count} output(s) unconfirmed ({CoinAmount.Format(mempool.Sum(u => u.ValueSats))} in mempool). ");
|
||||
|
||||
var immature = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && u.IsCoinbase &&
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (immature.Count > 0)
|
||||
{
|
||||
var 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). ");
|
||||
}
|
||||
|
||||
var unverified = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && !u.Verified &&
|
||||
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
|
||||
if (unverified.Count > 0)
|
||||
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
|
||||
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
|
||||
|
||||
throw new WalletSpendException(reasons.Length > 0
|
||||
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||
: "No spendable UTXOs selected.");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@ using PalladiumWallet.Core.Spv;
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
||||
/// <param name="CoinbaseTag">
|
||||
/// Printable ASCII runs (e.g. pool tags like "/slush/") extracted from the coinbase
|
||||
/// scriptSig; null for non-coinbase inputs or if no printable text was found.
|
||||
/// </param>
|
||||
public sealed record TxInputInfo(
|
||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
|
||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase,
|
||||
string? CoinbaseTag = null);
|
||||
|
||||
/// <summary>An output of a transaction.</summary>
|
||||
/// <param name="OpReturnText">Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise.</param>
|
||||
public sealed record TxOutputInfo(
|
||||
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
|
||||
uint Index, long AmountSats, string? Address, string ScriptType, string? OpReturnText, bool IsMine);
|
||||
|
||||
/// <summary>
|
||||
/// Complete transaction data assembled by querying the server: the raw transaction
|
||||
@@ -105,8 +111,9 @@ public static class TransactionInspector
|
||||
{
|
||||
var o = tx.Outputs[i];
|
||||
var addr = AddrOf(o.ScriptPubKey);
|
||||
var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null;
|
||||
outputs.Add(new TxOutputInfo(
|
||||
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
|
||||
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), opReturn,
|
||||
addr is not null && ownedAddresses.Contains(addr)));
|
||||
}
|
||||
|
||||
@@ -134,7 +141,7 @@ public static class TransactionInspector
|
||||
{
|
||||
if (tx.IsCoinBase)
|
||||
{
|
||||
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
|
||||
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true, CoinbaseTagOf(inp.ScriptSig.ToBytes())));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -189,4 +196,48 @@ public static class TransactionInspector
|
||||
}
|
||||
catch { return "—"; }
|
||||
}
|
||||
|
||||
/// <summary>Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text.</summary>
|
||||
private static string? OpReturnTextOf(Script script)
|
||||
{
|
||||
if (!script.IsUnspendable) return null;
|
||||
try
|
||||
{
|
||||
var data = script.ToOps().Skip(1)
|
||||
.Where(op => op.PushData is { Length: > 0 })
|
||||
.SelectMany(op => op.PushData)
|
||||
.ToArray();
|
||||
return data.Length == 0 ? null : DecodeUtf8OrHex(data);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string DecodeUtf8OrHex(byte[] data)
|
||||
{
|
||||
var text = System.Text.Encoding.UTF8.GetString(data);
|
||||
var looksLikeText = !text.Contains('�') && text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t');
|
||||
return looksLikeText ? text : "0x" + Convert.ToHexString(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool
|
||||
/// tags like "/slush/" embedded among the binary BIP34 height and extranonce.
|
||||
/// </summary>
|
||||
private static string? CoinbaseTagOf(byte[] scriptSig)
|
||||
{
|
||||
var runs = new List<string>();
|
||||
var run = new System.Text.StringBuilder();
|
||||
void Flush()
|
||||
{
|
||||
if (run.Length >= 4) runs.Add(run.ToString());
|
||||
run.Clear();
|
||||
}
|
||||
foreach (var b in scriptSig)
|
||||
{
|
||||
if (b is >= 0x20 and <= 0x7E) run.Append((char)b);
|
||||
else Flush();
|
||||
}
|
||||
Flush();
|
||||
return runs.Count == 0 ? null : string.Join(" ", runs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Confirmation-threshold rules shared between coin selection (<see cref="TransactionFactory"/>)
|
||||
/// and balance reporting: a coinbase output needs COINBASE_MATURITY + 1 confirmations
|
||||
/// (consensus rule nSpendHeight - nHeight >= 120, plus one block of safety margin, matching
|
||||
/// the Qt wallet); a regular output needs <see cref="ChainProfile.MinConfirmations"/> (wallet
|
||||
/// policy, no consensus rule).
|
||||
/// </summary>
|
||||
public static class UtxoSpendability
|
||||
{
|
||||
public static int RequiredConfirmations(this CachedUtxo utxo, ChainProfile profile) =>
|
||||
utxo.IsCoinbase ? profile.CoinbaseMaturity + 1 : profile.MinConfirmations;
|
||||
|
||||
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
||||
|
||||
/// <summary>
|
||||
/// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle
|
||||
/// proof has actually been checked. Without the <see cref="CachedUtxo.Verified"/> gate a
|
||||
/// malicious server could report a fake confirmed UTXO and have it spent before background
|
||||
/// verification ever caught the forgery.
|
||||
/// </summary>
|
||||
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||
!utxo.Frozen && utxo.Height > 0 && utxo.Verified
|
||||
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
|
||||
@@ -0,0 +1 @@
|
||||
hello world
|
||||
@@ -0,0 +1 @@
|
||||
PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
|
||||
@@ -0,0 +1 @@
|
||||
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
@@ -0,0 +1 @@
|
||||
ábÊco ábaco0ábacoábÊ
|
||||
@@ -0,0 +1 @@
|
||||
ábaco ábaco ábaco
|
||||
@@ -0,0 +1 @@
|
||||
0,001
|
||||
@@ -0,0 +1 @@
|
||||
79228162514264337593543950335
|
||||
@@ -0,0 +1 @@
|
||||
1.50000000
|
||||
@@ -0,0 +1 @@
|
||||
{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -0,0 +1 @@
|
||||
{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}
|
||||
@@ -0,0 +1 @@
|
||||
0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]
|
||||
@@ -0,0 +1 @@
|
||||
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","‡50002"]]]
|
||||
@@ -0,0 +1 @@
|
||||
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]
|
||||
@@ -0,0 +1 @@
|
||||
not-a-key
|
||||
@@ -0,0 +1 @@
|
||||
xpub661MyMwAqRbcF
|
||||
@@ -0,0 +1 @@
|
||||
zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs
|
||||
@@ -0,0 +1 @@
|
||||
{"Version":99}
|
||||
@@ -0,0 +1 @@
|
||||
{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}
|
||||
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Fuzz;
|
||||
|
||||
/// <summary>
|
||||
/// Fuzz targets over the parsers that consume untrusted input (server responses,
|
||||
/// wallet files, user-pasted keys/addresses/amounts). Each target enforces the
|
||||
/// parser's error contract: the exception types documented as its failure mode
|
||||
/// are swallowed, anything else escapes and is reported as a crash by the fuzzer.
|
||||
/// </summary>
|
||||
public static class FuzzTargets
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, Action<byte[]>> All =
|
||||
new Dictionary<string, Action<byte[]>>
|
||||
{
|
||||
["header"] = Header,
|
||||
["merkle"] = Merkle,
|
||||
["slip132"] = Slip132Keys,
|
||||
["bip39"] = Bip39Mnemonic,
|
||||
["address"] = Address,
|
||||
["coinamount"] = CoinAmountParse,
|
||||
["walletdoc"] = WalletDoc,
|
||||
["encfile"] = EncFile,
|
||||
["peers"] = Peers,
|
||||
};
|
||||
|
||||
/// <summary>Runs one input through a named target (used by replay and the regression test).</summary>
|
||||
public static void Run(string target, byte[] data) => All[target](data);
|
||||
|
||||
private static string Utf8(byte[] data) => Encoding.UTF8.GetString(data);
|
||||
|
||||
// 80-byte block headers arrive from the server both as raw bytes and as hex.
|
||||
// Contract: exactly 80 bytes never throws; any other length is ArgumentException;
|
||||
// the hex overload may also reject non-hex text with FormatException.
|
||||
private static void Header(byte[] data)
|
||||
{
|
||||
try { BlockHeaderInfo.Parse(data); }
|
||||
catch (ArgumentException) when (data.Length != BlockHeaderInfo.Size) { }
|
||||
|
||||
try { BlockHeaderInfo.Parse(Utf8(data)); }
|
||||
catch (FormatException) { }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
// Merkle proof verification runs on server-supplied txid/pos/branch.
|
||||
// Contract: never throws, for any position (including negative) and branch.
|
||||
private static void Merkle(byte[] data)
|
||||
{
|
||||
if (data.Length < 68)
|
||||
return;
|
||||
var txid = new uint256(data.AsSpan(0, 32));
|
||||
var position = BitConverter.ToInt32(data, 32);
|
||||
var root = new uint256(data.AsSpan(36, 32));
|
||||
var branch = new List<uint256>();
|
||||
for (var offset = 68; offset + 32 <= data.Length; offset += 32)
|
||||
branch.Add(new uint256(data.AsSpan(offset, 32)));
|
||||
|
||||
MerkleProof.Verify(txid, position, branch, root);
|
||||
}
|
||||
|
||||
// SLIP-132 extended keys are pasted by the user on import.
|
||||
// Contract: the Try* pair never throws, on any profile.
|
||||
private static void Slip132Keys(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
|
||||
{
|
||||
Slip132.TryDecodePublic(text, profile, out _, out _);
|
||||
Slip132.TryDecodePrivate(text, profile, out _, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// Mnemonics are pasted by the user on restore. Contract: TryParse never throws,
|
||||
// with auto-detected and explicit wordlist language alike.
|
||||
private static void Bip39Mnemonic(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
Bip39.TryParse(text, out _);
|
||||
if (data.Length > 0)
|
||||
Bip39.TryParse(text, out _, (MnemonicLanguage)(data[0] % 8));
|
||||
}
|
||||
|
||||
// Recipient addresses are pasted by the user (or scanned from a QR).
|
||||
// Contract: NBitcoin rejects anything invalid with FormatException.
|
||||
private static void Address(byte[] data)
|
||||
{
|
||||
try { BitcoinAddress.Create(Utf8(data), PalladiumNetworks.Mainnet); }
|
||||
catch (FormatException) { }
|
||||
}
|
||||
|
||||
// Amounts are typed by the user in the currently selected unit.
|
||||
// Contract: TryParse* never throws for any of the official units.
|
||||
private static void CoinAmountParse(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
foreach (var unit in CoinAmount.Units)
|
||||
CoinAmount.TryParseIn(text, unit, out _);
|
||||
CoinAmount.TryParseCoins(text, out _);
|
||||
}
|
||||
|
||||
// The plaintext wallet document is read from disk at every open.
|
||||
// Contract: malformed JSON or schema is JsonException/InvalidDataException.
|
||||
private static void WalletDoc(byte[] data)
|
||||
{
|
||||
try { WalletDocument.FromJson(Utf8(data)); }
|
||||
catch (JsonException) { }
|
||||
catch (InvalidDataException) { }
|
||||
}
|
||||
|
||||
// The encrypted container wraps the document on disk; a tampered file must
|
||||
// fail with the two typed exceptions, never a raw parser error, and
|
||||
// IsEncrypted (the format sniffer) must never throw at all.
|
||||
private static void EncFile(byte[] data)
|
||||
{
|
||||
var text = Utf8(data);
|
||||
EncryptedFile.IsEncrypted(text);
|
||||
try { EncryptedFile.Decrypt(text, "fuzz-password"); }
|
||||
catch (WrongPasswordException) { }
|
||||
catch (InvalidDataException) { }
|
||||
}
|
||||
|
||||
// server.peers.subscribe responses are attacker-controlled JSON.
|
||||
// Contract: any well-formed JSON of any shape parses to a (possibly empty)
|
||||
// peer list without throwing.
|
||||
private static void Peers(byte[] data)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(data); }
|
||||
catch (JsonException) { return; }
|
||||
using (doc)
|
||||
ElectrumApi.ParsePeers(doc.RootElement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Fuzzing wants stable, non-inlined instrumentation points. -->
|
||||
<TieredCompilation>false</TieredCompilation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SharpFuzz" Version="2.3.0" />
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Seed corpus travels next to the binary so replay and the regression
|
||||
test in PalladiumWallet.Tests can find it via AppContext.BaseDirectory. -->
|
||||
<None Include="Corpus\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text;
|
||||
using PalladiumWallet.Fuzz;
|
||||
|
||||
// Fuzzing entry point. One binary, one target per process (fuzzers want a
|
||||
// single deterministic execution path). Modes:
|
||||
//
|
||||
// <target> --afl run under afl-fuzz (instrumented Core, see fuzz.sh)
|
||||
// <target> --libfuzzer run under libfuzzer-dotnet
|
||||
// <target> --random N [seed] built-in dumb fuzzer: N corpus mutations (no tooling needed)
|
||||
// <target> <file|dir> ... replay saved inputs (used by the regression test / triage)
|
||||
// --make-seeds <dir> regenerate the seed corpus
|
||||
//
|
||||
// A "crash" is any exception escaping the target: each target already swallows
|
||||
// the exception types that are part of the parser's documented contract.
|
||||
|
||||
if (args.Length >= 2 && args[0] == "--make-seeds")
|
||||
{
|
||||
SeedCorpus.WriteAll(args[1]);
|
||||
Console.WriteLine($"Seed corpus written to {args[1]}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.Length < 2 || !FuzzTargets.All.TryGetValue(args[0], out var target))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: PalladiumWallet.Fuzz <target> --afl | --libfuzzer | --random N [seed] | <file|dir>...");
|
||||
Console.Error.WriteLine(" PalladiumWallet.Fuzz --make-seeds <dir>");
|
||||
Console.Error.WriteLine($"Targets: {string.Join(' ', FuzzTargets.All.Keys)}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
switch (args[1])
|
||||
{
|
||||
case "--afl":
|
||||
SharpFuzz.Fuzzer.Run(stream =>
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
target(ms.ToArray());
|
||||
});
|
||||
return 0;
|
||||
|
||||
case "--libfuzzer":
|
||||
SharpFuzz.Fuzzer.LibFuzzer.Run(span => target(span.ToArray()));
|
||||
return 0;
|
||||
|
||||
case "--random":
|
||||
{
|
||||
var iterations = args.Length > 2 ? int.Parse(args[2]) : 100_000;
|
||||
var seed = args.Length > 3 ? ulong.Parse(args[3]) : 0xF022_5EEDUL;
|
||||
return RandomFuzz.Run(args[0], target, iterations, seed);
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
var failures = 0;
|
||||
foreach (var path in args[1..])
|
||||
foreach (var file in Directory.Exists(path)
|
||||
? Directory.EnumerateFiles(path).OrderBy(f => f, StringComparer.Ordinal)
|
||||
: (IEnumerable<string>)[path])
|
||||
{
|
||||
var data = File.ReadAllBytes(file);
|
||||
try
|
||||
{
|
||||
target(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.Error.WriteLine($"CRASH {args[0]} on {file}:");
|
||||
Console.Error.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal corpus-mutation fuzzer for smoke runs and CI: not coverage-guided,
|
||||
/// but catches shallow contract violations without afl++/libFuzzer installed.
|
||||
/// Deterministic for a given seed; on crash it saves the input for replay.
|
||||
/// </summary>
|
||||
internal static class RandomFuzz
|
||||
{
|
||||
public static int Run(string name, Action<byte[]> target, int iterations, ulong seed)
|
||||
{
|
||||
var corpus = SeedCorpus.For(name);
|
||||
var rng = seed;
|
||||
ulong Next() { rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17; return rng; }
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var data = (byte[])corpus[(int)(Next() % (ulong)corpus.Count)].Clone();
|
||||
var mutations = 1 + (int)(Next() % 8);
|
||||
for (var m = 0; m < mutations && data.Length > 0; m++)
|
||||
switch (Next() % 4)
|
||||
{
|
||||
case 0: data[Next() % (ulong)data.Length] = (byte)Next(); break;
|
||||
case 1: data[Next() % (ulong)data.Length] ^= (byte)(1 << (int)(Next() % 8)); break;
|
||||
case 2: Array.Resize(ref data, (int)(Next() % 512) + 1); break;
|
||||
case 3: data = [.. data, .. data[..(int)(Next() % (ulong)data.Length)]]; break;
|
||||
}
|
||||
try
|
||||
{
|
||||
target(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var crashFile = $"crash-{name}-{i}.bin";
|
||||
File.WriteAllBytes(crashFile, data);
|
||||
Console.Error.WriteLine(
|
||||
$"CRASH {name} at iteration {i}: {ex.GetType().Name}: {ex.Message} (input saved to {crashFile})");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"{name}: {iterations} random mutations, no contract violations");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed inputs per target: small valid (or near-valid) examples that give the
|
||||
/// mutation engines a meaningful starting shape. Regenerate with --make-seeds.
|
||||
/// </summary>
|
||||
internal static class SeedCorpus
|
||||
{
|
||||
private static readonly Dictionary<string, (string Name, byte[] Data)[]> Seeds = new()
|
||||
{
|
||||
["header"] =
|
||||
[
|
||||
("genesis", Convert.FromHexString(
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
|
||||
("genesis-hex", Encoding.UTF8.GetBytes(
|
||||
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
|
||||
("short", [0x01, 0x00, 0x00, 0x00]),
|
||||
],
|
||||
["merkle"] =
|
||||
[
|
||||
("one-level", [.. new byte[32], .. BitConverter.GetBytes(1), .. new byte[32], .. Enumerable.Repeat((byte)0xAA, 32)]),
|
||||
("no-branch", [.. Enumerable.Repeat((byte)0x11, 32), .. BitConverter.GetBytes(0), .. Enumerable.Repeat((byte)0x11, 32)]),
|
||||
],
|
||||
["slip132"] =
|
||||
[
|
||||
("zpub", Encoding.UTF8.GetBytes(
|
||||
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs")),
|
||||
("xpub-prefix", Encoding.UTF8.GetBytes("xpub661MyMwAqRbcF")),
|
||||
("garbage", Encoding.UTF8.GetBytes("not-a-key")),
|
||||
],
|
||||
["bip39"] =
|
||||
[
|
||||
("english-12", Encoding.UTF8.GetBytes(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")),
|
||||
("spanish-word", Encoding.UTF8.GetBytes("ábaco ábaco ábaco")),
|
||||
("empty", []),
|
||||
// Regression: NBitcoin AutoDetect → "Unknown" language → NotSupportedException
|
||||
// escaped TryParse (fixed by catching it).
|
||||
("regression-notsupported", Convert.FromHexString(
|
||||
"c3a162ca636f20c3a16261636f30c3a16261636fc3a162ca")),
|
||||
],
|
||||
["address"] =
|
||||
[
|
||||
("bech32", Encoding.UTF8.GetBytes("plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay")),
|
||||
("legacy-ish", Encoding.UTF8.GetBytes("PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q")),
|
||||
("garbage", Encoding.UTF8.GetBytes("hello world")),
|
||||
],
|
||||
["coinamount"] =
|
||||
[
|
||||
("plain", Encoding.UTF8.GetBytes("1.50000000")),
|
||||
("comma", Encoding.UTF8.GetBytes("0,001")),
|
||||
("huge", Encoding.UTF8.GetBytes("79228162514264337593543950335")),
|
||||
],
|
||||
["walletdoc"] =
|
||||
[
|
||||
("minimal", Encoding.UTF8.GetBytes(
|
||||
"""{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}""")),
|
||||
("bad-version", Encoding.UTF8.GetBytes("""{"Version":99}""")),
|
||||
("not-json", Encoding.UTF8.GetBytes("hello")),
|
||||
],
|
||||
["encfile"] =
|
||||
[
|
||||
// Well-formed container with a tiny iteration count so every replay and
|
||||
// mutated descendant skips the full PBKDF2 cost (decryption then fails
|
||||
// with the typed WrongPasswordException — exactly the contract under test).
|
||||
("container", Encoding.UTF8.GetBytes(
|
||||
"""{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}""")),
|
||||
("wrong-format", Encoding.UTF8.GetBytes(
|
||||
"""{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}""")),
|
||||
("not-json", Encoding.UTF8.GetBytes("hello")),
|
||||
],
|
||||
["peers"] =
|
||||
[
|
||||
("typical", Encoding.UTF8.GetBytes(
|
||||
"""[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]""")),
|
||||
("odd-shapes", Encoding.UTF8.GetBytes("""[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]""")),
|
||||
// Regression: raw invalid-UTF-8 byte inside a JSON string parses but cannot
|
||||
// be transcoded by GetString() → InvalidOperationException (fixed in AsString).
|
||||
("regression-bad-utf8", Convert.FromHexString(
|
||||
"5b5b22312e322e332e34222c22706565722e6578616d706c65222c5b2276312e342e32222c22703130222c227435303030" +
|
||||
"31222c22873530303032225d5d5d")),
|
||||
],
|
||||
};
|
||||
|
||||
public static IReadOnlyList<byte[]> For(string target) =>
|
||||
Seeds[target].Select(s => s.Data).ToList();
|
||||
|
||||
public static void WriteAll(string root)
|
||||
{
|
||||
foreach (var (target, seeds) in Seeds)
|
||||
{
|
||||
var dir = Path.Combine(root, target);
|
||||
Directory.CreateDirectory(dir);
|
||||
foreach (var (name, data) in seeds)
|
||||
File.WriteAllBytes(Path.Combine(dir, name + ".bin"), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Fuzzing
|
||||
|
||||
Fuzz targets over every parser that consumes **untrusted input**: server
|
||||
responses (block headers, Merkle proofs, peer lists), wallet files (plaintext
|
||||
document and encrypted container), and user-pasted text (mnemonics, SLIP-132
|
||||
keys, addresses, amounts).
|
||||
|
||||
Each target encodes the parser's **error contract**: the exception types
|
||||
documented as its failure mode are swallowed, anything else escaping is a
|
||||
finding. Targets: `header` `merkle` `slip132` `bip39` `address` `coinamount`
|
||||
`walletdoc` `encfile` `peers` (see `FuzzTargets.cs` for each contract).
|
||||
|
||||
## Three ways to run
|
||||
|
||||
**1. Corpus replay — automatic.** The seed corpus (including regression inputs
|
||||
for every crash found so far) replays through all targets on every
|
||||
`dotnet test` run via `FuzzCorpusTests`, so fixed findings cannot come back.
|
||||
|
||||
**2. Built-in random smoke — no tooling.** Not coverage-guided, but catches
|
||||
shallow contract violations in seconds:
|
||||
|
||||
```bash
|
||||
dotnet run -- bip39 --random 50000 # target, iterations [, seed]
|
||||
```
|
||||
|
||||
**3. Coverage-guided campaign — afl++.** The real thing; run it before a
|
||||
release or after touching any parser:
|
||||
|
||||
```bash
|
||||
apt install afl++
|
||||
dotnet tool install --global SharpFuzz.CommandLine
|
||||
./fuzz.sh header # one target per campaign
|
||||
./fuzz.sh peers -V 3600 # extra args go to afl-fuzz
|
||||
```
|
||||
|
||||
`fuzz.sh` builds Release, instruments `PalladiumWallet.Core.dll` (and NBitcoin)
|
||||
with SharpFuzz, and launches afl-fuzz with the target's seed corpus. Findings
|
||||
land in `findings/<target>/crashes/`.
|
||||
|
||||
## Triage workflow
|
||||
|
||||
```bash
|
||||
dotnet run -- <target> findings/<target>/crashes/<file> # replay: full stack trace
|
||||
```
|
||||
|
||||
Fix the parser (typed exception or graceful rejection — see the hardening
|
||||
commits for `Bip39.TryParse`, `ElectrumApi.ParsePeers`, `EncryptedFile.Decrypt`
|
||||
as examples), then add the input to `SeedCorpus` in `Program.cs` as a
|
||||
`regression-*` seed and regenerate with `dotnet run -- --make-seeds Corpus`.
|
||||
|
||||
## Findings so far
|
||||
|
||||
The first smoke run found two real bugs, both reachable from untrusted input:
|
||||
|
||||
- `Bip39.TryParse` crashed with `NotSupportedException` on restore text
|
||||
resembling no wordlist (NBitcoin's `Wordlist.AutoDetect` throws for its
|
||||
internal "Unknown" language).
|
||||
- `ElectrumApi.ParsePeers` crashed with `InvalidOperationException` on a peer
|
||||
list containing a JSON string with invalid UTF-8 bytes (parses fine,
|
||||
fails at `GetString()` transcoding) — attacker-controlled server data.
|
||||
|
||||
Plus two hardening changes made so the `encfile`/`peers` contracts could be
|
||||
strict at all: `EncryptedFile.Decrypt` maps every malformed-container failure
|
||||
to `InvalidDataException` and bounds the PBKDF2 iteration count (a tampered
|
||||
file could previously demand 2^31 iterations, hanging the wallet at open), and
|
||||
`ParsePeers` tolerates any JSON shape.
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Coverage-guided fuzzing with afl++ + SharpFuzz.
|
||||
#
|
||||
# One-time setup:
|
||||
# apt install afl++ (or build from source)
|
||||
# dotnet tool install --global SharpFuzz.CommandLine
|
||||
#
|
||||
# Usage:
|
||||
# ./fuzz.sh <target> [afl-fuzz args...]
|
||||
# ./fuzz.sh header
|
||||
# ./fuzz.sh peers -V 3600 # time-limited campaign (seconds)
|
||||
#
|
||||
# Targets: header merkle slip132 bip39 address coinamount walletdoc encfile peers
|
||||
#
|
||||
# Findings land in findings/<target>/crashes/: replay one with
|
||||
# dotnet run -- <target> findings/<target>/crashes/<file>
|
||||
# then add it to the seed corpus in Program.cs as a regression input.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TARGET="${1:?usage: ./fuzz.sh <target> [afl-fuzz args...]}"
|
||||
shift || true
|
||||
|
||||
command -v afl-fuzz >/dev/null || { echo "afl-fuzz not found: apt install afl++"; exit 1; }
|
||||
command -v sharpfuzz >/dev/null || { echo "sharpfuzz not found: dotnet tool install --global SharpFuzz.CommandLine"; exit 1; }
|
||||
|
||||
dotnet build -c Release
|
||||
BIN="bin/Release/net10.0"
|
||||
|
||||
# Instrument the assemblies under test (idempotent: SharpFuzz skips already-
|
||||
# instrumented DLLs). Core is the real target; NBitcoin gives the fuzzer
|
||||
# visibility into the library our parsers delegate to.
|
||||
sharpfuzz "$BIN/PalladiumWallet.Core.dll"
|
||||
sharpfuzz "$BIN/NBitcoin.dll" || true
|
||||
|
||||
mkdir -p "findings/$TARGET"
|
||||
afl-fuzz -i "Corpus/$TARGET" -o "findings/$TARGET" -t 5000 "$@" \
|
||||
-- dotnet "$BIN/PalladiumWallet.Fuzz.dll" "$TARGET" --afl
|
||||
@@ -75,4 +75,25 @@ public class PalladiumNetworksTests
|
||||
Assert.NotSame(PalladiumNetworks.Testnet, PalladiumNetworks.Regtest);
|
||||
Assert.Same(PalladiumNetworks.Mainnet, PalladiumNetworks.For(NetKind.Mainnet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void For_con_un_kind_fuori_enum_lancia_eccezione()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => PalladiumNetworks.For((NetKind)99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_network_set_espone_le_tre_reti_sotto_il_codice_plm()
|
||||
{
|
||||
var set = PalladiumNetworkSet.Instance;
|
||||
|
||||
Assert.Equal("PLM", set.CryptoCode);
|
||||
Assert.Same(PalladiumNetworks.Mainnet, set.Mainnet);
|
||||
Assert.Same(PalladiumNetworks.Testnet, set.Testnet);
|
||||
Assert.Same(PalladiumNetworks.Regtest, set.Regtest);
|
||||
Assert.Same(PalladiumNetworks.Mainnet, set.GetNetwork(ChainName.Mainnet));
|
||||
Assert.Same(PalladiumNetworks.Testnet, set.GetNetwork(ChainName.Testnet));
|
||||
Assert.Same(PalladiumNetworks.Regtest, set.GetNetwork(ChainName.Regtest));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => set.GetNetwork(new ChainName("signet")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public class AddressDerivationTests
|
||||
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
||||
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);
|
||||
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
|
||||
@@ -37,7 +37,7 @@ public class AddressDerivationTests
|
||||
{
|
||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
|
||||
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);
|
||||
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
|
||||
|
||||
@@ -90,4 +90,51 @@ public class Bip39Tests
|
||||
Assert.NotEqual(Convert.ToHexString(noPass), Convert.ToHexString(withPass));
|
||||
Assert.NotEqual(Convert.ToHexString(withPass), Convert.ToHexString(otherPass));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Testo_vuoto_o_solo_spazi_viene_rifiutato(string? text)
|
||||
{
|
||||
Assert.False(Bip39.TryParse(text!, out var mnemonic));
|
||||
Assert.Null(mnemonic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MnemonicLanguage.English)]
|
||||
[InlineData(MnemonicLanguage.Spanish)]
|
||||
[InlineData(MnemonicLanguage.French)]
|
||||
[InlineData(MnemonicLanguage.Japanese)]
|
||||
[InlineData(MnemonicLanguage.PortugueseBrazil)]
|
||||
[InlineData(MnemonicLanguage.ChineseSimplified)]
|
||||
[InlineData(MnemonicLanguage.ChineseTraditional)]
|
||||
[InlineData(MnemonicLanguage.Czech)]
|
||||
public void Ogni_lingua_supportata_genera_e_riparsa_la_propria_mnemonica(MnemonicLanguage language)
|
||||
{
|
||||
var mnemonic = Bip39.Generate(MnemonicLength.Twelve, language);
|
||||
|
||||
// Explicit-language parse must always succeed; it also covers the full
|
||||
// language→wordlist map (USERGUIDE §2.1 promises these languages on restore).
|
||||
Assert.True(Bip39.TryParse(mnemonic.ToString(), out var parsed, language));
|
||||
Assert.Equal(mnemonic.ToString(), parsed!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_lingua_fuori_enum_viene_rifiutata()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => Bip39.Generate(MnemonicLength.Twelve, (MnemonicLanguage)99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Testo_che_non_somiglia_a_nessuna_wordlist_viene_rifiutato_senza_eccezioni()
|
||||
{
|
||||
// NBitcoin's Wordlist.AutoDetect throws NotSupportedException("Unknown")
|
||||
// for text resembling no wordlist instead of failing gracefully (found by
|
||||
// fuzzing): TryParse must swallow it and return false — this string reaches
|
||||
// TryParse straight from the user's restore input.
|
||||
Assert.False(Bip39.TryParse("ábÊco ábaco0ábaco ábÊ", out var mnemonic));
|
||||
Assert.Null(mnemonic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
|
||||
@@ -74,6 +75,39 @@ public class Bip84Slip132Tests
|
||||
Assert.False(Slip132.TryDecodePrivate(asXpub, ChainProfiles.Mainnet, out _, out _)); // pub ≠ priv
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_xkey_base58_valida_ma_di_lunghezza_sbagliata_viene_rifiutata()
|
||||
{
|
||||
// Well-formed Base58Check, but the decoded payload is not 78 bytes.
|
||||
var tooShort = Encoders.Base58Check.EncodeData([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
Assert.False(Slip132.TryDecodePublic(tooShort, ChainProfiles.Mainnet, out _, out _));
|
||||
Assert.False(Slip132.TryDecodePrivate(tooShort, ChainProfiles.Mainnet, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_zpub_con_payload_corrotto_viene_rifiutata_senza_eccezioni()
|
||||
{
|
||||
// Correct zpub header, but the 33-byte pubkey has an invalid point prefix:
|
||||
// the ExtPubKey constructor must fail and TryDecodePublic must return false.
|
||||
var data = Encoders.Base58Check.DecodeData(Account().ToSlip132());
|
||||
data[45] = 0xFF; // pubkey prefix (offset 4 header + 41) — not 0x02/0x03
|
||||
var corrupted = Encoders.Base58Check.EncodeData(data);
|
||||
|
||||
Assert.False(Slip132.TryDecodePublic(corrupted, ChainProfiles.Mainnet, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_zprv_con_payload_corrotto_viene_rifiutata_senza_eccezioni()
|
||||
{
|
||||
// Correct zprv header, but the byte before the 32-byte key must be 0x00:
|
||||
// ExtKey.CreateFromBytes must fail and TryDecodePrivate must return false.
|
||||
var data = Encoders.Base58Check.DecodeData(Account().ToSlip132Private());
|
||||
data[45] = 0xFF;
|
||||
var corrupted = Encoders.Base58Check.EncodeData(data);
|
||||
|
||||
Assert.False(Slip132.TryDecodePrivate(corrupted, ChainProfiles.Mainnet, out _, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0, "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c",
|
||||
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")]
|
||||
@@ -83,7 +117,7 @@ public class Bip84Slip132Tests
|
||||
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
|
||||
{
|
||||
var account = Account();
|
||||
var pubKey = account.GetPublicKey(isChange, index);
|
||||
var pubKey = account.GetPublicKey(isChange, index)!;
|
||||
|
||||
if (expectedPubKeyHex is not null)
|
||||
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using PalladiumWallet.Fuzz;
|
||||
|
||||
namespace PalladiumWallet.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Replays the fuzzing seed corpus (tests/PalladiumWallet.Fuzz/Corpus, copied
|
||||
/// next to the test binary) through every fuzz target on each test run: the
|
||||
/// corpus includes the regression inputs for crashes found by past campaigns,
|
||||
/// so a fixed contract violation can never silently come back. The real
|
||||
/// coverage-guided campaigns run separately via tests/PalladiumWallet.Fuzz/fuzz.sh.
|
||||
/// </summary>
|
||||
public class FuzzCorpusTests
|
||||
{
|
||||
public static TheoryData<string> Targets()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var name in FuzzTargets.All.Keys)
|
||||
data.Add(name);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void Il_corpus_del_target_rispetta_il_contratto_del_parser(string target)
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", target);
|
||||
Assert.True(Directory.Exists(dir), $"seed corpus missing for '{target}' at {dir}");
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(dir))
|
||||
{
|
||||
// Any escaping exception is a contract violation: the target itself
|
||||
// swallows the exception types documented as the parser's failure mode.
|
||||
FuzzTargets.Run(target, File.ReadAllBytes(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,204 @@
|
||||
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 Una_risposta_molto_grande_arriva_integra()
|
||||
{
|
||||
// A payload far beyond the PipeReader's segment size forces the
|
||||
// multi-segment dispatch path (ArrayPool copy) instead of the fast span.
|
||||
var big = new string('x', 256 * 1024);
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get", _ => big);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var result = await client.GetTransactionAsync("00").WaitAsync(Timeout);
|
||||
Assert.Equal(big, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,35 @@ public class PeerParsingTests
|
||||
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
|
||||
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"not":"an array"}""")]
|
||||
[InlineData(""" "just a string" """)]
|
||||
[InlineData("42")]
|
||||
[InlineData("""[[1,2,3]]""")] // numeric ip/hostname
|
||||
[InlineData("""[["h","n",42]]""")] // features not an array
|
||||
[InlineData("""[["h","n",[42,{"x":1},null]]]""")] // non-string features
|
||||
public void Una_risposta_peers_di_forma_inattesa_produce_una_lista_vuota(string json)
|
||||
{
|
||||
// The response is untrusted server data: any shape must degrade to an
|
||||
// empty list, never throw (found by fuzzing).
|
||||
Assert.Empty(ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_stringa_json_con_utf8_invalido_viene_ignorata_senza_eccezioni()
|
||||
{
|
||||
// Raw 0x87 inside a JSON string: JsonDocument.Parse accepts the bytes but
|
||||
// GetString() cannot transcode them to UTF-16 (found by fuzzing). The
|
||||
// whole entry degrades to "no usable host/feature", not an exception.
|
||||
var bytes = "[[\"1.2.3.4\",\"#\",[\"v1\",\"t#\"]]]"u8.ToArray();
|
||||
var hostIdx = Array.IndexOf(bytes, (byte)'#');
|
||||
bytes[hostIdx] = 0x87;
|
||||
bytes[Array.LastIndexOf(bytes, (byte)'#')] = 0x87;
|
||||
|
||||
using var doc = JsonDocument.Parse(bytes);
|
||||
Assert.Empty(ElectrumApi.ParsePeers(doc.RootElement));
|
||||
}
|
||||
}
|
||||
|
||||
public class ServerRegistryTests
|
||||
@@ -79,6 +108,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]
|
||||
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the update check: tag parsing plus the full CheckAsync flow via
|
||||
/// a stub HttpMessageHandler (the same seam the production overload wraps),
|
||||
/// so no real GitHub call is ever made.
|
||||
/// </summary>
|
||||
public class UpdateCheckerTests
|
||||
{
|
||||
/// <summary>Handler stub: replies with a fixed status/body, or throws.</summary>
|
||||
private sealed class StubHandler(HttpStatusCode status, string? body = null,
|
||||
Exception? throws = null) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken ct) =>
|
||||
throws is not null
|
||||
? Task.FromException<HttpResponseMessage>(throws)
|
||||
: Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body ?? "", Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<LatestRelease?> Check(string current, HttpStatusCode status,
|
||||
string? body = null, Exception? throws = null) =>
|
||||
UpdateChecker.CheckAsync(current, new StubHandler(status, body, throws));
|
||||
|
||||
[Fact]
|
||||
public async Task Una_release_piu_nuova_viene_segnalata_con_tag_e_url()
|
||||
{
|
||||
var release = await Check("0.9.1", HttpStatusCode.OK,
|
||||
"""{"tag_name":"v1.0.0","html_url":"https://example.test/rel/v1.0.0"}""");
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Equal("v1.0.0", release!.Tag);
|
||||
Assert.Equal("https://example.test/rel/v1.0.0", release.HtmlUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Senza_html_url_viene_costruito_il_link_alla_pagina_della_release()
|
||||
{
|
||||
var release = await Check("0.9.1", HttpStatusCode.OK, """{"tag_name":"v1.0.0"}""");
|
||||
|
||||
Assert.NotNull(release);
|
||||
Assert.Contains("/releases/tag/v1.0.0", release!.HtmlUrl);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"tag_name":"v0.9.1"}""")] // same version
|
||||
[InlineData("""{"tag_name":"v0.9.0"}""")] // older
|
||||
[InlineData("""{"tag_name":"main"}""")] // unparseable tag
|
||||
[InlineData("""{"tag_name":""}""")] // empty tag
|
||||
[InlineData("""{}""")] // missing tag
|
||||
public async Task Nessun_aggiornamento_quando_il_tag_non_e_piu_nuovo(string body)
|
||||
{
|
||||
Assert.Null(await Check("0.9.1", HttpStatusCode.OK, body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_risposta_http_di_errore_risolve_a_null()
|
||||
{
|
||||
Assert.Null(await Check("0.9.1", HttpStatusCode.NotFound));
|
||||
Assert.Null(await Check("0.9.1", HttpStatusCode.InternalServerError));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Json_malformato_o_errore_di_rete_risolvono_a_null()
|
||||
{
|
||||
Assert.Null(await Check("0.9.1", HttpStatusCode.OK, "not json at all"));
|
||||
Assert.Null(await Check("0.9.1", HttpStatusCode.OK,
|
||||
"""{"tag_name":"v9.9.9"}""", throws: new HttpRequestException("offline")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_versione_locale_non_parsabile_risolve_a_null()
|
||||
{
|
||||
Assert.Null(await Check("dev-build", HttpStatusCode.OK, """{"tag_name":"v9.9.9"}"""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[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
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,9 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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
|
||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||
{
|
||||
|
||||
@@ -239,4 +239,29 @@ public class BlockHeaderInfoTests
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => BlockHeaderInfo.Parse("ZZZ"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Senza_skip_pow_un_hash_sotto_il_target_passa()
|
||||
{
|
||||
// Bitcoin's genesis satisfies its own 0x1d00ffff target by construction.
|
||||
var header = BlockHeaderInfo.Parse(GenesisHeaderHex);
|
||||
var powProfile = ChainProfiles.Mainnet with { SkipPowValidation = false };
|
||||
|
||||
Assert.True(header.IsValidChild(uint256.Zero, powProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Senza_skip_pow_un_hash_sopra_il_target_viene_rifiutato()
|
||||
{
|
||||
// Rewrite the genesis bits to an almost-impossible target (0x03000001 →
|
||||
// tiny) without re-mining: the untouched hash can no longer satisfy it.
|
||||
var raw = Convert.FromHexString(GenesisHeaderHex);
|
||||
raw[72] = 0x01; raw[73] = 0x00; raw[74] = 0x00; raw[75] = 0x03;
|
||||
var header = BlockHeaderInfo.Parse(raw);
|
||||
var powProfile = ChainProfiles.Mainnet with { SkipPowValidation = false };
|
||||
|
||||
Assert.False(header.IsValidChild(uint256.Zero, powProfile));
|
||||
// Same header with skip enabled: the target is ignored.
|
||||
Assert.True(header.IsValidChild(uint256.Zero, ChainProfiles.Mainnet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
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(ChainProfile? profile = null)
|
||||
{
|
||||
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 ?? 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,
|
||||
uint256? prevHash = null)
|
||||
{
|
||||
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||
header.HashPrevBlock = prevHash ?? 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"));
|
||||
server.Handle("blockchain.block.headers", p =>
|
||||
{
|
||||
var start = p[0].GetInt32();
|
||||
var count = p[1].GetInt32();
|
||||
var hexes = new List<string>();
|
||||
for (var h = start; hexes.Count < count && Headers.TryGetValue(h, out var hex); h++)
|
||||
hexes.Add(hex);
|
||||
return new { count = hexes.Count, hex = string.Concat(hexes) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Verified means "its Merkle proof was checked" — a mempool tx has no proof to
|
||||
// check yet, so it's vacuously true; "not confirmed" is signalled by Height <= 0,
|
||||
// not by Verified (no merkle/header RPCs happen, asserted below).
|
||||
Assert.True(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);
|
||||
}
|
||||
|
||||
// ---- checkpoint anchoring ----
|
||||
|
||||
/// <summary>
|
||||
/// Builds a chain of single-tx headers from <paramref name="fromHeight"/> to
|
||||
/// <paramref name="toHeight"/> (inclusive), each linked to the previous via
|
||||
/// HashPrevBlock. The last height carries <paramref name="txid"/> as its Merkle
|
||||
/// root; <paramref name="roots"/> overrides the root at specific heights.
|
||||
/// </summary>
|
||||
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid,
|
||||
Dictionary<int, uint256>? roots = null)
|
||||
{
|
||||
var headers = new Dictionary<int, string>();
|
||||
uint256? prevHash = null;
|
||||
for (var h = fromHeight; h <= toHeight; h++)
|
||||
{
|
||||
var root = roots?.GetValueOrDefault(h) ?? (h == toHeight ? txid : uint256.One);
|
||||
var hex = Scenario.SingleTxHeaderHex(root, h, merkleRoot: root, prevHash: prevHash);
|
||||
headers[h] = hex;
|
||||
prevHash = BlockHeaderInfo.Parse(hex).Hash;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_tx_anchorata_a_un_checkpoint_valido_verifica_la_catena_di_header()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
|
||||
// covering 100..105 back to the checkpoint already includes the tx's own header —
|
||||
// no separate single-header call needed.
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
|
||||
// Second tx (at height 103) known upfront so the chain can commit to it,
|
||||
// but announced by the server only after the first sync.
|
||||
var tx2 = Net.CreateTransaction();
|
||||
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
|
||||
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(),
|
||||
roots: new() { [103] = tx2.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var sync = new WalletSynchronizer(checkpointAccount, client);
|
||||
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
||||
|
||||
// 103 <= the memoized 105: anchoring must early-return without re-walking,
|
||||
// so the header/range call counts stay at those of the first walk (103 is cached).
|
||||
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_500_000, result.ConfirmedSats);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
// Wrong hash at the checkpoint height: the real chain does not match it.
|
||||
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||
Assert.Contains("checkpoint height 100", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_catena_di_header_spezzata_fa_fallire_la_sync()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||
// Tamper with block 103: it no longer points at block 102's real hash.
|
||||
chain[103] = Scenario.SingleTxHeaderHex(uint256.One, 103, prevHash: uint256.One);
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||
Assert.Contains("Broken header chain at height 103", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op()
|
||||
{
|
||||
// Regtest profile (Profile) has no checkpoints at all: same behaviour as before
|
||||
// this feature existed — only the Merkle proof is checked.
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
Assert.Equal(1, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
// ---- verifica progressiva (§7.4) ----
|
||||
|
||||
[Fact]
|
||||
public async Task PartialResult_arriva_prima_della_proof_poi_il_risultato_finale_e_completamente_verificato()
|
||||
{
|
||||
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;
|
||||
|
||||
// Gate the Merkle-proof response so the download phase can complete (and fire
|
||||
// PartialResult) well before verification does.
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||
{
|
||||
gate.Wait();
|
||||
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
|
||||
});
|
||||
|
||||
var sync = new WalletSynchronizer(account, client);
|
||||
SyncResult? partial = null;
|
||||
var partialReceived = new TaskCompletionSource();
|
||||
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
|
||||
|
||||
var syncTask = sync.SyncOnceAsync();
|
||||
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.NotNull(partial);
|
||||
Assert.False(Assert.Single(partial!.History).Verified);
|
||||
Assert.False(Assert.Single(partial.Utxos).Verified);
|
||||
Assert.Equal(1_000_000, partial.PendingVerificationSats);
|
||||
Assert.Equal(1_000_000, partial.ConfirmedSats); // reported confirmed, just not yet proof-checked
|
||||
|
||||
gate.Set();
|
||||
var final = await syncTask;
|
||||
|
||||
Assert.True(Assert.Single(final.History).Verified);
|
||||
Assert.True(Assert.Single(final.Utxos).Verified);
|
||||
Assert.Equal(0, final.PendingVerificationSats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_coinbase_immaturo_e_non_ancora_verificato_non_produce_un_saldo_spendibile_negativo()
|
||||
{
|
||||
// Regression: ImmatureSats and PendingVerificationSats can overlap (an immature
|
||||
// coinbase can also be unverified) — "ConfirmedSats - ImmatureSats - PendingVerificationSats"
|
||||
// double-subtracts that overlap and can go negative. SpendableSats must be computed
|
||||
// directly from IsSpendable instead.
|
||||
var account = Account();
|
||||
var scenario = new Scenario { TipHeight = 200 };
|
||||
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121 — immature.
|
||||
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;
|
||||
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||
{
|
||||
gate.Wait();
|
||||
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
|
||||
});
|
||||
|
||||
var sync = new WalletSynchronizer(account, client);
|
||||
var partialReceived = new TaskCompletionSource();
|
||||
SyncResult? partial = null;
|
||||
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
|
||||
|
||||
var syncTask = sync.SyncOnceAsync();
|
||||
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Immature AND unverified at the same time: both non-spendable subsets are the
|
||||
// full 5 PLM, but the actual spendable balance must be exactly zero, not negative.
|
||||
Assert.Equal(5_000_000_000, partial!.ImmatureSats);
|
||||
Assert.Equal(5_000_000_000, partial.PendingVerificationSats);
|
||||
Assert.Equal(0, partial.SpendableSats);
|
||||
|
||||
gate.Set();
|
||||
await syncTask;
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_download_di_transazioni_su_server_busy_vengono_ritentati()
|
||||
{
|
||||
// Same throttling scenario but on transaction.get, which runs inside the
|
||||
// download tasks (the non-generic retry overload, unlike get_history).
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
|
||||
var server = new FakeElectrumServer();
|
||||
scenario.WireTo(server);
|
||||
var failures = 2;
|
||||
var txs = scenario.Txs;
|
||||
server.Handle("blockchain.transaction.get", p =>
|
||||
{
|
||||
if (Interlocked.Decrement(ref failures) >= 0)
|
||||
throw new FakeElectrumError(-101, "server busy");
|
||||
return txs[p[0].GetString()!].ToHex();
|
||||
});
|
||||
|
||||
await using var _ = server;
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
Assert.Equal(funding.GetHash().ToString(), Assert.Single(result.History).Txid);
|
||||
}
|
||||
|
||||
// ---- cache su disco ----
|
||||
|
||||
[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,181 @@
|
||||
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/portable/default precedence has its own sandboxed tests in
|
||||
/// <see cref="AppPathsResolutionTests"/>; both classes share a collection
|
||||
/// because AppPaths state is static.
|
||||
/// </summary>
|
||||
[Collection("AppPaths")]
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Precedence tests for <see cref="AppPaths.DataRoot"/> — override → portable →
|
||||
/// pointer → default — using the internal bootstrap seams to sandbox the
|
||||
/// machine-global locations (APPDATA pointer dir, executable dir, default root).
|
||||
/// </summary>
|
||||
[Collection("AppPaths")]
|
||||
public sealed class AppPathsResolutionTests : IDisposable
|
||||
{
|
||||
private readonly string _sandbox;
|
||||
private readonly string? _savedOverride;
|
||||
|
||||
public AppPathsResolutionTests()
|
||||
{
|
||||
_sandbox = Path.Combine(Path.GetTempPath(), $"plm-paths-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(_sandbox);
|
||||
_savedOverride = AppPaths.OverrideDataRoot;
|
||||
AppPaths.OverrideDataRoot = null;
|
||||
AppPaths.BootstrapDirOverride = Path.Combine(_sandbox, "bootstrap");
|
||||
AppPaths.PortableBaseOverride = Path.Combine(_sandbox, "exe");
|
||||
AppPaths.DefaultRootOverride = Path.Combine(_sandbox, "default-root");
|
||||
Directory.CreateDirectory(AppPaths.PortableBaseOverride);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppPaths.OverrideDataRoot = _savedOverride;
|
||||
AppPaths.BootstrapDirOverride = null;
|
||||
AppPaths.PortableBaseOverride = null;
|
||||
AppPaths.DefaultRootOverride = null;
|
||||
if (Directory.Exists(_sandbox))
|
||||
Directory.Delete(_sandbox, recursive: true);
|
||||
}
|
||||
|
||||
private string PortableDir => Path.Combine(_sandbox, "exe", AppPaths.PortableDirName);
|
||||
|
||||
[Fact]
|
||||
public void Senza_alcuna_configurazione_vince_il_default_e_la_posizione_non_e_configurata()
|
||||
{
|
||||
Assert.Equal(Path.Combine(_sandbox, "default-root"), AppPaths.DataRoot());
|
||||
Assert.False(AppPaths.IsDataLocationConfigured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void La_cartella_portable_accanto_all_eseguibile_vince_sul_pointer_e_sul_default()
|
||||
{
|
||||
AppPaths.ConfigureDataLocation(Path.Combine(_sandbox, "custom"));
|
||||
Directory.CreateDirectory(PortableDir);
|
||||
|
||||
Assert.Equal(PortableDir, AppPaths.DataRoot());
|
||||
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_pointer_scritto_da_ConfigureDataLocation_vince_sul_default()
|
||||
{
|
||||
var custom = Path.Combine(_sandbox, "custom");
|
||||
AppPaths.ConfigureDataLocation($" {custom} "); // trims and creates
|
||||
|
||||
Assert.True(Directory.Exists(custom));
|
||||
Assert.Equal(custom, AppPaths.DataRoot());
|
||||
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_pointer_vuoto_viene_ignorato_e_si_ricade_sul_default()
|
||||
{
|
||||
Directory.CreateDirectory(AppPaths.BootstrapDirOverride!);
|
||||
File.WriteAllText(Path.Combine(AppPaths.BootstrapDirOverride!, "data-location"), " ");
|
||||
|
||||
Assert.Equal(Path.Combine(_sandbox, "default-root"), AppPaths.DataRoot());
|
||||
Assert.False(AppPaths.IsDataLocationConfigured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dati_gia_presenti_nel_default_contano_come_posizione_configurata()
|
||||
{
|
||||
var defaultRoot = Path.Combine(_sandbox, "default-root");
|
||||
Directory.CreateDirectory(defaultRoot);
|
||||
Assert.False(AppPaths.IsDataLocationConfigured()); // exists but empty
|
||||
|
||||
File.WriteAllText(Path.Combine(defaultRoot, "config.json"), "{}");
|
||||
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L_override_esplicito_vince_anche_su_portable_e_pointer()
|
||||
{
|
||||
Directory.CreateDirectory(PortableDir);
|
||||
AppPaths.ConfigureDataLocation(Path.Combine(_sandbox, "custom"));
|
||||
AppPaths.OverrideDataRoot = Path.Combine(_sandbox, "override");
|
||||
|
||||
Assert.Equal(Path.Combine(_sandbox, "override"), AppPaths.DataRoot());
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,22 @@ public class StorageTests
|
||||
Assert.NotEqual(c1, c2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")] // broken JSON
|
||||
[InlineData("null")] // JSON null
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1"}""")] // missing fields → null strings
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"$$$","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // bad base64
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"AA==","Nonce":"AA==","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // wrong nonce size
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":0,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // iterations 0
|
||||
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":2147483647,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // PBKDF2 DoS
|
||||
public void Un_contenitore_malformato_produce_sempre_InvalidDataException(string container)
|
||||
{
|
||||
// A tampered/corrupted file must map to the two typed exceptions, never to a
|
||||
// raw JsonException/FormatException/ArgumentNullException (found by fuzzing);
|
||||
// the absurd iteration count would otherwise hang the wallet at open.
|
||||
Assert.Throws<InvalidDataException>(() => EncryptedFile.Decrypt(container, "pass"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ogni_encrypt_produce_salt_diverso()
|
||||
{
|
||||
@@ -77,6 +93,31 @@ public class StorageTests
|
||||
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 ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -82,6 +82,55 @@ public class ImportedKeyAccountTests
|
||||
Assert.Null(account.GetPrivateKey(false, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lista_vuota_viene_rifiutata()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => new ImportedKeyAccount([], ScriptKind.NativeSegwit, Profile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAddress_fuori_range_o_change_ricade_sul_primo_indirizzo()
|
||||
{
|
||||
var keys = Enumerable.Range(0, 2).Select(_ => GenerateKey()).ToList();
|
||||
var entries = keys
|
||||
.Select(k => (k.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network), (Key?)k))
|
||||
.ToList();
|
||||
var account = new ImportedKeyAccount(entries, ScriptKind.NativeSegwit, Profile);
|
||||
var first = entries[0].Item1.ToString();
|
||||
|
||||
// Fund safety: change and any out-of-range index must map to the first
|
||||
// address, never to an address the wallet does not control.
|
||||
Assert.Equal(first, account.GetAddress(isChange: true, 1).ToString());
|
||||
Assert.Equal(first, account.GetAddress(isChange: false, -1).ToString());
|
||||
Assert.Equal(first, account.GetAddress(isChange: false, 99).ToString());
|
||||
Assert.Equal(first, account.GetChangeAddress(5).ToString());
|
||||
Assert.Equal(entries[1].Item1.ToString(), account.GetAddress(isChange: false, 1).ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPublicKey_segue_gli_stessi_confini_di_GetPrivateKey()
|
||||
{
|
||||
var key = GenerateKey();
|
||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||
var account = new ImportedKeyAccount([(addr, key)], ScriptKind.NativeSegwit, Profile);
|
||||
|
||||
Assert.Equal(key.PubKey, account.GetPublicKey(false, 0));
|
||||
Assert.Null(account.GetPublicKey(true, 0));
|
||||
Assert.Null(account.GetPublicKey(false, -1));
|
||||
Assert.Null(account.GetPublicKey(false, 99));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPublicKey_e_null_per_una_entry_watch_only()
|
||||
{
|
||||
var key = GenerateKey();
|
||||
var addr = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Network);
|
||||
var account = new ImportedKeyAccount([(addr, (Key?)null)], ScriptKind.NativeSegwit, Profile);
|
||||
|
||||
Assert.Null(account.GetPublicKey(false, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedAddresses_copre_tutti_gli_indirizzi()
|
||||
{
|
||||
|
||||
@@ -33,12 +33,28 @@ public class TransactionFactoryTests
|
||||
{
|
||||
Txid = txid, Vout = 0, ValueSats = sats,
|
||||
Address = account.GetReceiveAddress(0).ToString(),
|
||||
IsChange = false, AddressIndex = 0, Height = 100,
|
||||
IsChange = false, AddressIndex = 0, Height = 100, Verified = true,
|
||||
},
|
||||
};
|
||||
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_utxo_confermato_ma_non_ancora_verificato_non_e_spendibile()
|
||||
{
|
||||
// Confirmed by the server, but its Merkle proof hasn't been checked yet (progressive
|
||||
// background verification, §7.4): must never be treated as spendable — otherwise a
|
||||
// malicious server could get a fabricated balance spent before the forgery is caught.
|
||||
var account = Account();
|
||||
var (utxos, txs) = Fund(account, 1_000_000);
|
||||
utxos[0].Verified = false;
|
||||
|
||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(5), amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||
Assert.Contains("awaiting Merkle-proof verification", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
|
||||
{
|
||||
@@ -48,7 +64,7 @@ public class TransactionFactoryTests
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
utxos, txs, destination, amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
Assert.True(built.Signed);
|
||||
// Output: recipient + change.
|
||||
@@ -74,7 +90,7 @@ public class TransactionFactoryTests
|
||||
|
||||
var built = new TransactionFactory(account).Build(
|
||||
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);
|
||||
Assert.Equal(500_000, output.Value.Satoshi + built.Fee.Satoshi);
|
||||
@@ -88,7 +104,7 @@ public class TransactionFactoryTests
|
||||
|
||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 900_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,8 +116,23 @@ public class TransactionFactoryTests
|
||||
|
||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 100_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0));
|
||||
Assert.Contains("pending confirmation", ex.Message);
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||
Assert.Contains("unconfirmed", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Una_fee_oltre_la_policy_standard_viene_rifiutata_prima_del_broadcast()
|
||||
{
|
||||
// An absurd fee rate produces a fee far above NBitcoin's standard policy
|
||||
// cap: the builder.Verify safety net must refuse the transaction instead
|
||||
// of letting a fat-finger fee reach the network.
|
||||
var account = Account();
|
||||
var (utxos, txs) = Fund(account, 100_000_000); // 1 PLM
|
||||
|
||||
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
utxos, txs, account.GetReceiveAddress(1), amountSats: 1_000_000,
|
||||
feeRateSatPerVByte: 500_000, changeIndex: 0, tipHeight: 100));
|
||||
Assert.Contains("Invalid transaction", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -113,7 +144,62 @@ public class TransactionFactoryTests
|
||||
|
||||
Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||
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, Verified = true },
|
||||
};
|
||||
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]
|
||||
@@ -127,7 +213,7 @@ public class TransactionFactoryTests
|
||||
|
||||
var built = new TransactionFactory(watchOnly).Build(
|
||||
utxos, txs, full.GetReceiveAddress(5), amountSats: 400_000,
|
||||
feeRateSatPerVByte: 2, changeIndex: 0);
|
||||
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||
|
||||
Assert.False(built.Signed);
|
||||
// Air-gapped flow (§6.5): the online-machine PSBT is signed
|
||||
@@ -189,6 +275,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, Verified = true,
|
||||
});
|
||||
}
|
||||
|
||||
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]
|
||||
public void Una_config_corrotta_torna_ai_default()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
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 Un_output_OP_RETURN_con_testo_UTF8_viene_decodificato()
|
||||
{
|
||||
var account = Account();
|
||||
var tx = Net.CreateTransaction();
|
||||
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("hello palladium"u8.ToArray()));
|
||||
|
||||
var (server, client) = await StartAsync(tx);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: 1_000_000, verified: true);
|
||||
|
||||
var opReturn = Assert.Single(details.Outputs, o => o.Index == 1);
|
||||
Assert.Null(opReturn.Address);
|
||||
Assert.Equal("hello palladium", opReturn.OpReturnText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_output_OP_RETURN_con_dati_binari_ricade_su_hex()
|
||||
{
|
||||
var account = Account();
|
||||
byte[] data = [0x00, 0x01, 0xFF, 0xFE, 0x02];
|
||||
var tx = Net.CreateTransaction();
|
||||
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey(data));
|
||||
|
||||
var (server, client) = await StartAsync(tx);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: 1_000_000, verified: true);
|
||||
|
||||
var opReturn = Assert.Single(details.Outputs, o => o.Index == 1);
|
||||
Assert.Equal("0x" + Convert.ToHexString(data), opReturn.OpReturnText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Piu_output_OP_RETURN_nella_stessa_tx_sono_decodificati_singolarmente()
|
||||
{
|
||||
var account = Account();
|
||||
var tx = Net.CreateTransaction();
|
||||
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("first"u8.ToArray()));
|
||||
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("second"u8.ToArray()));
|
||||
|
||||
var (server, client) = await StartAsync(tx);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||
Owned(account), netSats: 1_000_000, verified: true);
|
||||
|
||||
Assert.Equal("first", details.Outputs.Single(o => o.Index == 1).OpReturnText);
|
||||
Assert.Equal("second", details.Outputs.Single(o => o.Index == 2).OpReturnText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_output_normale_non_ha_testo_OP_RETURN()
|
||||
{
|
||||
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: 101,
|
||||
Owned(account), netSats: -610_000, verified: true);
|
||||
|
||||
Assert.All(details.Outputs, o => Assert.Null(o.OpReturnText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Il_tag_pool_nello_scriptSig_della_coinbase_viene_estratto()
|
||||
{
|
||||
var account = Account();
|
||||
var coinbase = Net.CreateTransaction();
|
||||
// BIP34 height push (binary) followed by a pool tag (printable ASCII).
|
||||
var scriptSig = new Script(Op.GetPushOp(190), Op.GetPushOp("/slush/"u8.ToArray()));
|
||||
coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig));
|
||||
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);
|
||||
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.True(input.IsCoinbase);
|
||||
Assert.Contains("/slush/", input.CoinbaseTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uno_scriptSig_coinbase_senza_testo_stampabile_non_produce_tag()
|
||||
{
|
||||
var account = Account();
|
||||
var coinbase = Net.CreateTransaction();
|
||||
var scriptSig = new Script(Op.GetPushOp([0x00, 0x01, 0x02]));
|
||||
coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig));
|
||||
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);
|
||||
|
||||
var input = Assert.Single(details.Inputs);
|
||||
Assert.Null(input.CoinbaseTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_input_normale_non_ha_tag_coinbase()
|
||||
{
|
||||
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: 101,
|
||||
Owned(account), netSats: -610_000, verified: true);
|
||||
|
||||
Assert.All(details.Inputs, i => Assert.Null(i.CoinbaseTag));
|
||||
}
|
||||
|
||||
[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(ValidMnemonic, doc.Mnemonic);
|
||||
Assert.Null(doc.Passphrase);
|
||||
Assert.NotEmpty(doc.AccountXpub);
|
||||
Assert.NotEmpty(doc.MasterFingerprint);
|
||||
Assert.NotEmpty(doc.AccountXpub!);
|
||||
Assert.NotEmpty(doc.MasterFingerprint!);
|
||||
Assert.False(doc.IsWatchOnly);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ public class WalletLoaderTests
|
||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
||||
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
Assert.Equal("mainnet", doc.Network);
|
||||
Assert.NotEmpty(doc.AccountXpub);
|
||||
Assert.NotEmpty(doc.AccountXpub!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -186,4 +186,67 @@ public class WalletLoaderTests
|
||||
AccountPath = "84'/0'/0'", AccountXpub = "xpub" };
|
||||
Assert.Equal(expected, WalletLoader.ProfileOf(doc).Kind);
|
||||
}
|
||||
|
||||
// ---- corrupted/incomplete documents (defensive branches of ToAccount) ----
|
||||
|
||||
private static WalletDocument EmptyDoc() => new()
|
||||
{
|
||||
Network = "mainnet",
|
||||
ScriptKind = "NativeSegwit",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ToAccount_mnemonica_corrotta_nel_file_lancia_eccezione()
|
||||
{
|
||||
var doc = EmptyDoc();
|
||||
doc.Mnemonic = "not a valid mnemonic at all";
|
||||
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(doc));
|
||||
Assert.Contains("mnemonic", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAccount_xprv_corrotta_nel_file_lancia_eccezione()
|
||||
{
|
||||
var doc = EmptyDoc();
|
||||
doc.AccountXprv = "zprvGarbageGarbageGarbage";
|
||||
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(doc));
|
||||
Assert.Contains("Xprv", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAccount_xpub_corrotta_nel_file_lancia_eccezione()
|
||||
{
|
||||
var doc = EmptyDoc();
|
||||
doc.AccountXpub = "zpubGarbageGarbageGarbage";
|
||||
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(doc));
|
||||
Assert.Contains("Xpub", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAccount_documento_senza_alcuna_chiave_lancia_eccezione()
|
||||
{
|
||||
var ex = Assert.Throws<InvalidDataException>(() => WalletLoader.ToAccount(EmptyDoc()));
|
||||
Assert.Contains("no xpub and no seed", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromXpub_chiave_invalida_lancia_eccezione()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromXpub("zpubGarbage", ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromXprv_chiave_invalida_lancia_eccezione()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromXprv("zprvGarbage", ChainProfiles.Mainnet));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewFromWif_senza_chiavi_lancia_eccezione()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(
|
||||
() => WalletLoader.NewFromWif([], ScriptKind.NativeSegwit, ChainProfiles.Mainnet));
|
||||
}
|
||||
}
|
||||
|
||||
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