Compare commits
9 Commits
0.9.0
..
c75e3921aa
| Author | SHA1 | Date | |
|---|---|---|---|
| c75e3921aa | |||
| 50d8e7f21a | |||
| 15978bf564 | |||
| 31aabd0856 | |||
| 56ce1d4259 | |||
| 448f7dc65d | |||
| e8ff99a768 | |||
| 27231c8eec | |||
| eb7ec9e835 |
@@ -1,6 +1,8 @@
|
|||||||
# AGENTS.md
|
# 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
|
## Role
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ Operate as an **expert in cryptocurrencies and cryptography**: reason with the d
|
|||||||
|
|
||||||
## How to assist
|
## 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.
|
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
@@ -26,9 +28,10 @@ SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin
|
|||||||
```
|
```
|
||||||
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
src/Core/ Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/ (no UI dependency)
|
||||||
src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Assets)
|
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.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity -> apk
|
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||||
src/Cli/ CLI on the same Core tests/ xUnit
|
src/Cli/ CLI on the same Core tests/ xUnit
|
||||||
|
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
The Avalonia UI lives **once** in `src/App` (a library); the two heads only carry the
|
||||||
@@ -43,11 +46,12 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
|||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- Build: `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` -- single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args -> usage)
|
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
- Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true --self-contained`
|
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
- Linux publish: `dotnet publish src/App.Desktop -r linux-x64 --self-contained` (then AppImage via PupNet Deploy)
|
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
||||||
|
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||||
|
|
||||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
||||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
||||||
@@ -62,23 +66,24 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
|
|
||||||
## Architecture (points that require reading multiple files)
|
## Architecture (points that require reading multiple files)
|
||||||
|
|
||||||
- **Layers:** 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.
|
- **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**. 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.
|
- **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).
|
- **PSBT-centric:** every signing flow goes through PSBT (offline/air-gapped/multisig/hardware).
|
||||||
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
- **Ports:** 50001/50002 = indexing server (what the SPV wallet talks to), **not** the node's P2P port (2333).
|
||||||
|
|
||||||
## GUI conventions (`src/App`)
|
## GUI conventions (`src/App`)
|
||||||
|
|
||||||
- **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), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here. It is 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.
|
- **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.
|
- **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 = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. On startup `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release and `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.
|
- **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
|
## 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.
|
- **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:** bump the version with `./update-version.sh` (interactive) — it updates `<Version>` in the App csproj (the single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, which **must strictly increase** every release or users can't update in place), and stubs a `CHANGELOG.md` entry. Fill in that entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually.
|
||||||
|
|||||||
@@ -5,6 +5,54 @@ Technical changelog for PalladiumWallet. Format loosely follows
|
|||||||
by subsystem rather than strictly by date, since `0.9.0` is the first
|
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||||
release and covers the full history from the initial commit.
|
release and covers the full history from the initial commit.
|
||||||
|
|
||||||
|
## [0.9.1] — 2026-07-02
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Test suite expanded from 239 to 307 tests; `Core` line coverage raised
|
||||||
|
from ~50% to ~92% (branch coverage from ~41% to ~79%).
|
||||||
|
- In-process fake ElectrumX server (`tests/.../Net/FakeElectrumServer.cs`):
|
||||||
|
a real loopback TCP socket speaking newline-delimited JSON-RPC 2.0,
|
||||||
|
optionally TLS with a self-signed certificate, with per-method handlers
|
||||||
|
and call counters — exercises the network stack against real socket code
|
||||||
|
instead of mocks.
|
||||||
|
- New end-to-end coverage for previously untested network/SPV code:
|
||||||
|
`ElectrumClient` (request pipelining, error mapping, notifications,
|
||||||
|
disconnection, cancellation, TLS/TOFU handshake), `WalletSynchronizer`
|
||||||
|
(gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature
|
||||||
|
balances, busy-retry, disk-cache reuse, and the security-critical path
|
||||||
|
where a lying server fails Merkle verification and the sync aborts),
|
||||||
|
`TransactionInspector` (fee calculation, mine/theirs attribution, RBF,
|
||||||
|
coinbase handling), `CertificatePinStore` (TOFU pin/match/mismatch/reset),
|
||||||
|
`ServerRegistry` peer discovery, and `UpdateChecker` tag parsing.
|
||||||
|
- `TransactionFactory`: added coverage for legacy/P2SH/segwit destinations,
|
||||||
|
multi-UTXO selection, dust change absorbed into the fee, and a golden
|
||||||
|
txid anchoring the PSBT signing path (deterministic via RFC 6979).
|
||||||
|
- Property-based tests extended: SLIP-132 roundtrip for every script kind
|
||||||
|
and network, `WalletDocument` JSON roundtrip with arbitrary
|
||||||
|
labels/contacts, `Scripthash` cross-checked against an independent
|
||||||
|
SHA-256 computation.
|
||||||
|
- `update-version.sh`: single script to bump the version across the project
|
||||||
|
ahead of a release tag.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `CertificatePinStore.Load`: a corrupted pin file threw and blocked every
|
||||||
|
SSL connection until the user manually deleted it; now falls back to
|
||||||
|
first-contact TOFU, same as `ServerRegistry` already did for its own file.
|
||||||
|
- `EncryptedFile.IsEncrypted`: threw on valid JSON with a non-object root
|
||||||
|
(e.g. a bare number or array) or on invalid UTF-16 input, instead of
|
||||||
|
returning `false`. Both bugs were found by the expanded property tests.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `README.md`: "Running tests" section rewritten with a per-area coverage
|
||||||
|
table, the coverage-measurement command, and a description of the fake
|
||||||
|
ElectrumX server.
|
||||||
|
- `CLAUDE.md`/`AGENTS.md` kept in sync, pointing future work at extending
|
||||||
|
the fake ElectrumX server instead of mocking `ElectrumClient` (not an
|
||||||
|
interface, by design).
|
||||||
|
|
||||||
## [0.9.0] — 2026-07-02
|
## [0.9.0] — 2026-07-02
|
||||||
|
|
||||||
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
|
First release. SPV wallet (Sparrow-style) for the Palladium (PLM) network —
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## 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 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.
|
||||||
@@ -44,7 +46,7 @@ by `MainWindow` on desktop and as the single-view root on Android.
|
|||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- Build: `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s
|
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design.
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place.
|
||||||
@@ -74,14 +76,14 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
## GUI conventions (`src/App`)
|
## 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), so it works both as a desktop window's content and as Android's single-view root. Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them. `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||||
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here. It is 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.
|
- **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.
|
- **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 = `<Version>` in `src/App/PalladiumWallet.App.csproj`; read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. On startup `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release and `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.
|
- **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
|
## 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.
|
- **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:** whenever a new version tag is created (bumping `<Version>` in `src/App/PalladiumWallet.App.csproj`), update `CHANGELOG.md` with an entry for that version before/with the tag — it's the technical record of what shipped, not optional bookkeeping.
|
- **Releases:** bump the version with `./update-version.sh` (interactive) — it updates `<Version>` in the App csproj (the single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, which **must strictly increase** every release or users can't update in place), and stubs a `CHANGELOG.md` entry. Fill in that entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually.
|
||||||
|
|||||||
@@ -139,9 +139,34 @@ Run only the tests in one project:
|
|||||||
dotnet test tests/PalladiumWallet.Tests
|
dotnet test tests/PalladiumWallet.Tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`):
|
||||||
|
```bash
|
||||||
|
dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"
|
||||||
|
```
|
||||||
|
|
||||||
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
||||||
|
|
||||||
The suite includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
### What the suite covers
|
||||||
|
|
||||||
|
The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
|
||||||
|
|
||||||
|
| Area | What is verified |
|
||||||
|
|---|---|
|
||||||
|
| `Chain/` | Network profiles (prefixes, ports, coin type 746, SLIP-132 headers), NBitcoin network registration |
|
||||||
|
| `Crypto/` | BIP39 (official Trezor vectors, NFKD normalisation), BIP32/44/49/84/86 derivation against public golden vectors, SLIP-132 encode/decode, HD and imported-key accounts, watch-only isolation |
|
||||||
|
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the path where a lying server fails Merkle verification and the sync must abort |
|
||||||
|
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), release-tag parsing |
|
||||||
|
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data paths |
|
||||||
|
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting |
|
||||||
|
|
||||||
|
Network-facing code is tested against an **in-process fake ElectrumX server**
|
||||||
|
(`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`): a real loopback TCP
|
||||||
|
socket speaking newline-delimited JSON-RPC (optionally TLS with a self-signed
|
||||||
|
certificate), with per-method handlers and call counters. Client, synchroniser
|
||||||
|
and inspector therefore exercise the same code paths used in production —
|
||||||
|
framing, retries, TLS pinning included — without any external dependency.
|
||||||
|
|
||||||
|
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -22,7 +22,7 @@ It does **not** protect against:
|
|||||||
This wallet is an SPV client, not a full node. It validates:
|
This wallet is an SPV client, not a full node. It validates:
|
||||||
|
|
||||||
- Block headers (proof of work checked up to the last checkpoint; `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side — trust is anchored to hardcoded checkpoints in `Core/Chain/ChainProfiles.cs`)
|
- Block headers (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`)
|
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
|
||||||
|
|
||||||
It does **not** validate:
|
It does **not** validate:
|
||||||
|
|
||||||
@@ -73,6 +73,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
|
## Known limitations and out-of-scope for v1
|
||||||
|
|
||||||
- No Tor/proxy support (network traffic reveals which addresses are being queried)
|
- No Tor/proxy support (network traffic reveals which addresses are being queried)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
<ApplicationVersion>2</ApplicationVersion>
|
||||||
<ApplicationDisplayVersion>0.9.0</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>0.9.1</ApplicationDisplayVersion>
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||||
<Version>0.9.0</Version>
|
<Version>0.9.1</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -58,10 +58,21 @@ public sealed class CertificatePinStore(string filePath)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Dictionary<string, string> Load() =>
|
private Dictionary<string, string> Load()
|
||||||
File.Exists(filePath)
|
{
|
||||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
|
if (!File.Exists(filePath))
|
||||||
: [];
|
return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// A corrupt pin file must not make every SSL connection fail forever:
|
||||||
|
// fall back to first-contact TOFU (same trust level as the bootstrap).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void Save(Dictionary<string, string> pins)
|
private void Save(Dictionary<string, string> pins)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public static class UpdateChecker
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
/// <summary>Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable <see cref="Version"/>.</summary>
|
||||||
private static bool TryParse(string raw, out Version version)
|
internal static bool TryParse(string raw, out Version version)
|
||||||
{
|
{
|
||||||
var s = raw.Trim();
|
var s = raw.Trim();
|
||||||
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..];
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
<PackageReference Include="NBitcoin" Version="10.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -26,13 +26,21 @@ public static class EncryptedFile
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(fileContent);
|
using var doc = JsonDocument.Parse(fileContent);
|
||||||
return doc.RootElement.TryGetProperty("Format", out var f)
|
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||||
|
&& doc.RootElement.TryGetProperty("Format", out var f)
|
||||||
|
&& f.ValueKind == JsonValueKind.String
|
||||||
&& f.GetString() == Format;
|
&& f.GetString() == Format;
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Invalid UTF-16 (lone surrogates) cannot be transcoded for parsing:
|
||||||
|
// certainly not an encrypted container.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Encrypt(string plaintext, string password)
|
public static string Encrypt(string plaintext, string password)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class AddressDerivationTests
|
|||||||
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
// Known abandon-about address at m/44'/0'/0'/0/0 (public reference).
|
||||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.Legacy,
|
||||||
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
ChainProfiles.Mainnet, new KeyPath("44'/0'/0'"));
|
||||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
var pubKey = account.GetPublicKey(isChange: false, 0)!;
|
||||||
|
|
||||||
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
|
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.Legacy, Network.Main);
|
||||||
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
|
Assert.Equal("1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", bitcoinAddr.ToString());
|
||||||
@@ -37,7 +37,7 @@ public class AddressDerivationTests
|
|||||||
{
|
{
|
||||||
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
|
var account = HdAccount.FromSeed(AbandonAboutSeed(), ScriptKind.WrappedSegwit,
|
||||||
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
|
ChainProfiles.Mainnet, new KeyPath("49'/0'/0'"));
|
||||||
var pubKey = account.GetPublicKey(isChange: false, 0);
|
var pubKey = account.GetPublicKey(isChange: false, 0)!;
|
||||||
|
|
||||||
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
|
var bitcoinAddr = pubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, Network.Main);
|
||||||
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
|
Assert.Equal("37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", bitcoinAddr.ToString());
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ public class Bip84Slip132Tests
|
|||||||
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
|
bool isChange, int index, string? expectedPubKeyHex, string bitcoinAddress)
|
||||||
{
|
{
|
||||||
var account = Account();
|
var account = Account();
|
||||||
var pubKey = account.GetPublicKey(isChange, index);
|
var pubKey = account.GetPublicKey(isChange, index)!;
|
||||||
|
|
||||||
if (expectedPubKeyHex is not null)
|
if (expectedPubKeyHex is not null)
|
||||||
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
Assert.Equal(expectedPubKeyHex, pubKey.ToHex());
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
|
||||||
|
/// certificate passes, changed certificate is rejected until an explicit reset.
|
||||||
|
/// </summary>
|
||||||
|
public class CertificatePinStoreTests
|
||||||
|
{
|
||||||
|
private static string TempPath() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
|
||||||
|
|
||||||
|
private static X509Certificate2 Cert(string cn) =>
|
||||||
|
FakeElectrumServer.CreateSelfSignedCertificate(cn);
|
||||||
|
|
||||||
|
private static void Cleanup(string path)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Path.GetDirectoryName(path)))
|
||||||
|
Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert = Cert("server");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
|
||||||
|
|
||||||
|
// A fresh instance reads the same file (persistence).
|
||||||
|
Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_certificato_cambiato_viene_rifiutato()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var original = Cert("originale");
|
||||||
|
using var changed = Cert("cambiato");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||||
|
Assert.False(store.VerifyOrPin("host", 50002, changed));
|
||||||
|
// The rejection must not overwrite the pin.
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Host_e_porte_diverse_hanno_pin_indipendenti()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert1 = Cert("uno");
|
||||||
|
using var cert2 = Cert("due");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
|
||||||
|
Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
|
||||||
|
Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
|
||||||
|
Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var original = Cert("originale");
|
||||||
|
using var renewed = Cert("rinnovato");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||||
|
Assert.False(store.VerifyOrPin("host", 50002, renewed));
|
||||||
|
|
||||||
|
Assert.True(store.Reset("host", 50002));
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
|
||||||
|
Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_reset_di_un_server_mai_visto_restituisce_false()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetAll_cancella_il_file_e_tutti_i_pin()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert1 = Cert("uno");
|
||||||
|
using var cert2 = Cert("due");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
store.VerifyOrPin("host-a", 50002, cert1);
|
||||||
|
store.VerifyOrPin("host-b", 50002, cert1);
|
||||||
|
|
||||||
|
store.ResetAll();
|
||||||
|
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_file_pin_corrotto_non_blocca_le_connessioni()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, "{ non-json ");
|
||||||
|
|
||||||
|
using var cert = Cert("server");
|
||||||
|
var store = new CertificatePinStore(path);
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
|
||||||
|
Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
|
||||||
|
}
|
||||||
|
finally { Cleanup(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
|
||||||
|
{
|
||||||
|
using var cert = Cert("server");
|
||||||
|
var fingerprint = CertificatePinStore.Fingerprint(cert);
|
||||||
|
|
||||||
|
Assert.Equal(64, fingerprint.Length);
|
||||||
|
Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
|
||||||
|
Assert.Equal(
|
||||||
|
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
|
||||||
|
.ToLowerInvariant(),
|
||||||
|
fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
|
||||||
|
/// (real loopback TCP socket: framing, pipelining, and failure paths are the
|
||||||
|
/// same code paths used in production).
|
||||||
|
/// </summary>
|
||||||
|
public class ElectrumClientTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connessione_e_richiesta_fanno_roundtrip()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.banner", _ => "benvenuto");
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
Assert.True(client.IsConnected);
|
||||||
|
Assert.Equal("benvenuto", await client.BannerAsync());
|
||||||
|
// ConnectAsync performs the protocol handshake up front.
|
||||||
|
Assert.Equal(1, server.CallCount("server.version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_errore_del_server_diventa_ElectrumServerException()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.transaction.get",
|
||||||
|
_ => throw new FakeElectrumError(-32600, "tx non trovata"));
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<ElectrumServerException>(
|
||||||
|
() => client.GetTransactionAsync("00"));
|
||||||
|
Assert.Contains("tx non trovata", ex.Message);
|
||||||
|
Assert.Contains("-32600", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("echo", p => p[0].GetInt32());
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
// More requests than the in-flight cap (32): exercises both the write
|
||||||
|
// batching and the id → response correlation.
|
||||||
|
var tasks = Enumerable.Range(0, 200)
|
||||||
|
.Select(i => client.RequestAsync("echo", default, i))
|
||||||
|
.ToList();
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
for (var i = 0; i < results.Length; i++)
|
||||||
|
Assert.Equal(i, results[i].GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
|
||||||
|
|
||||||
|
await server.NotifyAsync("blockchain.headers.subscribe", new object[]
|
||||||
|
{
|
||||||
|
new { height = 4242, hex = "00" },
|
||||||
|
});
|
||||||
|
|
||||||
|
var (method, parameters) = await received.Task.WaitAsync(Timeout);
|
||||||
|
Assert.Equal("blockchain.headers.subscribe", method);
|
||||||
|
Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
client.Disconnected += _ => disconnected.TrySetResult();
|
||||||
|
|
||||||
|
var pending = client.BannerAsync();
|
||||||
|
server.DropClients();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
|
||||||
|
await disconnected.Task.WaitAsync(Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var pending = client.BannerAsync(cts.Token);
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending.WaitAsync(Timeout));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.scripthash.listunspent", _ => new object[]
|
||||||
|
{
|
||||||
|
new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
|
||||||
|
});
|
||||||
|
server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
|
||||||
|
server.Handle("blockchain.estimatefee", _ => 0.00012m);
|
||||||
|
server.Handle("blockchain.relayfee", _ => 0.00001m);
|
||||||
|
server.Handle("server.ping", _ => null);
|
||||||
|
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
|
||||||
|
Assert.Equal(12_345L, unspent.ValueSats);
|
||||||
|
Assert.Equal(1, unspent.TxPos);
|
||||||
|
Assert.Equal(99, unspent.Height);
|
||||||
|
|
||||||
|
Assert.Equal("4", await client.BroadcastAsync("beef"));
|
||||||
|
Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
|
||||||
|
Assert.Equal(0.00001m, await client.RelayFeeAsync());
|
||||||
|
await client.PingAsync(); // must not throw
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
|
||||||
|
{
|
||||||
|
var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pins = new CertificatePinStore(pinsPath);
|
||||||
|
int port;
|
||||||
|
|
||||||
|
// First contact: any certificate is pinned (trust on first use).
|
||||||
|
using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer(cert1);
|
||||||
|
port = server.Port;
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(
|
||||||
|
server.Host, port, useSsl: true, pins);
|
||||||
|
Assert.True(client.IsConnected);
|
||||||
|
Assert.True(client.UseSsl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same host:port, different certificate: the pin must block the connection.
|
||||||
|
using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
|
||||||
|
{
|
||||||
|
await using var server = await RebindAsync(cert2, port);
|
||||||
|
await Assert.ThrowsAsync<CertificatePinMismatchException>(() =>
|
||||||
|
ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(pinsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebinds a fresh server to the port just released by the previous one.</summary>
|
||||||
|
private static async Task<FakeElectrumServer> RebindAsync(
|
||||||
|
System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
try { return new FakeElectrumServer(cert, port); }
|
||||||
|
catch (System.Net.Sockets.SocketException) when (attempt < 20)
|
||||||
|
{
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error a handler can throw to make the fake server reply with a JSON-RPC
|
||||||
|
/// error object (e.g. code -102 "server busy" to exercise the retry path).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeElectrumError(int code, string message) : Exception(message)
|
||||||
|
{
|
||||||
|
public int Code { get; } = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
|
||||||
|
/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
|
||||||
|
/// Handlers are registered per method and receive the request "params" array;
|
||||||
|
/// whatever they return is serialised as the "result". "server.version" is
|
||||||
|
/// pre-registered. Calls are counted per method (cache/retry assertions).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeElectrumServer : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly TcpListener _listener;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly Task _acceptLoop;
|
||||||
|
private readonly X509Certificate2? _certificate;
|
||||||
|
private readonly ConcurrentDictionary<string, Func<JsonElement, object?>> _handlers = new();
|
||||||
|
private readonly ConcurrentDictionary<string, int> _callCounts = new();
|
||||||
|
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
|
||||||
|
|
||||||
|
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
|
||||||
|
public static readonly object NoResponse = new();
|
||||||
|
|
||||||
|
public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
|
||||||
|
{
|
||||||
|
_certificate = certificate;
|
||||||
|
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||||
|
_listener.Start();
|
||||||
|
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
|
||||||
|
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||||
|
public string Host => "127.0.0.1";
|
||||||
|
|
||||||
|
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
|
||||||
|
public void Handle(string method, Func<JsonElement, object?> handler) =>
|
||||||
|
_handlers[method] = handler;
|
||||||
|
|
||||||
|
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
|
||||||
|
|
||||||
|
public void ResetCallCounts() => _callCounts.Clear();
|
||||||
|
|
||||||
|
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
|
||||||
|
public async Task NotifyAsync(string method, object parameters)
|
||||||
|
{
|
||||||
|
var line = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
method,
|
||||||
|
@params = parameters,
|
||||||
|
});
|
||||||
|
foreach (var (stream, writeLock, _) in _clients)
|
||||||
|
await WriteLineAsync(stream, writeLock, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
|
||||||
|
public void DropClients()
|
||||||
|
{
|
||||||
|
foreach (var (_, _, tcp) in _clients)
|
||||||
|
tcp.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
|
||||||
|
public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var request = new CertificateRequest(
|
||||||
|
$"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
using var cert = request.CreateSelfSigned(
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
|
||||||
|
// PFX roundtrip so the private key is usable by SslStream on every platform.
|
||||||
|
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptLoopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
|
||||||
|
_ = Task.Run(() => ServeClientAsync(tcp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (SocketException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ServeClientAsync(TcpClient tcp)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Stream stream = tcp.GetStream();
|
||||||
|
if (_certificate is not null)
|
||||||
|
{
|
||||||
|
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
|
||||||
|
await ssl.AuthenticateAsServerAsync(_certificate);
|
||||||
|
stream = ssl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var writeLock = new SemaphoreSlim(1, 1);
|
||||||
|
_clients.Add((stream, writeLock, tcp));
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var line = await reader.ReadLineAsync(_cts.Token);
|
||||||
|
if (line is null) break;
|
||||||
|
if (line.Length == 0) continue;
|
||||||
|
await HandleRequestAsync(stream, writeLock, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Client went away or TLS failed (e.g. rejected pin): normal in tests.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(line);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var id = root.GetProperty("id").GetInt64();
|
||||||
|
var method = root.GetProperty("method").GetString()!;
|
||||||
|
var parameters = root.TryGetProperty("params", out var p) ? p : default;
|
||||||
|
|
||||||
|
_callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
|
||||||
|
|
||||||
|
byte[] payload;
|
||||||
|
if (!_handlers.TryGetValue(method, out var handler))
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
error = new { code = -32601, message = $"unknown method '{method}'" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = handler(parameters);
|
||||||
|
if (ReferenceEquals(result, NoResponse))
|
||||||
|
return;
|
||||||
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (FakeElectrumError err)
|
||||||
|
{
|
||||||
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id,
|
||||||
|
error = new { code = err.Code, message = err.Message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteLineAsync(stream, writeLock, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
|
||||||
|
{
|
||||||
|
await writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stream.WriteAsync(payload);
|
||||||
|
stream.WriteByte((byte)'\n');
|
||||||
|
await stream.FlushAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Connection already gone: irrelevant for the test in progress.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
writeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _cts.CancelAsync();
|
||||||
|
_listener.Stop();
|
||||||
|
DropClients();
|
||||||
|
try { await _acceptLoop; } catch { }
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,49 @@ public class ServerRegistryTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("server.peers.subscribe", _ => new object[]
|
||||||
|
{
|
||||||
|
// Full announcement, port-less announcement (→ profile defaults),
|
||||||
|
// and a bootstrap duplicate that must not be re-added.
|
||||||
|
new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
|
||||||
|
new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
|
||||||
|
new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
|
||||||
|
});
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||||
|
var bootstrapCount = registry.All.Count;
|
||||||
|
|
||||||
|
var added = await registry.DiscoverAsync(client);
|
||||||
|
|
||||||
|
Assert.Equal(2, added);
|
||||||
|
Assert.Equal(bootstrapCount + 2, registry.All.Count);
|
||||||
|
var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
|
||||||
|
Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
|
||||||
|
Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
|
||||||
|
|
||||||
|
// Persisted for the next session (bootstrap servers excluded from the file).
|
||||||
|
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||||
|
Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
|
||||||
|
var savedJson = File.ReadAllText(path);
|
||||||
|
Assert.DoesNotContain("173.212.224.67", savedJson);
|
||||||
|
|
||||||
|
// A second discovery of the same peers adds nothing.
|
||||||
|
Assert.Equal(0, await registry.DiscoverAsync(client));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the release-tag parsing used by the update check. The network
|
||||||
|
/// call itself is best-effort by design (any failure → null) and is not
|
||||||
|
/// exercised here: only the version-comparison logic is deterministic.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateCheckerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("v1.2.3", "1.2.3")]
|
||||||
|
[InlineData("V0.9.1", "0.9.1")]
|
||||||
|
[InlineData("1.2.3", "1.2.3")]
|
||||||
|
[InlineData("0.9.0-beta", "0.9.0")]
|
||||||
|
[InlineData("v2.0.0-rc.1", "2.0.0")]
|
||||||
|
[InlineData(" 1.4 ", "1.4")]
|
||||||
|
public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
|
||||||
|
{
|
||||||
|
Assert.True(UpdateChecker.TryParse(tag, out var version));
|
||||||
|
Assert.Equal(Version.Parse(expected), version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("main")]
|
||||||
|
[InlineData("v")]
|
||||||
|
[InlineData("1")]
|
||||||
|
[InlineData("non-una-versione")]
|
||||||
|
public void I_tag_non_versionati_vengono_rifiutati(string tag)
|
||||||
|
{
|
||||||
|
Assert.False(UpdateChecker.TryParse(tag, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
|
||||||
|
{
|
||||||
|
// The same comparison CheckAsync applies between remote tag and running app.
|
||||||
|
Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
|
||||||
|
Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
|
||||||
|
Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,121 @@ public class PropertyTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scripthash ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The scripthash must equal an independent SHA-256-and-reverse computation
|
||||||
|
/// for any script bytes (cross-check, not a re-run of the same code).
|
||||||
|
[Fact]
|
||||||
|
public void Scripthash_coincide_con_il_calcolo_indipendente_per_ogni_script()
|
||||||
|
{
|
||||||
|
Gen.Byte.Array[0, 64].Sample(bytes =>
|
||||||
|
{
|
||||||
|
var expected = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||||
|
Array.Reverse(expected);
|
||||||
|
Assert.Equal(
|
||||||
|
Convert.ToHexString(expected).ToLowerInvariant(),
|
||||||
|
Core.Spv.Scripthash.FromScript(Script.FromBytesUnsafe(bytes)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slip132 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly Gen<Core.Chain.ScriptKind> GenScriptKind = Gen.OneOfConst(
|
||||||
|
Core.Chain.ScriptKind.Legacy,
|
||||||
|
Core.Chain.ScriptKind.WrappedSegwit,
|
||||||
|
Core.Chain.ScriptKind.NativeSegwit,
|
||||||
|
Core.Chain.ScriptKind.WrappedSegwitMultisig,
|
||||||
|
Core.Chain.ScriptKind.NativeSegwitMultisig);
|
||||||
|
|
||||||
|
private static readonly Gen<Core.Chain.ChainProfile> GenProfile = Gen.OneOfConst(
|
||||||
|
Core.Chain.ChainProfiles.Mainnet, Core.Chain.ChainProfiles.Testnet);
|
||||||
|
|
||||||
|
/// Encode → TryDecodePrivate must roundtrip key bytes and header family
|
||||||
|
/// for any key, script kind, and network.
|
||||||
|
[Fact]
|
||||||
|
public void Slip132_roundtrip_chiave_privata_per_ogni_kind_e_rete()
|
||||||
|
{
|
||||||
|
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
|
||||||
|
{
|
||||||
|
var key = ExtKey.CreateFromSeed(seed);
|
||||||
|
var encoded = Core.Crypto.Slip132.Encode(key, kind, profile);
|
||||||
|
|
||||||
|
Assert.True(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out var decoded, out var decodedKind));
|
||||||
|
Assert.Equal(key.ToBytes(), decoded!.ToBytes());
|
||||||
|
// Legacy and Taproot share the BIP32 header: compare header families, not enum values.
|
||||||
|
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
|
||||||
|
// A private key must never decode as public.
|
||||||
|
Assert.False(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out _, out _));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same roundtrip for the public (watch-only import) side.
|
||||||
|
[Fact]
|
||||||
|
public void Slip132_roundtrip_chiave_pubblica_per_ogni_kind_e_rete()
|
||||||
|
{
|
||||||
|
Gen.Select(Gen.Byte.Array[32, 64], GenScriptKind, GenProfile).Sample((seed, kind, profile) =>
|
||||||
|
{
|
||||||
|
var xpub = ExtKey.CreateFromSeed(seed).Neuter();
|
||||||
|
var encoded = Core.Crypto.Slip132.Encode(xpub, kind, profile);
|
||||||
|
|
||||||
|
Assert.True(Core.Crypto.Slip132.TryDecodePublic(encoded, profile, out var decoded, out var decodedKind));
|
||||||
|
Assert.Equal(xpub.ToBytes(), decoded!.ToBytes());
|
||||||
|
Assert.Equal(profile.ExtKeyHeaders[kind], profile.ExtKeyHeaders[decodedKind]);
|
||||||
|
Assert.False(Core.Crypto.Slip132.TryDecodePrivate(encoded, profile, out _, out _));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TryDecode must never throw on arbitrary input strings.
|
||||||
|
[Fact]
|
||||||
|
public void Slip132_TryDecode_non_lancia_mai_su_input_arbitrario()
|
||||||
|
{
|
||||||
|
Gen.String.Sample(text =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core.Crypto.Slip132.TryDecodePublic(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
|
||||||
|
Core.Crypto.Slip132.TryDecodePrivate(text, Core.Chain.ChainProfiles.Mainnet, out _, out _);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Assert.Fail($"TryDecode threw {ex.GetType().Name} for '{text}'");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WalletDocument ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// ToJson → FromJson must preserve labels and contacts for arbitrary strings
|
||||||
|
/// (unicode, quotes, control characters…).
|
||||||
|
[Fact]
|
||||||
|
public void WalletDocument_roundtrip_json_con_etichette_e_contatti_arbitrari()
|
||||||
|
{
|
||||||
|
Gen.Select(Gen.Select(Gen.String, Gen.String).Array[0, 8], Gen.String).Sample((pairs, contactName) =>
|
||||||
|
{
|
||||||
|
// Lone UTF-16 surrogates are not representable in JSON (the writer
|
||||||
|
// replaces them): an exact roundtrip is impossible by design, skip.
|
||||||
|
if (pairs.Any(p => p.Item1.Any(char.IsSurrogate) || p.Item2.Any(char.IsSurrogate))
|
||||||
|
|| contactName.Any(char.IsSurrogate))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var doc = new WalletDocument
|
||||||
|
{
|
||||||
|
Network = "regtest",
|
||||||
|
ScriptKind = "NativeSegwit",
|
||||||
|
AccountPath = "84'/1'/0'",
|
||||||
|
AccountXpub = "vpub-test",
|
||||||
|
Contacts = { new StoredContact { Name = contactName, Address = "rplm1qtest" } },
|
||||||
|
};
|
||||||
|
foreach (var (k, v) in pairs)
|
||||||
|
doc.Labels[k] = v;
|
||||||
|
|
||||||
|
var restored = WalletDocument.FromJson(doc.ToJson());
|
||||||
|
|
||||||
|
Assert.Equal(doc.Labels, restored.Labels);
|
||||||
|
Assert.Equal(contactName, Assert.Single(restored.Contacts).Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// helper: builds the branch for the given position
|
// helper: builds the branch for the given position
|
||||||
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
private static List<uint256> BuildBranch(IReadOnlyList<uint256> leaves, int position)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Spv;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Spv;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end tests of the SPV synchroniser against the in-process fake server:
|
||||||
|
/// gap-limit scanning, UTXO/history reconstruction, Merkle verification (the
|
||||||
|
/// path that must reject a lying server), busy retry, and the disk cache.
|
||||||
|
/// Every block in these scenarios contains a single transaction, so the Merkle
|
||||||
|
/// root is the txid itself and the branch is empty (the tree math has its own
|
||||||
|
/// dedicated tests in <see cref="MerkleProofTests"/>).
|
||||||
|
/// </summary>
|
||||||
|
public class WalletSynchronizerTests
|
||||||
|
{
|
||||||
|
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
||||||
|
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||||
|
|
||||||
|
private static HdAccount Account()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
out var mnemonic));
|
||||||
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side chain state: transactions, per-scripthash histories and
|
||||||
|
/// single-transaction block headers, wired onto a FakeElectrumServer.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Scenario
|
||||||
|
{
|
||||||
|
public int TipHeight = 200;
|
||||||
|
public readonly Dictionary<string, Transaction> Txs = [];
|
||||||
|
public readonly Dictionary<string, List<(string Txid, int Height)>> History = [];
|
||||||
|
public readonly Dictionary<int, string> Headers = [];
|
||||||
|
|
||||||
|
/// <summary>Creates a tx paying <paramref name="sats"/> to <paramref name="to"/> and registers it.</summary>
|
||||||
|
public Transaction Pay(BitcoinAddress to, long sats, int height, OutPoint? from = null,
|
||||||
|
bool coinbase = false, BitcoinAddress? changeTo = null, long changeSats = 0)
|
||||||
|
{
|
||||||
|
var tx = Net.CreateTransaction();
|
||||||
|
tx.Inputs.Add(coinbase ? new TxIn() : new TxIn(from ?? new OutPoint(uint256.One, 0)));
|
||||||
|
tx.Outputs.Add(Money.Satoshis(sats), to);
|
||||||
|
if (changeTo is not null)
|
||||||
|
tx.Outputs.Add(Money.Satoshis(changeSats), changeTo);
|
||||||
|
Register(tx, height, to, changeTo);
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Register(Transaction tx, int height, params BitcoinAddress?[] touchedAddresses)
|
||||||
|
{
|
||||||
|
var txid = tx.GetHash().ToString();
|
||||||
|
Txs[txid] = tx;
|
||||||
|
foreach (var addr in touchedAddresses)
|
||||||
|
{
|
||||||
|
if (addr is null) continue;
|
||||||
|
var sh = Scripthash.FromAddress(addr);
|
||||||
|
if (!History.TryGetValue(sh, out var list))
|
||||||
|
History[sh] = list = [];
|
||||||
|
if (!list.Contains((txid, height)))
|
||||||
|
list.Add((txid, height));
|
||||||
|
}
|
||||||
|
if (height > 0 && !Headers.ContainsKey(height))
|
||||||
|
Headers[height] = SingleTxHeaderHex(tx.GetHash(), height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
|
||||||
|
public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null)
|
||||||
|
{
|
||||||
|
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
|
header.HashPrevBlock = uint256.Zero;
|
||||||
|
header.HashMerkleRoot = merkleRoot ?? txid;
|
||||||
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||||
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
|
header.Nonce = (uint)height;
|
||||||
|
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WireTo(FakeElectrumServer server)
|
||||||
|
{
|
||||||
|
server.Handle("blockchain.headers.subscribe",
|
||||||
|
_ => new { height = TipHeight, hex = SingleTxHeaderHex(uint256.One, TipHeight) });
|
||||||
|
server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||||
|
server.Handle("blockchain.scripthash.get_history", p =>
|
||||||
|
(History.GetValueOrDefault(p[0].GetString()!) ?? [])
|
||||||
|
.Select(h => new { tx_hash = h.Txid, height = h.Height }));
|
||||||
|
server.Handle("blockchain.transaction.get", p =>
|
||||||
|
Txs.TryGetValue(p[0].GetString()!, out var tx)
|
||||||
|
? tx.ToHex()
|
||||||
|
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||||
|
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||||
|
new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() });
|
||||||
|
server.Handle("blockchain.block.header", p =>
|
||||||
|
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
||||||
|
? hex
|
||||||
|
: throw new FakeElectrumError(-32600, "no such block"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(Scenario scenario)
|
||||||
|
{
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
scenario.WireTo(server);
|
||||||
|
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
return (server, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- scansione ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wallet_vuoto_scansiona_il_gap_limit_e_riporta_saldo_zero()
|
||||||
|
{
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(Account(), client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(200, result.TipHeight);
|
||||||
|
Assert.Equal(0, result.ConfirmedSats);
|
||||||
|
Assert.Equal(0, result.UnconfirmedSats);
|
||||||
|
Assert.Equal(0, result.NextReceiveIndex);
|
||||||
|
Assert.Equal(0, result.NextChangeIndex);
|
||||||
|
// Default gap limit 20 on both chains.
|
||||||
|
Assert.Equal(40, result.Addresses.Count);
|
||||||
|
Assert.Equal(40, server.CallCount("blockchain.scripthash.get_history"));
|
||||||
|
Assert.Empty(result.Utxos);
|
||||||
|
Assert.Empty(result.History);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_scoperta_continua_oltre_il_primo_batch_quando_un_indirizzo_e_usato()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(3), 50_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client, gapLimit: 5).SyncOnceAsync();
|
||||||
|
|
||||||
|
// Index 3 used → the window slides: 5 more empty addresses (5..9) close the scan.
|
||||||
|
Assert.Equal(4, result.NextReceiveIndex);
|
||||||
|
Assert.Equal(10, result.Addresses.Count(a => !a.IsChange));
|
||||||
|
Assert.Equal(5, result.Addresses.Count(a => a.IsChange));
|
||||||
|
Assert.Equal(50_000, result.ConfirmedSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ricostruzione UTXO e storico ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_ricezione_confermata_produce_utxo_saldo_e_storico_verificato()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(0, result.ImmatureSats); // regtest: 1 conferma minima, ne ha 101
|
||||||
|
Assert.Equal(1, result.NextReceiveIndex);
|
||||||
|
|
||||||
|
var utxo = Assert.Single(result.Utxos);
|
||||||
|
Assert.Equal(funding.GetHash().ToString(), utxo.Txid);
|
||||||
|
Assert.Equal(100, utxo.Height);
|
||||||
|
Assert.False(utxo.IsChange);
|
||||||
|
|
||||||
|
var entry = Assert.Single(result.History);
|
||||||
|
Assert.Equal(1_000_000, entry.DeltaSats);
|
||||||
|
Assert.True(entry.Verified); // Merkle proof checked against the header
|
||||||
|
|
||||||
|
var row = result.AddressRows.Single(r => r.Address == account.GetReceiveAddress(0).ToString());
|
||||||
|
Assert.Equal(1_000_000, row.BalanceSats);
|
||||||
|
Assert.Equal(1, row.TxCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_spesa_rimuove_l_utxo_e_produce_il_delta_negativo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
|
||||||
|
// Spend: 600k to an external address, 390k change to change/0, 10k fee.
|
||||||
|
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
var spend = Net.CreateTransaction();
|
||||||
|
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)));
|
||||||
|
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||||
|
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||||
|
scenario.Register(spend, 101, account.GetReceiveAddress(0), account.GetChangeAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
// The funding UTXO is spent: only the change remains.
|
||||||
|
var utxo = Assert.Single(result.Utxos);
|
||||||
|
Assert.Equal(spend.GetHash().ToString(), utxo.Txid);
|
||||||
|
Assert.True(utxo.IsChange);
|
||||||
|
Assert.Equal(390_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(1, result.NextChangeIndex);
|
||||||
|
|
||||||
|
// History newest-first with the net deltas.
|
||||||
|
Assert.Equal(2, result.History.Count);
|
||||||
|
Assert.Equal(spend.GetHash().ToString(), result.History[0].Txid);
|
||||||
|
Assert.Equal(-610_000, result.History[0].DeltaSats); // 390k change − 1M spent
|
||||||
|
Assert.Equal(1_000_000, result.History[1].DeltaSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_tx_in_mempool_conta_nel_saldo_non_confermato_e_non_verifica_merkle()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(0, result.ConfirmedSats);
|
||||||
|
Assert.Equal(250_000, result.UnconfirmedSats);
|
||||||
|
var entry = Assert.Single(result.History);
|
||||||
|
Assert.False(entry.Verified);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_coinbase_immaturo_finisce_nel_saldo_immaturo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario { TipHeight = 200 };
|
||||||
|
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121.
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(5_000_000_000, result.ConfirmedSats);
|
||||||
|
Assert.Equal(5_000_000_000, result.ImmatureSats);
|
||||||
|
Assert.True(Assert.Single(result.Utxos).IsCoinbase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sicurezza SPV ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_prova_merkle_che_non_torna_con_l_header_fa_fallire_la_sync()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
// The server lies: the block 100 header commits to a different Merkle root.
|
||||||
|
scenario.Headers[100] = Scenario.SingleTxHeaderHex(
|
||||||
|
funding.GetHash(), 100, merkleRoot: uint256.One);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||||
|
() => new WalletSynchronizer(account, client).SyncOnceAsync());
|
||||||
|
Assert.Contains(funding.GetHash().ToString(), ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resilienza ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gli_errori_server_busy_vengono_ritentati_fino_al_successo()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
scenario.WireTo(server);
|
||||||
|
// The first 3 history calls fail with the ElectrumX throttling error.
|
||||||
|
var failures = 3;
|
||||||
|
var histories = scenario.History;
|
||||||
|
server.Handle("blockchain.scripthash.get_history", p =>
|
||||||
|
{
|
||||||
|
if (Interlocked.Decrement(ref failures) >= 0)
|
||||||
|
throw new FakeElectrumError(-102, "excessive resource usage");
|
||||||
|
return (histories.GetValueOrDefault(p[0].GetString()!) ?? [])
|
||||||
|
.Select(h => new { tx_hash = h.Txid, height = h.Height });
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var _ = server;
|
||||||
|
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cache su disco ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_cache_precaricata_evita_di_riscaricare_tx_prove_e_header()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var first = new WalletSynchronizer(account, client);
|
||||||
|
var result1 = await first.SyncOnceAsync();
|
||||||
|
var (rawTx, verifiedAt, headers) = first.ExportCaches(Net);
|
||||||
|
|
||||||
|
Assert.Single(rawTx); // the confirmed tx is exported
|
||||||
|
Assert.Single(verifiedAt); // with its verified height
|
||||||
|
Assert.NotEmpty(headers);
|
||||||
|
|
||||||
|
// Fresh synchroniser (new launch), warm cache: no re-download.
|
||||||
|
server.ResetCallCounts();
|
||||||
|
var second = new WalletSynchronizer(account, client);
|
||||||
|
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||||
|
result1.NextReceiveIndex, result1.NextChangeIndex, Net);
|
||||||
|
var result2 = await second.SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Le_tx_non_confermate_non_vengono_esportate_nella_cache()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 250_000, height: 0); // mempool
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var sync = new WalletSynchronizer(account, client);
|
||||||
|
await sync.SyncOnceAsync();
|
||||||
|
var (rawTx, verifiedAt, _) = sync.ExportCaches(Net);
|
||||||
|
|
||||||
|
// Unconfirmed txs can change (RBF): they must always be re-downloaded.
|
||||||
|
Assert.Empty(rawTx);
|
||||||
|
Assert.Empty(verifiedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account a indirizzi fissi (WIF importati) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_account_con_indirizzi_fissi_scansiona_solo_quelli()
|
||||||
|
{
|
||||||
|
var key = new Key();
|
||||||
|
var address = key.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
var account = new ImportedKeyAccount([(address, key)], ScriptKind.NativeSegwit, Profile);
|
||||||
|
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(address, 750_000, height: 150);
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var result = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||||
|
|
||||||
|
Assert.Equal(750_000, result.ConfirmedSats);
|
||||||
|
Assert.Single(result.Addresses);
|
||||||
|
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
|
||||||
|
Assert.Equal(1, result.NextReceiveIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the data-path resolution (§8), pinned to a temporary root via
|
||||||
|
/// <see cref="AppPaths.OverrideDataRoot"/> — the same seam the Android head and
|
||||||
|
/// the CLI --data-dir use — so nothing outside the temp folder is touched.
|
||||||
|
/// The pointer-file and portable-mode branches read machine-global locations
|
||||||
|
/// and are deliberately not exercised here.
|
||||||
|
/// </summary>
|
||||||
|
public class AppPathsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly string? _savedOverride;
|
||||||
|
|
||||||
|
public AppPathsTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), $"plm-data-{Guid.NewGuid()}");
|
||||||
|
_savedOverride = AppPaths.OverrideDataRoot;
|
||||||
|
AppPaths.OverrideDataRoot = _root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AppPaths.OverrideDataRoot = _savedOverride;
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void L_override_ha_priorita_su_tutto()
|
||||||
|
{
|
||||||
|
Assert.Equal(_root, AppPaths.DataRoot());
|
||||||
|
Assert.True(AppPaths.IsDataLocationConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ogni_rete_ha_la_propria_sottocartella_con_il_nome_del_profilo()
|
||||||
|
{
|
||||||
|
foreach (var net in new[] { NetKind.Mainnet, NetKind.Testnet, NetKind.Regtest })
|
||||||
|
{
|
||||||
|
var dir = AppPaths.ForNetwork(net);
|
||||||
|
Assert.Equal(Path.Combine(_root, ChainProfiles.For(net).NetName), dir);
|
||||||
|
Assert.True(Directory.Exists(dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void I_percorsi_dei_file_di_rete_stanno_sotto_la_cartella_della_rete()
|
||||||
|
{
|
||||||
|
var netDir = AppPaths.ForNetwork(NetKind.Mainnet);
|
||||||
|
|
||||||
|
Assert.Equal(Path.Combine(netDir, "server-certs.json"), AppPaths.CertificatePinsPath(NetKind.Mainnet));
|
||||||
|
Assert.Equal(Path.Combine(netDir, "servers.json"), AppPaths.ServersPath(NetKind.Mainnet));
|
||||||
|
Assert.Equal(Path.Combine(netDir, "wallets", "default.wallet.json"),
|
||||||
|
AppPaths.DefaultWalletPath(NetKind.Mainnet));
|
||||||
|
Assert.Equal(Path.Combine(_root, "config.json"), AppPaths.ConfigPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WalletFiles_elenca_solo_i_wallet_in_ordine_alfabetico()
|
||||||
|
{
|
||||||
|
var dir = AppPaths.WalletsDir(NetKind.Regtest);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "zeta.wallet.json"), "{}");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "alfa.wallet.json"), "{}");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "non-un-wallet.txt"), "x");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "default.wallet.json.lock"), "x");
|
||||||
|
|
||||||
|
var files = AppPaths.WalletFiles(NetKind.Regtest);
|
||||||
|
|
||||||
|
Assert.Equal(2, files.Count);
|
||||||
|
Assert.EndsWith("alfa.wallet.json", files[0]);
|
||||||
|
Assert.EndsWith("zeta.wallet.json", files[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Le_reti_non_condividono_le_cartelle_wallet()
|
||||||
|
{
|
||||||
|
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Mainnet), AppPaths.WalletsDir(NetKind.Testnet));
|
||||||
|
Assert.NotEqual(AppPaths.WalletsDir(NetKind.Testnet), AppPaths.WalletsDir(NetKind.Regtest));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,31 @@ public class StorageTests
|
|||||||
Assert.False(EncryptedFile.IsEncrypted("non è json"));
|
Assert.False(EncryptedFile.IsEncrypted("non è json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: valid JSON whose root is not an object (found by the
|
||||||
|
// property test) must not throw from TryGetProperty.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("5")]
|
||||||
|
[InlineData("true")]
|
||||||
|
[InlineData("\"stringa\"")]
|
||||||
|
[InlineData("[1, 2]")]
|
||||||
|
public void IsEncrypted_restituisce_false_per_json_con_radice_non_oggetto(string content)
|
||||||
|
{
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEncrypted_restituisce_false_per_utf16_invalido()
|
||||||
|
{
|
||||||
|
// Lone surrogate: cannot be transcoded to UTF-8 for JSON parsing.
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted("\ud800"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEncrypted_restituisce_false_se_Format_non_e_una_stringa()
|
||||||
|
{
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted("{\"Format\": 42}"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- WalletDocument JSON ----
|
// ---- WalletDocument JSON ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -244,6 +244,104 @@ public class TransactionFactoryTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ScriptPubKeyType.Legacy)]
|
||||||
|
[InlineData(ScriptPubKeyType.SegwitP2SH)]
|
||||||
|
[InlineData(ScriptPubKeyType.Segwit)]
|
||||||
|
public void Si_puo_pagare_ogni_tipo_di_indirizzo_standard(ScriptPubKeyType kind)
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
|
var destination = new Key().PubKey.GetAddress(kind, Net);
|
||||||
|
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, destination, amountSats: 400_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
Assert.Contains(built.Transaction.Outputs,
|
||||||
|
o => o.ScriptPubKey == destination.ScriptPubKey && o.Value.Satoshi == 400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Piu_utxo_vengono_combinati_quando_uno_solo_non_basta()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var allUtxos = new List<CachedUtxo>();
|
||||||
|
var allTxs = new Dictionary<string, Transaction>();
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var funding = Net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)i)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(300_000), account.GetReceiveAddress(i));
|
||||||
|
var txid = funding.GetHash().ToString();
|
||||||
|
allTxs[txid] = funding;
|
||||||
|
allUtxos.Add(new CachedUtxo
|
||||||
|
{
|
||||||
|
Txid = txid, Vout = 0, ValueSats = 300_000,
|
||||||
|
Address = account.GetReceiveAddress(i).ToString(),
|
||||||
|
IsChange = false, AddressIndex = i, Height = 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
allUtxos, allTxs, account.GetReceiveAddress(5), amountSats: 700_000,
|
||||||
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
|
// 700k > any pair? No: it needs all three 300k coins (600k < 700k + fee).
|
||||||
|
Assert.Equal(3, built.Transaction.Inputs.Count);
|
||||||
|
Assert.True(built.Signed);
|
||||||
|
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 100_000);
|
||||||
|
|
||||||
|
// Leaves ~200 sats after the fee: below the P2WPKH dust threshold,
|
||||||
|
// so no change output must be created and the remainder goes to the fee.
|
||||||
|
var built = new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(5), amountSats: 99_650,
|
||||||
|
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
|
||||||
|
|
||||||
|
var output = Assert.Single(built.Transaction.Outputs);
|
||||||
|
Assert.Equal(99_650, output.Value.Satoshi);
|
||||||
|
Assert.Equal(100_000 - 99_650, built.Fee.Satoshi);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void La_firma_di_una_tx_fissa_produce_il_txid_golden()
|
||||||
|
{
|
||||||
|
// Golden vector for the signing path (derivation → sighash → witness):
|
||||||
|
// the factory shuffles outputs for privacy, so the vector is anchored one
|
||||||
|
// level below, on a transaction with fixed structure signed through PSBT
|
||||||
|
// (the same flow used air-gapped, §6.5). RFC 6979 makes it deterministic:
|
||||||
|
// any change in this txid is a blocking regression.
|
||||||
|
var account = Account();
|
||||||
|
|
||||||
|
var funding = Net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var spend = Net.CreateTransaction();
|
||||||
|
spend.Version = 2;
|
||||||
|
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||||
|
spend.Outputs.Add(Money.Satoshis(600_000), account.GetReceiveAddress(5));
|
||||||
|
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||||
|
|
||||||
|
var psbt = PSBT.FromTransaction(spend, Net);
|
||||||
|
psbt.AddCoins(new Coin(funding, 0));
|
||||||
|
psbt.SignWithKeys(account.GetExtPrivateKey(isChange: false, 0));
|
||||||
|
psbt.Finalize();
|
||||||
|
var signed = psbt.ExtractTransaction();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"a943cf6bf606fa0050e490cb76ed9313959d228fb0ffa235b7e8b7f6834610b6",
|
||||||
|
signed.GetHash().ToString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Una_config_corrotta_torna_ai_default()
|
public void Una_config_corrotta_torna_ai_default()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.Wallet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the transaction detail assembly (§10): fee from resolved inputs,
|
||||||
|
/// mine/theirs attribution, RBF flag, coinbase, and the degraded paths when the
|
||||||
|
/// server cannot provide a previous transaction.
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionInspectorTests
|
||||||
|
{
|
||||||
|
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||||
|
|
||||||
|
private static HdAccount Account()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
|
out var mnemonic));
|
||||||
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Funding (1M sats to receive/0) + spend (600k external, 390k change/0, 10k fee, RBF).</summary>
|
||||||
|
private static (Transaction Funding, Transaction Spend, BitcoinAddress External, HdAccount Account) SpendPair()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var funding = Net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var external = new Key().PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
var spend = Net.CreateTransaction();
|
||||||
|
spend.Inputs.Add(new TxIn(new OutPoint(funding, 0)) { Sequence = 0xfffffffd });
|
||||||
|
spend.Outputs.Add(Money.Satoshis(600_000), external);
|
||||||
|
spend.Outputs.Add(Money.Satoshis(390_000), account.GetChangeAddress(0));
|
||||||
|
return (funding, spend, external, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartAsync(
|
||||||
|
params Transaction[] served)
|
||||||
|
{
|
||||||
|
var byId = served.ToDictionary(t => t.GetHash().ToString());
|
||||||
|
var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.transaction.get", p =>
|
||||||
|
byId.TryGetValue(p[0].GetString()!, out var tx)
|
||||||
|
? tx.ToHex()
|
||||||
|
: throw new FakeElectrumError(-32600, "no such transaction"));
|
||||||
|
server.Handle("blockchain.block.header", p =>
|
||||||
|
{
|
||||||
|
var height = p[0].GetInt32();
|
||||||
|
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||||
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
|
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||||
|
});
|
||||||
|
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||||
|
return (server, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> Owned(HdAccount account) =>
|
||||||
|
[
|
||||||
|
account.GetReceiveAddress(0).ToString(),
|
||||||
|
account.GetChangeAddress(0).ToString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_spesa_confermata_riporta_fee_conferme_e_attribuzione_mine_theirs()
|
||||||
|
{
|
||||||
|
var (funding, spend, external, account) = SpendPair();
|
||||||
|
var (server, client) = await StartAsync(funding, spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: -610_000, verified: true);
|
||||||
|
|
||||||
|
Assert.Equal(10_000, details.FeeSats);
|
||||||
|
Assert.Equal(1_000_000, details.TotalInSats);
|
||||||
|
Assert.Equal(990_000, details.TotalOutSats);
|
||||||
|
Assert.Equal(100, details.Confirmations); // 200 − 101 + 1
|
||||||
|
Assert.True(details.RbfSignaled);
|
||||||
|
Assert.True(details.Verified);
|
||||||
|
Assert.False(details.IsCoinbase);
|
||||||
|
Assert.False(details.IsIncoming);
|
||||||
|
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_101), details.BlockTime);
|
||||||
|
Assert.Equal((double)10_000 / details.VirtualSize, details.FeeRateSatPerVb);
|
||||||
|
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.True(input.IsMine);
|
||||||
|
Assert.Equal(1_000_000, input.AmountSats);
|
||||||
|
Assert.Equal(account.GetReceiveAddress(0).ToString(), input.Address);
|
||||||
|
|
||||||
|
Assert.Equal(2, details.Outputs.Count);
|
||||||
|
Assert.Equal(600_000, details.SentToOthersSats);
|
||||||
|
Assert.Equal(390_000, details.ReceivedSats);
|
||||||
|
Assert.Contains(details.Outputs, o => o.IsMine && o.AmountSats == 390_000);
|
||||||
|
Assert.Contains(details.Outputs, o => !o.IsMine && o.AmountSats == 600_000);
|
||||||
|
|
||||||
|
// Outgoing tx: the counterparty is the external recipient.
|
||||||
|
Assert.Equal([external.ToString()], details.CounterpartyAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_ricezione_indica_il_mittente_come_controparte()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var senderKey = new Key();
|
||||||
|
var senderAddr = senderKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, Net);
|
||||||
|
|
||||||
|
// The sender's coin, then the payment to us spending it.
|
||||||
|
var senderFunding = Net.CreateTransaction();
|
||||||
|
senderFunding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||||
|
senderFunding.Outputs.Add(Money.Satoshis(2_000_000), senderAddr);
|
||||||
|
|
||||||
|
var payment = Net.CreateTransaction();
|
||||||
|
payment.Inputs.Add(new TxIn(new OutPoint(senderFunding, 0)));
|
||||||
|
payment.Outputs.Add(Money.Satoshis(1_500_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(senderFunding, payment);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, payment.GetHash().ToString(), tipHeight: 200, height: 150,
|
||||||
|
Owned(account), netSats: 1_500_000, verified: true);
|
||||||
|
|
||||||
|
Assert.True(details.IsIncoming);
|
||||||
|
Assert.Equal([senderAddr.ToString()], details.CounterpartyAddresses);
|
||||||
|
Assert.Equal(500_000, details.FeeSats); // 2M in − 1.5M out
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_coinbase_non_ha_fee_ne_importi_in_ingresso()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var coinbase = Net.CreateTransaction();
|
||||||
|
coinbase.Inputs.Add(new TxIn());
|
||||||
|
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(coinbase);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
|
||||||
|
Owned(account), netSats: 5_000_000_000, verified: true);
|
||||||
|
|
||||||
|
Assert.True(details.IsCoinbase);
|
||||||
|
Assert.Null(details.FeeSats);
|
||||||
|
Assert.Null(details.TotalInSats);
|
||||||
|
Assert.Null(details.FeeRateSatPerVb);
|
||||||
|
Assert.Equal(11, details.Confirmations);
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.True(input.IsCoinbase);
|
||||||
|
Assert.Null(input.AmountSats);
|
||||||
|
// No previous transactions to resolve.
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get") - 1); // only the coinbase itself
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Se_la_tx_precedente_non_e_recuperabile_la_fee_resta_ignota()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
// The server only knows the spend: the funding lookup fails.
|
||||||
|
var (server, client) = await StartAsync(spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: -610_000, verified: false);
|
||||||
|
|
||||||
|
Assert.Null(details.FeeSats);
|
||||||
|
Assert.Null(details.TotalInSats);
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.Null(input.AmountSats);
|
||||||
|
Assert.False(input.IsMine); // unresolvable → not attributable
|
||||||
|
Assert.Equal(funding.GetHash().ToString(), input.PrevTxid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Una_tx_in_mempool_non_ha_conferme_ne_timestamp()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
var (server, client) = await StartAsync(funding, spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||||
|
Owned(account), netSats: -610_000, verified: false);
|
||||||
|
|
||||||
|
Assert.Equal(0, details.Confirmations);
|
||||||
|
Assert.Null(details.BlockTime);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
var cache = new Dictionary<string, Transaction>
|
||||||
|
{
|
||||||
|
[funding.GetHash().ToString()] = funding,
|
||||||
|
[spend.GetHash().ToString()] = spend,
|
||||||
|
};
|
||||||
|
var (server, client) = await StartAsync();
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 0,
|
||||||
|
Owned(account), netSats: -610_000, verified: false, cache);
|
||||||
|
|
||||||
|
Assert.Equal(10_000, details.FeeSats);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ public class WalletLoaderTests
|
|||||||
Assert.Equal("NativeSegwit", doc.ScriptKind);
|
Assert.Equal("NativeSegwit", doc.ScriptKind);
|
||||||
Assert.Equal(ValidMnemonic, doc.Mnemonic);
|
Assert.Equal(ValidMnemonic, doc.Mnemonic);
|
||||||
Assert.Null(doc.Passphrase);
|
Assert.Null(doc.Passphrase);
|
||||||
Assert.NotEmpty(doc.AccountXpub);
|
Assert.NotEmpty(doc.AccountXpub!);
|
||||||
Assert.NotEmpty(doc.MasterFingerprint);
|
Assert.NotEmpty(doc.MasterFingerprint!);
|
||||||
Assert.False(doc.IsWatchOnly);
|
Assert.False(doc.IsWatchOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ public class WalletLoaderTests
|
|||||||
var (doc, _) = WalletLoader.NewFromMnemonic(
|
var (doc, _) = WalletLoader.NewFromMnemonic(
|
||||||
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
ValidMnemonic24, null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
Assert.Equal("mainnet", doc.Network);
|
Assert.Equal("mainnet", doc.Network);
|
||||||
Assert.NotEmpty(doc.AccountXpub);
|
Assert.NotEmpty(doc.AccountXpub!);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bumps the app version everywhere it's tracked and stubs a CHANGELOG.md entry.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
|
APP_CSPROJ="src/App/PalladiumWallet.App.csproj"
|
||||||
|
ANDROID_CSPROJ="src/App.Android/PalladiumWallet.App.Android.csproj"
|
||||||
|
CHANGELOG="CHANGELOG.md"
|
||||||
|
|
||||||
|
current_version=$(grep -oP '(?<=<Version>)[^<]+(?=</Version>)' "$APP_CSPROJ")
|
||||||
|
echo "Current version: $current_version"
|
||||||
|
|
||||||
|
read -rp "New version (e.g. 1.0.0): " new_version
|
||||||
|
|
||||||
|
if [[ -z "$new_version" ]]; then
|
||||||
|
echo "No version entered, aborting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Invalid version format: '$new_version' (expected X.Y.Z)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$new_version" == "$current_version" ]]; then
|
||||||
|
echo "New version is the same as the current one ($current_version), aborting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Single source of truth: App csproj <Version>
|
||||||
|
sed -i "s|<Version>$current_version</Version>|<Version>$new_version</Version>|" "$APP_CSPROJ"
|
||||||
|
|
||||||
|
# 2. Android head: ApplicationDisplayVersion (versionName) must mirror it,
|
||||||
|
# and ApplicationVersion (versionCode) must strictly increase on every
|
||||||
|
# release or users can't update in place.
|
||||||
|
current_code=$(grep -oP '(?<=<ApplicationVersion>)[^<]+(?=</ApplicationVersion>)' "$ANDROID_CSPROJ")
|
||||||
|
new_code=$((current_code + 1))
|
||||||
|
|
||||||
|
sed -i "s|<ApplicationDisplayVersion>$current_version</ApplicationDisplayVersion>|<ApplicationDisplayVersion>$new_version</ApplicationDisplayVersion>|" "$ANDROID_CSPROJ"
|
||||||
|
sed -i "s|<ApplicationVersion>$current_code</ApplicationVersion>|<ApplicationVersion>$new_code</ApplicationVersion>|" "$ANDROID_CSPROJ"
|
||||||
|
|
||||||
|
# 3. CHANGELOG.md: stub a new entry above the previous top-most one.
|
||||||
|
today=$(date +%F)
|
||||||
|
if grep -q "^## \[$new_version\]" "$CHANGELOG"; then
|
||||||
|
echo "CHANGELOG.md already has an entry for $new_version, leaving it untouched."
|
||||||
|
else
|
||||||
|
awk -v ver="$new_version" -v date="$today" '
|
||||||
|
!done && /^## \[/ {
|
||||||
|
print "## [" ver "] — " date
|
||||||
|
print ""
|
||||||
|
print "TODO: describe the changes in this release."
|
||||||
|
print ""
|
||||||
|
done = 1
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$CHANGELOG" > "$CHANGELOG.tmp"
|
||||||
|
mv "$CHANGELOG.tmp" "$CHANGELOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Updated:"
|
||||||
|
echo " - $APP_CSPROJ: Version $current_version -> $new_version"
|
||||||
|
echo " - $ANDROID_CSPROJ: ApplicationDisplayVersion $current_version -> $new_version, ApplicationVersion (versionCode) $current_code -> $new_code"
|
||||||
|
echo " - $CHANGELOG: stubbed entry for $new_version (fill in the details manually)"
|
||||||
|
echo
|
||||||
|
echo "Review the diff, fill in the CHANGELOG entry, then commit and tag manually."
|
||||||
Reference in New Issue
Block a user