Compare commits
11 Commits
d8917fbd9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c2f5e6cd | |||
| feb765663c | |||
| 322ce8f305 | |||
| 077835a30b | |||
| 663460d62e | |||
| be818a50a8 | |||
| 6f4ae679e5 | |||
| cdede17683 | |||
| a264669151 | |||
| d9c75eaec2 | |||
| c868c17505 |
@@ -93,3 +93,7 @@ settings.local.json
|
|||||||
# Local agent/tooling metadata
|
# Local agent/tooling metadata
|
||||||
.agents/
|
.agents/
|
||||||
.codex/
|
.codex/
|
||||||
|
|
||||||
|
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
|
||||||
|
findings/
|
||||||
|
crash-*.bin
|
||||||
|
|||||||
@@ -6,20 +6,22 @@ This file provides guidance to coding agents (OpenAI Codex and others following
|
|||||||
|
|
||||||
## 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 domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||||
|
|
||||||
## Language policy
|
## Language policy
|
||||||
|
|
||||||
- **Conversation with the user**: Italian.
|
- **Conversation with the user**: Italian.
|
||||||
- **All code, comments, commit messages, and documentation files**: English only.
|
- **Code, comments, commit messages, documentation files**: English only.
|
||||||
|
|
||||||
## How to assist
|
## How to assist
|
||||||
|
|
||||||
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||||
|
|
||||||
|
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||||
|
|
||||||
## Stack and structure
|
## Stack and structure
|
||||||
|
|
||||||
@@ -34,33 +36,40 @@ src/Cli/ CLI on the same Core tests/ xUnit
|
|||||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
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 per-platform entry point and packages.
|
||||||
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
|
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||||
by `MainWindow` on desktop and as the single-view root on Android.
|
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||||
|
|
||||||
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- **Build:** `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design. Two `internal` test seams (visible via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test), and `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests touching them share the xUnit collection `"AppPaths"` because that state is static).
|
- **Test** (headless, primary verification layer): `dotnet test`
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||||
- **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.
|
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||||
|
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||||
|
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||||
|
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||||
|
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||||
|
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||||
|
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||||
|
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||||
|
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
|
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||||
|
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||||
|
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
|
||||||
|
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||||
|
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||||
|
|
||||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||||
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||||
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||||
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
|
||||||
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
|
||||||
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
|
||||||
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
|
||||||
|
|
||||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||||
|
|
||||||
@@ -68,22 +77,28 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
|
|
||||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
- **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**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
- **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), working both as a desktop window's content and as Android's single-view root.
|
||||||
- **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.
|
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||||
- **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.
|
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||||
|
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||||
|
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||||
|
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||||
|
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||||
|
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
- **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. On startup `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release and `MainWindowViewModel.Update.cs` prompts the user if newer.
|
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
- **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.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.
|
- **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.
|
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||||
|
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||||
|
|||||||
+102
@@ -5,6 +5,108 @@ 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.
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-07-09
|
||||||
|
|
||||||
|
First stable release. Closes the last open security gap from 0.9.x (header
|
||||||
|
trust was not actually anchored to any checkpoint), fixes several crash
|
||||||
|
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
|
||||||
|
decoding to the transaction detail view.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
|
||||||
|
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
|
||||||
|
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
|
||||||
|
plus one near tip). Previously the checkpoint array was empty and the
|
||||||
|
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
|
||||||
|
never called — on this LWMA chain, where PoW can't be recomputed
|
||||||
|
client-side, a malicious or eclipsing server could hand back any
|
||||||
|
internally-consistent header for a Merkle proof with nothing tying it to
|
||||||
|
the real chain. For every header used in a Merkle proof, the intervening
|
||||||
|
headers are now downloaded back to the nearest checkpoint and verified as
|
||||||
|
an unbroken prev-hash chain terminating in that checkpoint's exact hash
|
||||||
|
(memoized per sync session). Testnet/Regtest remain unanchored (no node
|
||||||
|
available to source checkpoints from); a missing checkpoint is a no-op,
|
||||||
|
not a failure.
|
||||||
|
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
|
||||||
|
target per untrusted-input parser (header, Merkle proof, peer list,
|
||||||
|
wallet file, mnemonic/key/address/amount), each encoding that parser's
|
||||||
|
documented error contract. Found and fixed:
|
||||||
|
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
|
||||||
|
wordlist instead of failing gracefully.
|
||||||
|
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
|
||||||
|
shape other than the expected `[ip, hostname, [features...]]`, and on a
|
||||||
|
JSON string containing invalid UTF-8.
|
||||||
|
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
|
||||||
|
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
|
||||||
|
the documented `WrongPasswordException`/`InvalidDataException`
|
||||||
|
contract; worse, the PBKDF2 iteration count was read from the
|
||||||
|
(attacker-controlled) container with no upper bound — a tampered file
|
||||||
|
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
|
||||||
|
count is now clamped to 10,000,000.
|
||||||
|
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
|
||||||
|
connection until manually deleted; now falls back to first-contact
|
||||||
|
TOFU like `ServerRegistry` already did.
|
||||||
|
- The seed corpus (incl. a regression input per fixed finding) replays
|
||||||
|
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
|
||||||
|
regress.
|
||||||
|
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
|
||||||
|
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
|
||||||
|
and disclosure of the AI-assisted testing methodology used (adversarial
|
||||||
|
fake-server simulation, property-based fuzzing, targeted security
|
||||||
|
review) as a complement to, not a replacement for, independent review.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
|
||||||
|
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
|
||||||
|
are each decoded independently) and coinbase scriptSig pool tags (e.g.
|
||||||
|
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
|
||||||
|
height/extranonce). Both were previously dropped entirely — discarded
|
||||||
|
once no destination address could be derived from the script.
|
||||||
|
- Help overlay: "User guide" button next to "Report a bug", linking to
|
||||||
|
`USERGUIDE.md` on GitHub.
|
||||||
|
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
|
||||||
|
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
|
||||||
|
exact numbers the software enforces.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
|
||||||
|
in Italian regardless of the active language; routed through `Loc` with
|
||||||
|
the missing keys added. `CertificatePinMismatchException` no longer
|
||||||
|
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
|
||||||
|
the UI to translate.
|
||||||
|
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
|
||||||
|
the code/docs-are-English-only policy; translated every user-facing
|
||||||
|
string and comment.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
|
||||||
|
checkpoint-anchoring (including the memoization and non-generic-retry
|
||||||
|
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
|
||||||
|
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
|
||||||
|
wordlist languages plus the empty-input guard, SLIP-132 rejection of
|
||||||
|
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
|
||||||
|
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
|
||||||
|
`WalletLoader`'s defensive branches for corrupted files,
|
||||||
|
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
|
||||||
|
precedence chain (via new internal override seams), `UpdateChecker`
|
||||||
|
end-to-end via a stub-transport seam, and `ElectrumClient`'s
|
||||||
|
multi-segment response dispatch.
|
||||||
|
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
|
||||||
|
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
|
||||||
|
absence of false positives on standard outputs/inputs.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
|
||||||
|
dense prose to scannable bullet lists; a stale `SECURITY.md` file
|
||||||
|
reference fixed.
|
||||||
|
- `README.md` test-coverage section and `SECURITY.md` updated to describe
|
||||||
|
the checkpoint anchoring and fuzzing guarantees actually enforced now.
|
||||||
|
|
||||||
## [0.9.1] — 2026-07-02
|
## [0.9.1] — 2026-07-02
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|||||||
@@ -6,20 +6,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 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 domain rigor about UTXO consensus, HD key derivation (BIP32/39/SLIP-132), signature schemes and scripts (P2PKH/P2SH/P2WPKH, PSBT), address encoding (base58/bech32), Merkle/SPV proofs, and at-rest encryption. When a choice touches cryptographic correctness or fund safety, judge it through that lens and flag known risks (nonce reuse, missing validation, exposed keys/seed, wrong fee/coin-selection, unverified server responses). Explain trade-offs with technical precision; never take for granted what hasn't been verified.
|
||||||
|
|
||||||
## Language policy
|
## Language policy
|
||||||
|
|
||||||
- **Conversation with the user**: Italian.
|
- **Conversation with the user**: Italian.
|
||||||
- **All code, comments, commit messages, and documentation files**: English only.
|
- **Code, comments, commit messages, documentation files**: English only.
|
||||||
|
|
||||||
## How to assist
|
## How to assist
|
||||||
|
|
||||||
On **every requested change**, before implementing, judge whether it makes sense and say so plainly: if a request is useful and consistent with the project, proceed; if it is useless, redundant, already covered elsewhere, or risks degrading the code, **say so** with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
Before implementing any requested change, judge whether it makes sense and say so plainly. Useful and consistent with the project → proceed. Useless, redundant, already covered elsewhere, or likely to degrade the code → say so with a short rationale and propose the better alternative (or doing nothing). No automatic agreement — an honest opinion is worth more than blind execution.
|
||||||
|
|
||||||
|
After implementing a **new feature**, propose the tests needed for proper coverage (unit tests for the new logic, edge cases, error paths; property-based tests where invariants apply; integration tests against `FakeElectrumServer` if network/SPV code is involved; a fuzz target if a new untrusted-input parser was added) — don't just write the feature and stop there.
|
||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android, from the same source. Lightning is excluded from the first release.
|
SPV wallet (Sparrow-style) for the **Palladium (PLM)** cryptocurrency, a Bitcoin-derived UTXO chain. Targets desktop (Windows/Linux) and Android from the same source. Lightning is excluded from the first release.
|
||||||
|
|
||||||
## Stack and structure
|
## Stack and structure
|
||||||
|
|
||||||
@@ -34,33 +36,40 @@ src/Cli/ CLI on the same Core tests/ xUnit
|
|||||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
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 per-platform entry point and packages.
|
||||||
per-platform entry point and packages. `MainView` (UserControl) is the shared root, hosted
|
- `MainView` (UserControl) is the shared root, hosted by `MainWindow` on desktop and as the single-view root on Android.
|
||||||
by `MainWindow` on desktop and as the single-view root on Android.
|
- **Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI reaches network/cryptography only through the wallet domain, never directly. `Core` knows nothing about the UI.
|
||||||
|
|
||||||
**Non-negotiable dependency rule:** `App`/`Cli` depend only on `Core`; the UI goes through the wallet domain, never directly through network or cryptography. `Core` knows nothing about the UI.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
.NET 10 SDK lives in `~/.dotnet10`: in non-interactive shells, before any `dotnet` command run
|
.NET 10 SDK lives in `~/.dotnet10` — in non-interactive shells, before any `dotnet` command run
|
||||||
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
`export PATH="$HOME/.dotnet10:$PATH" DOTNET_ROOT="$HOME/.dotnet10"`.
|
||||||
|
|
||||||
- Build: `dotnet build`
|
- **Build:** `dotnet build`
|
||||||
- Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s; coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML). The test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test. Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning) is tested against the in-process fake ElectrumX server in `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters): extend that, don't mock the client — it isn't an interface, by design. Two `internal` test seams (visible via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test), and `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests touching them share the xUnit collection `"AppPaths"` because that state is static).
|
- **Test** (headless, primary verification layer): `dotnet test`
|
||||||
- GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||||
- CLI: `dotnet run --project src/Cli -- <command>` (no args → usage)
|
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||||
- **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.
|
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||||
- Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained`
|
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||||
- Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||||
|
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||||
|
- **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding
|
||||||
|
- Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests`
|
||||||
|
- Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- <target> --random 50000`
|
||||||
|
- Coverage-guided campaign: `tests/PalladiumWallet.Fuzz/fuzz.sh <target>` (needs afl++ + the SharpFuzz.CommandLine tool)
|
||||||
|
- After fixing a finding: add the crashing input to `SeedCorpus` in the fuzz project's `Program.cs` and regenerate with `--make-seeds Corpus`
|
||||||
|
- **GUI hot reload:** `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install)
|
||||||
|
- **CLI:** `dotnet run --project src/Cli -- <command>` (no args → usage)
|
||||||
|
- **Release binaries (all 3 targets):** `./docker/build.sh [windows|linux|android|all]` — reproducible Docker builds (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj (details in `docker/README.md`)
|
||||||
|
- Single-file desktop publish needs `-p:IncludeNativeLibrariesForSelfExtract=true` or Avalonia's native libs (Skia/HarfBuzz/ANGLE) stay outside the exe and it silently fails to start
|
||||||
|
- Android workload dictates the SDK API level — error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`
|
||||||
|
- Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never commit it, see `docker/keystore/README.md`) — without it every build gets a random signature and users must uninstall to update instead of updating in place
|
||||||
|
- **Manual publish:** `dotnet publish src/App.Desktop -r win-x64|linux-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` (AppImage via PupNet Deploy is a future step, no pupnet.conf yet)
|
||||||
|
|
||||||
**Android (apk).** Needs the `android` workload (`dotnet workload install android`), a JDK
|
**Android (apk):** needs the `android` workload (`dotnet workload install android`), a JDK (`JAVA_HOME`), and the Android SDK (`ANDROID_HOME`, or pass `-p:AndroidSdkDirectory=...`; a plain solution-level `dotnet build` needs it too).
|
||||||
(`JAVA_HOME`), and the Android SDK. To provision the SDK once:
|
- Provision once: `dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`
|
||||||
`dotnet build src/App.Android -t:InstallAndroidDependencies -p:AndroidSdkDirectory=$HOME/android-sdk -p:AcceptAndroidSDKLicenses=true`.
|
- Debug apk: `JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk` → `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`
|
||||||
Then build a debug apk (output in `src/App.Android/bin/Debug/net10.0-android/*-Signed.apk`):
|
- Head is an app, not a library (`<OutputType>Exe</OutputType>`); min SDK 23 (AndroidX requirement)
|
||||||
`JAVA_HOME=<jdk> dotnet build src/App.Android -c Debug -t:SignAndroidPackage -p:AndroidSdkDirectory=$HOME/android-sdk`
|
|
||||||
(set `ANDROID_HOME` to skip the `-p:AndroidSdkDirectory` flag). The head is an application,
|
|
||||||
not a library, because it sets `<OutputType>Exe</OutputType>`; min SDK 23 (AndroidX requirement).
|
|
||||||
Note: a plain `dotnet build` at the solution level needs the Android SDK path for the Android head.
|
|
||||||
|
|
||||||
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
**CLI** (`src/Cli`): `create`/`restore`/`restore-xpub`/`info`; `sync`/`send`/`servers`/`reset-certs` (`--server host:port [--ssl]`); `newseed`/`addresses`. Default wallet file `~/.palladium-wallet/<network>/wallets/default.wallet.json` (`--file` to change it).
|
||||||
|
|
||||||
@@ -68,22 +77,28 @@ Note: a plain `dotnet build` at the solution level needs the Android SDK path fo
|
|||||||
|
|
||||||
- **Layers:** GUI → wallet domain → SPV/Sync → Network → Cryptography → Persistence; each layer depends only downward.
|
- **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**. This is a custom layer: NBitcoin assumes Bitcoin's retargeting.
|
||||||
- **NBitcoin vs custom:** NBitcoin covers the custom network, BIP32/39, addresses, transactions, PSBT, signing, encoding, hashing — **do not reimplement these**. Hand-written custom code: JSON-RPC client for the indexing server (ElectrumX-like); SPV sync with Merkle verification; header/checkpoint validation; coin selection and fee policy; versioned encrypted JSON wallet file.
|
- **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), working both as a desktop window's content and as Android's single-view root.
|
||||||
- **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.
|
- Top-level APIs (file/folder picker, clipboard) are reached via `TopLevel.GetTopLevel(this)` since a UserControl doesn't expose them.
|
||||||
- **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.
|
- `MainWindowViewModel.IsDesktop` (from `OperatingSystem.IsAndroid()`) hides filesystem-only features (open-from-file; the data-location wizard step auto-skips on Android because the head sets `AppPaths.OverrideDataRoot`).
|
||||||
|
- **Single ViewModel** `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`); `Core` is driven directly from here.
|
||||||
|
- Split into **partial classes by feature** (`MainWindowViewModel.Send.cs`, `.Receive.cs`, `.Sync.cs`, `.Settings.cs`, `.Wizard.cs`, `.Contacts.cs`, `.Update.cs`, …): new feature logic goes in the matching partial (or a new one), not in the main file.
|
||||||
|
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||||
|
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||||
|
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
- **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. On startup `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release and `MainWindowViewModel.Update.cs` prompts the user if newer.
|
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
- **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.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.
|
- **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.
|
- **Releases:** `./update-version.sh` (interactive) updates `<Version>` in the App csproj (single source), mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` (Android versionCode, **must strictly increase** or users can't update in place), and stubs a `CHANGELOG.md` entry
|
||||||
|
- Fill in the changelog entry before/with the tag — it's the technical record of what shipped, not optional bookkeeping — then commit and tag manually
|
||||||
|
|||||||
+73
-3
@@ -19,39 +19,107 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Desktop
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.App.Android", "src\App.Android\PalladiumWallet.App.Android.csproj", "{BCC5BE4A-B909-4043-B0FB-B5A839349578}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tests\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj", "{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
Release|x64 = Release|x64
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
Release|x86 = Release|x86
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{13EE9780-5810-4229-BFCF-6003172534DD}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{D1AE035A-6DAC-46F4-90FB-F1AE2A79D416}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
{A7D0EF95-B206-4646-99DD-1D2BBB7AF978} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
@@ -60,5 +128,7 @@ Global
|
|||||||
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
{C7E79E8E-B1DE-4053-9FB4-853814766CE0} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||||
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
{A5D1DD48-7485-43F0-BFE3-2F645EC4D1E7} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
{BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
|
{BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||||
|
{BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ 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.
|
The suite also includes **property-based tests** ([CsCheck](https://github.com/AnthonyLloyd/CsCheck)) in `tests/PalladiumWallet.Tests/PropertyTests.cs`. These generate hundreds of random inputs per test and verify invariants that must hold universally — no crash on arbitrary strings, encrypt/decrypt roundtrip for any plaintext and password, SLIP-132 key roundtrip for every script kind and network, every leaf in a randomly-built Merkle tree verifies against its root. They run automatically with `dotnet test` and take ~30 s.
|
||||||
|
|
||||||
|
### Fuzzing
|
||||||
|
|
||||||
|
`tests/PalladiumWallet.Fuzz` fuzzes every parser that consumes untrusted input
|
||||||
|
(server-supplied headers/proofs/peer lists, wallet files, user-pasted
|
||||||
|
keys/mnemonics/addresses/amounts) via [SharpFuzz](https://github.com/Metalnem/sharpfuzz):
|
||||||
|
each target enforces the parser's documented error contract, so any other
|
||||||
|
exception escaping is a finding. The seed corpus — including a regression input
|
||||||
|
for every crash found so far — replays automatically inside `dotnet test`;
|
||||||
|
coverage-guided campaigns run separately with afl++ (`tests/PalladiumWallet.Fuzz/fuzz.sh`),
|
||||||
|
and a built-in random-mutation mode (`dotnet run -- <target> --random N`) needs
|
||||||
|
no external tooling. See `tests/PalladiumWallet.Fuzz/README.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|||||||
+16
-1
@@ -41,6 +41,20 @@ It cannot (given correct Merkle verification):
|
|||||||
- Fabricate a confirmed transaction with a valid Merkle proof
|
- Fabricate a confirmed transaction with a valid Merkle proof
|
||||||
- Forge a payment to a wrong address
|
- Forge a payment to a wrong address
|
||||||
|
|
||||||
|
**Progressive verification (mobile-friendly sync).** On a wallet with many historical
|
||||||
|
transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof:
|
||||||
|
balance and history are shown as soon as transaction downloads finish (`PartialResult`,
|
||||||
|
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
|
||||||
|
each transaction's `Verified` flag catches up progressively. This means the UI can display a
|
||||||
|
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
|
||||||
|
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
|
||||||
|
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
|
||||||
|
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
|
||||||
|
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
|
||||||
|
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
|
||||||
|
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
|
||||||
|
a sync, never a partial one, so no unverified data survives a restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key and seed management
|
## Key and seed management
|
||||||
@@ -55,8 +69,9 @@ It cannot (given correct Merkle verification):
|
|||||||
## Encryption at rest
|
## Encryption at rest
|
||||||
|
|
||||||
- Algorithm: AES-256-GCM
|
- Algorithm: AES-256-GCM
|
||||||
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible)
|
- Key derivation: PBKDF2-HMAC-SHA512, 600 000 iterations, 16-byte random salt (fresh salt and nonce on every save; the iteration count is stored in the file container, so future increases remain backward-compatible — bounded at 10 000 000 on read, since the count is attacker-controlled in a tampered file and an absurd value would hang the wallet at open)
|
||||||
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
- Authentication: GCM tag (16 bytes) — any tampering is detected before decryption
|
||||||
|
- A malformed container (broken JSON, missing fields, bad base64, wrong nonce/tag size) always fails with a typed `InvalidDataException`, never a raw parser exception — the decrypt path is fuzz-tested (`tests/PalladiumWallet.Fuzz`)
|
||||||
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
- The user can explicitly opt out of encryption (UI shows a warning); the `WalletStore.Save` API accepts `null` password only when the caller has confirmed user intent
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ build_android() {
|
|||||||
-t:SignAndroidPackage \
|
-t:SignAndroidPackage \
|
||||||
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
|
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
|
||||||
-p:ApplicationVersion=${VERSION_CODE} \
|
-p:ApplicationVersion=${VERSION_CODE} \
|
||||||
|
-p:AndroidKeyStore=true \
|
||||||
-p:AndroidSigningKeyStore=/keystore/release.keystore \
|
-p:AndroidSigningKeyStore=/keystore/release.keystore \
|
||||||
-p:AndroidSigningKeyAlias=palladiumwallet \
|
-p:AndroidSigningKeyAlias=palladiumwallet \
|
||||||
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
|
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
|
||||||
|
|||||||
@@ -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>2</ApplicationVersion>
|
<ApplicationVersion>3</ApplicationVersion>
|
||||||
<ApplicationDisplayVersion>0.9.1</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>1.0.0</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
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public sealed class Loc
|
|||||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
||||||
|
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch"],
|
||||||
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
||||||
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
||||||
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
||||||
@@ -394,6 +395,8 @@ public sealed class Loc
|
|||||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
||||||
|
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft"],
|
||||||
|
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…"],
|
||||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||||
["msg.certreset"] = [
|
["msg.certreset"] = [
|
||||||
"Certificati SSL azzerati: riprova la connessione.",
|
"Certificati SSL azzerati: riprova la connessione.",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||||
<Version>0.9.1</Version>
|
<Version>1.0.0</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string immatureText = "";
|
private string immatureText = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string verifyingText = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string networkInfo = "";
|
private string networkInfo = "";
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ public partial class MainWindowViewModel
|
|||||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||||
UnconfirmedText = "";
|
UnconfirmedText = "";
|
||||||
ImmatureText = "";
|
ImmatureText = "";
|
||||||
|
VerifyingText = "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
@@ -265,7 +269,11 @@ public partial class MainWindowViewModel
|
|||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
|
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
|
||||||
|
// immature coinbase can also be unverified), which double-subtracts and can go
|
||||||
|
// negative. SpendableSats is computed directly from the same IsSpendable gate
|
||||||
|
// coin selection uses, so it's always correct.
|
||||||
|
BalanceText = Fmt(cache.SpendableSats);
|
||||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||||
UnconfirmedText = pending != 0
|
UnconfirmedText = pending != 0
|
||||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||||
@@ -273,13 +281,20 @@ public partial class MainWindowViewModel
|
|||||||
ImmatureText = cache.ImmatureSats != 0
|
ImmatureText = cache.ImmatureSats != 0
|
||||||
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
||||||
: "";
|
: "";
|
||||||
|
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
|
||||||
|
// proof background verification hasn't reached yet — same "not spendable" treatment
|
||||||
|
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
|
||||||
|
VerifyingText = cache.PendingVerificationSats != 0
|
||||||
|
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
|
||||||
|
: "";
|
||||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||||
History.Clear();
|
History.Clear();
|
||||||
foreach (var tx in cache.History)
|
foreach (var tx in cache.History)
|
||||||
History.Add(new HistoryRow(
|
History.Add(new HistoryRow(
|
||||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||||
tx.Txid));
|
tx.Txid,
|
||||||
|
tx.Verified));
|
||||||
|
|
||||||
Addresses.Clear();
|
Addresses.Clear();
|
||||||
foreach (var a in cache.Addresses)
|
foreach (var a in cache.Addresses)
|
||||||
|
|||||||
@@ -273,32 +273,34 @@ public partial class MainWindowViewModel
|
|||||||
_doc.Cache?.NextChangeIndex ?? 0,
|
_doc.Cache?.NextChangeIndex ?? 0,
|
||||||
net);
|
net);
|
||||||
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
|
||||||
|
// Progressive verification (§7.4): the wallet becomes usable as soon as
|
||||||
|
// transaction downloads finish, without waiting for every historical Merkle
|
||||||
|
// proof — critical on mobile, where verifying thousands of proofs can take far
|
||||||
|
// longer than the download itself. Never persisted to disk on its own (only the
|
||||||
|
// final, fully-verified snapshot below is saved); coin selection still refuses
|
||||||
|
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
|
||||||
|
// early can't be exploited to spend a server-fabricated balance.
|
||||||
|
_synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
_resyncRequested = false;
|
_resyncRequested = false;
|
||||||
var result = await _synchronizer.SyncOnceAsync(ct);
|
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
|
||||||
|
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
|
||||||
|
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
|
||||||
|
var (result, rawHex, verifiedAt, blockHeaders) = await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var r = await _synchronizer.SyncOnceAsync(ct);
|
||||||
|
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
|
||||||
|
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders);
|
||||||
|
}, ct);
|
||||||
_lastTransactions = result.Transactions;
|
_lastTransactions = result.Transactions;
|
||||||
|
|
||||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
var cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
|
||||||
PalladiumNetworks.For(_account.Profile.Kind));
|
FillDisplayFields(cache, result);
|
||||||
_doc.Cache = new SyncCache
|
_doc.Cache = cache;
|
||||||
{
|
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
||||||
TipHeight = result.TipHeight,
|
|
||||||
ConfirmedSats = result.ConfirmedSats,
|
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
|
||||||
ImmatureSats = result.ImmatureSats,
|
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
|
||||||
History = [.. result.History],
|
|
||||||
Utxos = [.. result.Utxos],
|
|
||||||
Addresses = [.. result.AddressRows],
|
|
||||||
RawTxHex = rawHex,
|
|
||||||
VerifiedAt = verifiedAt,
|
|
||||||
BlockHeaders = blockHeaders,
|
|
||||||
};
|
|
||||||
WalletStore.Save(_doc, _walletPath!, _password);
|
|
||||||
ApplyCache(_doc.Cache);
|
ApplyCache(_doc.Cache);
|
||||||
_syncFailed = false;
|
_syncFailed = false;
|
||||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||||
@@ -345,6 +347,44 @@ public partial class MainWindowViewModel
|
|||||||
_ = ConnectAndSync();
|
_ = ConnectAndSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
|
||||||
|
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
|
||||||
|
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
|
||||||
|
/// resume without re-downloading) were never written.
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyPartialResult(SyncResult result)
|
||||||
|
{
|
||||||
|
if (_doc is null)
|
||||||
|
return;
|
||||||
|
var previous = _doc.Cache;
|
||||||
|
var cache = new SyncCache
|
||||||
|
{
|
||||||
|
RawTxHex = previous?.RawTxHex,
|
||||||
|
VerifiedAt = previous?.VerifiedAt,
|
||||||
|
BlockHeaders = previous?.BlockHeaders,
|
||||||
|
};
|
||||||
|
FillDisplayFields(cache, result);
|
||||||
|
_doc.Cache = cache;
|
||||||
|
_lastTransactions = result.Transactions;
|
||||||
|
ApplyCache(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillDisplayFields(SyncCache cache, SyncResult result)
|
||||||
|
{
|
||||||
|
cache.TipHeight = result.TipHeight;
|
||||||
|
cache.ConfirmedSats = result.ConfirmedSats;
|
||||||
|
cache.UnconfirmedSats = result.UnconfirmedSats;
|
||||||
|
cache.ImmatureSats = result.ImmatureSats;
|
||||||
|
cache.PendingVerificationSats = result.PendingVerificationSats;
|
||||||
|
cache.SpendableSats = result.SpendableSats;
|
||||||
|
cache.NextReceiveIndex = result.NextReceiveIndex;
|
||||||
|
cache.NextChangeIndex = result.NextChangeIndex;
|
||||||
|
cache.History = [.. result.History];
|
||||||
|
cache.Utxos = [.. result.Utxos];
|
||||||
|
cache.Addresses = [.. result.AddressRows];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
||||||
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
|
|||||||
namespace PalladiumWallet.App.ViewModels;
|
namespace PalladiumWallet.App.ViewModels;
|
||||||
|
|
||||||
/// <summary>Transaction history row for the view.</summary>
|
/// <summary>Transaction history row for the view.</summary>
|
||||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
|
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
|
||||||
|
|
||||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||||
public sealed record AddressRow(
|
public sealed record AddressRow(
|
||||||
|
|||||||
@@ -64,13 +64,17 @@ public sealed class TransactionDetailsViewModel
|
|||||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
||||||
i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—",
|
i.IsCoinbase
|
||||||
|
? i.CoinbaseTag is { Length: > 0 } tag
|
||||||
|
? $"{loc["tx.coinbase.newcoins"]} — {tag}"
|
||||||
|
: loc["tx.coinbase.newcoins"]
|
||||||
|
: i.Address ?? "—",
|
||||||
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||||
i.IsMine)));
|
i.IsMine)));
|
||||||
|
|
||||||
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
||||||
$"#{o.Index}",
|
$"#{o.Index}",
|
||||||
o.Address ?? $"({o.ScriptType})",
|
o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"),
|
||||||
CoinAmount.FormatIn(o.AmountSats, unit),
|
CoinAmount.FormatIn(o.AmountSats, unit),
|
||||||
o.IsMine)));
|
o.IsMine)));
|
||||||
}
|
}
|
||||||
@@ -98,7 +102,8 @@ public sealed class TransactionDetailsViewModel
|
|||||||
{
|
{
|
||||||
if (d.Confirmations <= 0)
|
if (d.Confirmations <= 0)
|
||||||
return loc["tx.status.mempool"];
|
return loc["tx.status.mempool"];
|
||||||
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||||
|
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string Signed(long sats)
|
private string Signed(long sats)
|
||||||
|
|||||||
@@ -317,6 +317,9 @@
|
|||||||
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Text="{Binding VerifyingText}" Foreground="#FCD34D"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding VerifyingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
||||||
Margin="0,2,0,0"/>
|
Margin="0,2,0,0"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -373,13 +376,16 @@
|
|||||||
<DataTemplate x:DataType="vm:HistoryRow">
|
<DataTemplate x:DataType="vm:HistoryRow">
|
||||||
<Panel Cursor="Hand">
|
<Panel Cursor="Hand">
|
||||||
<!-- Desktop: 3 fixed columns -->
|
<!-- Desktop: 3 fixed columns -->
|
||||||
<Grid ColumnDefinitions="90,160,*"
|
<Grid ColumnDefinitions="90,160,*,Auto"
|
||||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||||
FontFamily="monospace" FontSize="12"
|
FontFamily="monospace" FontSize="12"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||||
|
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
|
||||||
|
IsVisible="{Binding !Verified}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- Mobile: vertical card -->
|
<!-- Mobile: vertical card -->
|
||||||
<StackPanel Spacing="2"
|
<StackPanel Spacing="2"
|
||||||
@@ -389,6 +395,9 @@
|
|||||||
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
||||||
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
||||||
TextTrimming="CharacterEllipsis"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||||
|
Foreground="#FCD34D" FontSize="11"
|
||||||
|
IsVisible="{Binding !Verified}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -1563,9 +1572,14 @@
|
|||||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding Loc[help.info]}" TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="{Binding Loc[help.bug.report]}"
|
<Button Content="{Binding Loc[help.bug.report]}"
|
||||||
Click="OnOpenBugReportClick"
|
Click="OnOpenBugReportClick"
|
||||||
HorizontalAlignment="Left"/>
|
HorizontalAlignment="Left"/>
|
||||||
|
<Button Content="{Binding Loc[help.user.guide]}"
|
||||||
|
Click="OnOpenUserGuideClick"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</StackPanel>
|
||||||
<Border BorderBrush="{DynamicResource BorderSubtleBrush}"
|
<Border BorderBrush="{DynamicResource BorderSubtleBrush}"
|
||||||
BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ public partial class MainView : UserControl
|
|||||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnOpenUserGuideClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
const string userGuideUrl = "https://github.com/davide3011/PalladiumWallet/blob/main/USERGUIDE.md";
|
||||||
|
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||||
|
if (launcher is not null)
|
||||||
|
await launcher.LaunchUriAsync(new Uri(userGuideUrl));
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
private async void OnOpenReleasePageClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.UpdateReleaseUrl)) return;
|
||||||
|
|||||||
+6
-2
@@ -104,8 +104,9 @@ static int Info(string[] o)
|
|||||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||||
if (doc.Cache is { } cache)
|
if (doc.Cache is { } cache)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendable"
|
Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||||
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
|
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
|
||||||
|
+ (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
||||||
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
||||||
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||||
@@ -149,6 +150,8 @@ static async Task<int> Sync(string[] o)
|
|||||||
ConfirmedSats = result.ConfirmedSats,
|
ConfirmedSats = result.ConfirmedSats,
|
||||||
UnconfirmedSats = result.UnconfirmedSats,
|
UnconfirmedSats = result.UnconfirmedSats,
|
||||||
ImmatureSats = result.ImmatureSats,
|
ImmatureSats = result.ImmatureSats,
|
||||||
|
PendingVerificationSats = result.PendingVerificationSats,
|
||||||
|
SpendableSats = result.SpendableSats,
|
||||||
NextReceiveIndex = result.NextReceiveIndex,
|
NextReceiveIndex = result.NextReceiveIndex,
|
||||||
NextChangeIndex = result.NextChangeIndex,
|
NextChangeIndex = result.NextChangeIndex,
|
||||||
History = [.. result.History],
|
History = [.. result.History],
|
||||||
@@ -160,8 +163,9 @@ static async Task<int> Sync(string[] o)
|
|||||||
};
|
};
|
||||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||||
|
|
||||||
Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendable"
|
Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
||||||
|
+ (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
||||||
Console.WriteLine($"History ({result.History.Count}):");
|
Console.WriteLine($"History ({result.History.Count}):");
|
||||||
foreach (var tx in result.History)
|
foreach (var tx in result.History)
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ public static class ChainProfiles
|
|||||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||||
],
|
],
|
||||||
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||||
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one
|
// RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
|
||||||
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
|
||||||
|
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||||
// bounds how far back a forged header chain must be walked to be caught, since
|
// bounds how far back a forged header chain must be walked to be caught, since
|
||||||
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
||||||
Checkpoints =
|
Checkpoints =
|
||||||
@@ -78,6 +79,7 @@ public static class ChainProfiles
|
|||||||
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
||||||
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
||||||
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
||||||
|
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,26 @@ public static class Bip39
|
|||||||
// ideographic spaces — the text must not be altered further (§4.1).
|
// ideographic spaces — the text must not be altered further (§4.1).
|
||||||
text = text.Trim();
|
text = text.Trim();
|
||||||
|
|
||||||
IEnumerable<Wordlist> candidates = language is not null
|
Wordlist wordlist;
|
||||||
? [ToWordlist(language.Value)]
|
if (language is not null)
|
||||||
: [Wordlist.AutoDetect(text)];
|
|
||||||
|
|
||||||
foreach (var wordlist in candidates)
|
|
||||||
{
|
{
|
||||||
|
wordlist = ToWordlist(language.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
wordlist = Wordlist.AutoDetect(text);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
// AutoDetect lands on NBitcoin's internal "Unknown" language for
|
||||||
|
// text resembling no wordlist and throws instead of returning a
|
||||||
|
// default (found by fuzzing) — not a valid mnemonic, plain and simple.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsed = new Mnemonic(text, wordlist);
|
var parsed = new Mnemonic(text, wordlist);
|
||||||
@@ -76,8 +90,11 @@ public static class Bip39
|
|||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
// Words outside the wordlist or invalid count — try the next candidate.
|
// Words outside the wordlist or invalid count.
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
// Defensive: same NBitcoin failure mode surfacing from the constructor.
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
|
|||||||
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
||||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||||
|
|
||||||
|
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
|
||||||
|
public readonly record struct HeaderRangeResponse(int Count, string Hex);
|
||||||
|
|
||||||
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
||||||
public readonly record struct ChainTip(int Height, string HeaderHex);
|
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||||
|
|
||||||
@@ -82,6 +85,23 @@ public static class ElectrumApi
|
|||||||
return r.GetString()!;
|
return r.GetString()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
|
||||||
|
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
|
||||||
|
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
|
||||||
|
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
|
||||||
|
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
|
||||||
|
/// callers must loop until the full range is covered.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
|
||||||
|
int startHeight, int count, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
|
||||||
|
return new HeaderRangeResponse(
|
||||||
|
r.GetProperty("count").GetInt32(),
|
||||||
|
r.GetProperty("hex").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -124,17 +144,22 @@ public static class ElectrumApi
|
|||||||
/// Parses the server.peers.subscribe response: a list of
|
/// Parses the server.peers.subscribe response: a list of
|
||||||
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
/// [ip, hostname, ["v1.4.2", "pN", "tPORT", "sPORT", ...]];
|
||||||
/// "t"/"s" without a number = network default port (resolved by the caller).
|
/// "t"/"s" without a number = network default port (resolved by the caller).
|
||||||
|
/// The response is untrusted server data: any unexpected shape (non-array,
|
||||||
|
/// wrong element types) is skipped, never thrown on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
public static IReadOnlyList<PeerInfo> ParsePeers(JsonElement response)
|
||||||
{
|
{
|
||||||
var peers = new List<PeerInfo>();
|
var peers = new List<PeerInfo>();
|
||||||
|
if (response.ValueKind != JsonValueKind.Array)
|
||||||
|
return peers;
|
||||||
foreach (var entry in response.EnumerateArray())
|
foreach (var entry in response.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3)
|
if (entry.ValueKind != JsonValueKind.Array || entry.GetArrayLength() < 3
|
||||||
|
|| entry[2].ValueKind != JsonValueKind.Array)
|
||||||
continue;
|
continue;
|
||||||
var host = entry[1].GetString();
|
var host = AsString(entry[1]);
|
||||||
if (string.IsNullOrWhiteSpace(host))
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
host = entry[0].GetString();
|
host = AsString(entry[0]);
|
||||||
if (string.IsNullOrWhiteSpace(host))
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -142,7 +167,7 @@ public static class ElectrumApi
|
|||||||
string? version = null;
|
string? version = null;
|
||||||
foreach (var feature in entry[2].EnumerateArray())
|
foreach (var feature in entry[2].EnumerateArray())
|
||||||
{
|
{
|
||||||
var f = feature.GetString();
|
var f = AsString(feature);
|
||||||
if (string.IsNullOrEmpty(f))
|
if (string.IsNullOrEmpty(f))
|
||||||
continue;
|
continue;
|
||||||
switch (f[0])
|
switch (f[0])
|
||||||
@@ -157,4 +182,21 @@ public static class ElectrumApi
|
|||||||
}
|
}
|
||||||
return peers;
|
return peers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? AsString(JsonElement element)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.String)
|
||||||
|
return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return element.GetString();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Syntactically valid JSON string that cannot be transcoded to UTF-16
|
||||||
|
// (invalid UTF-8 bytes / lone surrogates) — untrusted server data,
|
||||||
|
// treat as absent (found by fuzzing).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ public sealed class SyncResult
|
|||||||
/// <see cref="ConfirmedSats"/>.
|
/// <see cref="ConfirmedSats"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required long ImmatureSats { get; init; }
|
public required long ImmatureSats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
|
||||||
|
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
|
||||||
|
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
|
||||||
|
/// (an immature coinbase can also be unverified), so never subtract both from
|
||||||
|
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
|
||||||
|
/// </summary>
|
||||||
|
public required long PendingVerificationSats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
|
||||||
|
/// now — the true spendable balance. Computed directly from the same gate coin selection
|
||||||
|
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
|
||||||
|
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
|
||||||
|
/// </summary>
|
||||||
|
public required long SpendableSats { get; init; }
|
||||||
public required int NextReceiveIndex { get; init; }
|
public required int NextReceiveIndex { get; init; }
|
||||||
public required int NextChangeIndex { get; init; }
|
public required int NextChangeIndex { get; init; }
|
||||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||||
@@ -46,14 +63,37 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
||||||
public event Action<string>? Progress;
|
public event Action<string>? Progress;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
|
||||||
|
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
|
||||||
|
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
|
||||||
|
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
|
||||||
|
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
|
||||||
|
/// only completes once verification is fully done, for callers that need the final state.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<SyncResult>? PartialResult;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
|
||||||
|
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
||||||
|
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||||
|
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||||
|
|
||||||
// checkpoint height -> highest height already proven to hash-chain back to it
|
// checkpoint height -> highest height already proven to hash-chain back to it
|
||||||
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
||||||
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||||
|
|
||||||
|
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
|
||||||
|
// being verified) would otherwise race on overlapping ranges and issue duplicate range
|
||||||
|
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
|
||||||
|
// serializing them costs nothing that matters.
|
||||||
|
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
|
||||||
|
|
||||||
|
// Max headers requested per blockchain.block.headers call. The server may return fewer
|
||||||
|
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
|
||||||
|
private const int HeaderBatchSize = 2016;
|
||||||
|
|
||||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||||
// discovery — already-used addresses are fetched in a single burst instead of
|
// discovery — already-used addresses are fetched in a single burst instead of
|
||||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
||||||
@@ -162,7 +202,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||||
txHeights[item.TxHash] = item.Height;
|
txHeights[item.TxHash] = item.Height;
|
||||||
|
|
||||||
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
// 4. Download missing transactions — needed to compute amounts/UTXOs locally.
|
||||||
|
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
|
||||||
|
// wallet with thousands of transactions, downloads finish in seconds while proofs can
|
||||||
|
// take much longer over a high-latency link, and the wallet has everything it needs to
|
||||||
|
// show balance/history the moment downloads are done. Verification then continues in
|
||||||
|
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
|
||||||
|
// while coin selection stays locked out of any UTXO until its own proof is checked
|
||||||
|
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
|
||||||
|
// cannot get a fabricated balance spent just because it was shown early.
|
||||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||||
var toVerify = txHeights
|
var toVerify = txHeights
|
||||||
@@ -171,46 +219,81 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (missing.Count > 0 || toVerify.Count > 0)
|
if (missing.Count > 0 || toVerify.Count > 0)
|
||||||
{
|
|
||||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
||||||
var dlDone = 0;
|
|
||||||
var merkDone = 0;
|
|
||||||
|
|
||||||
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
var dlDone = 0;
|
||||||
|
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
var raw = await client.GetTransactionAsync(txid, ct);
|
var raw = await client.GetTransactionAsync(txid, ct);
|
||||||
_txCache[txid] = Transaction.Parse(raw, network);
|
_txCache[txid] = Transaction.Parse(raw, network);
|
||||||
var n = Interlocked.Increment(ref dlDone);
|
var n = Interlocked.Increment(ref dlDone);
|
||||||
if (n % 50 == 0 || n == missing.Count)
|
if (n % 50 == 0 || n == missing.Count)
|
||||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
|
Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
|
||||||
}, ct));
|
}, ct)));
|
||||||
|
|
||||||
|
SyncResult BuildSnapshot() =>
|
||||||
|
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
|
||||||
|
|
||||||
|
PartialResult?.Invoke(BuildSnapshot());
|
||||||
|
|
||||||
|
var merkDone = 0;
|
||||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||||
{
|
{
|
||||||
var (txid, height) = kv;
|
var (txid, height) = kv;
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
var headerTask = _headerFetches.GetOrAdd(height,
|
// Anchor first: on a checkpointed height this fills _headerFetches[height]
|
||||||
h => client.GetBlockHeaderAsync(h, ct));
|
// via the batched range fetch (§7.3), so the header lookup below is a cache
|
||||||
var proof = await proofTask;
|
// hit instead of a second individual blockchain.block.header RPC per tx —
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
// halves round-trips for this stage on mainnet, where it matters most on
|
||||||
|
// high-latency mobile links. Falls back to an individual fetch when no
|
||||||
|
// checkpoint covers this height (testnet/regtest today).
|
||||||
await AnchorToCheckpointAsync(height, ct);
|
await AnchorToCheckpointAsync(height, ct);
|
||||||
|
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
|
||||||
|
var header = BlockHeaderInfo.Parse(headerHex);
|
||||||
|
var proof = await proofTask;
|
||||||
if (!MerkleProof.Verify(
|
if (!MerkleProof.Verify(
|
||||||
uint256.Parse(txid), proof.Pos,
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||||
|
_verifiedAtHeight[txid] = height;
|
||||||
var n = Interlocked.Increment(ref merkDone);
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
if (n % 50 == 0 || n == toVerify.Count)
|
if (n % 50 == 0 || n == toVerify.Count)
|
||||||
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||||
}, ct));
|
if (n % PartialResultBatchSize == 0)
|
||||||
|
PartialResult?.Invoke(BuildSnapshot());
|
||||||
|
}, ct)).ToList();
|
||||||
|
|
||||||
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
if (merkTasks.Count > 0)
|
||||||
foreach (var (txid, height) in toVerify)
|
await Task.WhenAll(merkTasks);
|
||||||
_verifiedAtHeight[txid] = height;
|
|
||||||
|
return BuildSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
|
||||||
|
// every single verified proof would make the background verification phase itself O(n²) for
|
||||||
|
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
|
||||||
|
// visibly without that cost.
|
||||||
|
private const int PartialResultBatchSize = 200;
|
||||||
|
|
||||||
|
private bool IsTxVerified(string txid, int height) =>
|
||||||
|
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
|
||||||
|
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
|
||||||
|
/// transaction downloads finish (proofs still pending), and again as verification progresses,
|
||||||
|
/// each time reflecting whichever transactions have been proof-checked so far.
|
||||||
|
/// </summary>
|
||||||
|
private SyncResult BuildResult(
|
||||||
|
int tipHeight,
|
||||||
|
List<TrackedAddress> tracked,
|
||||||
|
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
|
||||||
|
Dictionary<string, int> txHeights,
|
||||||
|
int nextReceive,
|
||||||
|
int nextChange)
|
||||||
|
{
|
||||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
|
||||||
|
|
||||||
// 6. Local UTXO reconstruction.
|
// 6. Local UTXO reconstruction.
|
||||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||||
@@ -222,6 +305,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
var utxos = new List<CachedUtxo>();
|
var utxos = new List<CachedUtxo>();
|
||||||
foreach (var (txid, tx) in transactions)
|
foreach (var (txid, tx) in transactions)
|
||||||
{
|
{
|
||||||
|
var height = txHeights[txid];
|
||||||
|
var verifiedTx = IsTxVerified(txid, height);
|
||||||
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
||||||
{
|
{
|
||||||
var output = tx.Outputs[vout];
|
var output = tx.Outputs[vout];
|
||||||
@@ -237,8 +322,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
Address = addr.Address.ToString(),
|
Address = addr.Address.ToString(),
|
||||||
IsChange = addr.IsChange,
|
IsChange = addr.IsChange,
|
||||||
AddressIndex = addr.Index,
|
AddressIndex = addr.Index,
|
||||||
Height = txHeights[txid],
|
Height = height,
|
||||||
IsCoinbase = tx.IsCoinBase,
|
IsCoinbase = tx.IsCoinBase,
|
||||||
|
Verified = verifiedTx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +333,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
var history = new List<CachedTx>();
|
var history = new List<CachedTx>();
|
||||||
foreach (var (txid, tx) in transactions)
|
foreach (var (txid, tx) in transactions)
|
||||||
{
|
{
|
||||||
|
var height = txHeights[txid];
|
||||||
var received = tx.Outputs
|
var received = tx.Outputs
|
||||||
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
||||||
.Sum(o => o.Value.Satoshi);
|
.Sum(o => o.Value.Satoshi);
|
||||||
@@ -257,9 +344,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
history.Add(new CachedTx
|
history.Add(new CachedTx
|
||||||
{
|
{
|
||||||
Txid = txid,
|
Txid = txid,
|
||||||
Height = txHeights[txid],
|
Height = height,
|
||||||
DeltaSats = received - sentSats,
|
DeltaSats = received - sentSats,
|
||||||
Verified = verified[txid],
|
Verified = IsTxVerified(txid, height),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
history.Sort((a, b) =>
|
history.Sort((a, b) =>
|
||||||
@@ -286,12 +373,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
|
|
||||||
return new SyncResult
|
return new SyncResult
|
||||||
{
|
{
|
||||||
TipHeight = tip.Height,
|
TipHeight = tipHeight,
|
||||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||||
ImmatureSats = utxos.Where(u =>
|
ImmatureSats = utxos.Where(u =>
|
||||||
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile))
|
u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile))
|
||||||
.Sum(u => u.ValueSats),
|
.Sum(u => u.ValueSats),
|
||||||
|
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
|
||||||
|
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
|
||||||
NextReceiveIndex = nextReceive,
|
NextReceiveIndex = nextReceive,
|
||||||
NextChangeIndex = nextChange,
|
NextChangeIndex = nextChange,
|
||||||
History = history,
|
History = history,
|
||||||
@@ -325,9 +414,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1)
|
await FetchHeaderRangeAsync(cp.Height, height, ct);
|
||||||
.Select(async h => BlockHeaderInfo.Parse(
|
|
||||||
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct)))));
|
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||||
|
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
if (!headers[0].MatchesCheckpoint(cp))
|
if (!headers[0].MatchesCheckpoint(cp))
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
@@ -341,6 +432,41 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
|
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
|
||||||
|
/// is present in <see cref="_headerFetches"/>, downloading gaps with
|
||||||
|
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
|
||||||
|
/// </summary>
|
||||||
|
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _headerRangeLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var h = fromHeight;
|
||||||
|
while (h <= toHeightInclusive)
|
||||||
|
{
|
||||||
|
if (_headerFetches.ContainsKey(h)) { h++; continue; }
|
||||||
|
|
||||||
|
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
|
||||||
|
var range = await client.GetBlockHeadersAsync(h, requested, ct);
|
||||||
|
if (range.Count == 0)
|
||||||
|
throw new SpvVerificationException(
|
||||||
|
$"Server returned no headers starting at height {h}: server is not trustworthy.");
|
||||||
|
|
||||||
|
for (var i = 0; i < range.Count; i++)
|
||||||
|
{
|
||||||
|
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
|
||||||
|
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
|
||||||
|
}
|
||||||
|
h += range.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_headerRangeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans one chain (receiving or change).
|
/// Scans one chain (receiving or change).
|
||||||
///
|
///
|
||||||
@@ -414,7 +540,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
return (firstUnused, tracked, history);
|
return (firstUnused, tracked, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
// Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
|
||||||
|
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
|
||||||
|
// rather than guessed at from wall-clock numbers alone.
|
||||||
|
private int _busyRetries;
|
||||||
|
|
||||||
|
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var delay = 200;
|
var delay = 200;
|
||||||
for (var attempt = 0; ; attempt++)
|
for (var attempt = 0; ; attempt++)
|
||||||
@@ -423,13 +554,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
catch (ElectrumServerException ex)
|
catch (ElectrumServerException ex)
|
||||||
when (IsBusy(ex) && attempt < 7)
|
when (IsBusy(ex) && attempt < 7)
|
||||||
{
|
{
|
||||||
|
ReportBusyRetry(delay);
|
||||||
await Task.Delay(delay, ct);
|
await Task.Delay(delay, ct);
|
||||||
delay = Math.Min(delay * 2, 5_000);
|
delay = Math.Min(delay * 2, 5_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var delay = 200;
|
var delay = 200;
|
||||||
for (var attempt = 0; ; attempt++)
|
for (var attempt = 0; ; attempt++)
|
||||||
@@ -438,12 +570,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
catch (ElectrumServerException ex)
|
catch (ElectrumServerException ex)
|
||||||
when (IsBusy(ex) && attempt < 7)
|
when (IsBusy(ex) && attempt < 7)
|
||||||
{
|
{
|
||||||
|
ReportBusyRetry(delay);
|
||||||
await Task.Delay(delay, ct);
|
await Task.Delay(delay, ct);
|
||||||
delay = Math.Min(delay * 2, 5_000);
|
delay = Math.Min(delay * 2, 5_000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReportBusyRetry(int delayMs)
|
||||||
|
{
|
||||||
|
var n = Interlocked.Increment(ref _busyRetries);
|
||||||
|
if (n == 1 || n % 20 == 0)
|
||||||
|
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsBusy(ElectrumServerException ex) =>
|
private static bool IsBusy(ElectrumServerException ex) =>
|
||||||
ex.Message.Contains("-102") ||
|
ex.Message.Contains("-102") ||
|
||||||
ex.Message.Contains("-101") ||
|
ex.Message.Contains("-101") ||
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ public static class EncryptedFile
|
|||||||
{
|
{
|
||||||
private const string Format = "plm-wallet-aesgcm-v1";
|
private const string Format = "plm-wallet-aesgcm-v1";
|
||||||
private const int DefaultIterations = 600_000;
|
private const int DefaultIterations = 600_000;
|
||||||
|
// Upper bound on the iteration count read from the container: the value is
|
||||||
|
// attacker-controlled in a tampered file, and an absurd count would hang the
|
||||||
|
// wallet at open (PBKDF2 DoS). Leaves ample room for future increases.
|
||||||
|
private const int MaxIterations = 10_000_000;
|
||||||
private const int SaltSize = 16;
|
private const int SaltSize = 16;
|
||||||
private const int NonceSize = 12;
|
private const int NonceSize = 12;
|
||||||
private const int TagSize = 16;
|
private const int TagSize = 16;
|
||||||
@@ -62,23 +66,51 @@ public static class EncryptedFile
|
|||||||
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Throws <see cref="WrongPasswordException"/> if the password is wrong or the file is tampered with.</summary>
|
/// <summary>
|
||||||
|
/// Throws <see cref="WrongPasswordException"/> if the password is wrong or the
|
||||||
|
/// ciphertext is tampered with, <see cref="InvalidDataException"/> for any
|
||||||
|
/// malformed container (broken JSON, missing fields, bad base64, wrong
|
||||||
|
/// nonce/tag size, out-of-range iteration count) — never a raw parsing exception.
|
||||||
|
/// </summary>
|
||||||
public static string Decrypt(string fileContent, string password)
|
public static string Decrypt(string fileContent, string password)
|
||||||
{
|
{
|
||||||
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
Container? container;
|
||||||
?? throw new InvalidDataException("Contenitore cifrato non valido.");
|
try
|
||||||
|
{
|
||||||
|
container = JsonSerializer.Deserialize<Container>(fileContent);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid encrypted container.");
|
||||||
|
}
|
||||||
|
if (container is null)
|
||||||
|
throw new InvalidDataException("Invalid encrypted container.");
|
||||||
if (container.Format != Format)
|
if (container.Format != Format)
|
||||||
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
|
throw new InvalidDataException($"Unknown container format: {container.Format}");
|
||||||
|
if (container.Iterations is <= 0 or > MaxIterations)
|
||||||
|
throw new InvalidDataException($"Iteration count out of range: {container.Iterations}");
|
||||||
|
|
||||||
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
|
byte[] salt, nonce, tag, cipher;
|
||||||
var cipher = Convert.FromBase64String(container.Data);
|
try
|
||||||
|
{
|
||||||
|
salt = Convert.FromBase64String(container.Salt);
|
||||||
|
nonce = Convert.FromBase64String(container.Nonce);
|
||||||
|
tag = Convert.FromBase64String(container.Tag);
|
||||||
|
cipher = Convert.FromBase64String(container.Data);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Invalid encrypted container.");
|
||||||
|
}
|
||||||
|
if (nonce.Length != NonceSize || tag.Length != TagSize)
|
||||||
|
throw new InvalidDataException("Invalid encrypted container.");
|
||||||
|
|
||||||
|
var key = DeriveKey(password, salt, container.Iterations);
|
||||||
var plain = new byte[cipher.Length];
|
var plain = new byte[cipher.Length];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var aes = new AesGcm(key, TagSize);
|
using var aes = new AesGcm(key, TagSize);
|
||||||
aes.Decrypt(
|
aes.Decrypt(nonce, cipher, tag, plain);
|
||||||
Convert.FromBase64String(container.Nonce), cipher,
|
|
||||||
Convert.FromBase64String(container.Tag), plain);
|
|
||||||
}
|
}
|
||||||
catch (AuthenticationTagMismatchException)
|
catch (AuthenticationTagMismatchException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ public sealed class SyncCache
|
|||||||
|
|
||||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||||
public long ImmatureSats { get; set; }
|
public long ImmatureSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
|
||||||
|
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
|
||||||
|
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
|
||||||
|
/// </summary>
|
||||||
|
public long PendingVerificationSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
|
||||||
|
public long SpendableSats { get; set; }
|
||||||
public int NextReceiveIndex { get; set; }
|
public int NextReceiveIndex { get; set; }
|
||||||
public int NextChangeIndex { get; set; }
|
public int NextChangeIndex { get; set; }
|
||||||
public List<CachedTx> History { get; set; } = [];
|
public List<CachedTx> History { get; set; } = [];
|
||||||
@@ -141,6 +151,13 @@ public sealed class CachedTx
|
|||||||
public required string Txid { get; set; }
|
public required string Txid { get; set; }
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public long DeltaSats { get; set; }
|
public long DeltaSats { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once this tx's Merkle proof has actually been checked against a
|
||||||
|
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
|
||||||
|
/// be pending its own proof while background verification catches up). Always
|
||||||
|
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
|
||||||
|
/// </summary>
|
||||||
public bool Verified { get; set; }
|
public bool Verified { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,4 +172,11 @@ public sealed class CachedUtxo
|
|||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public bool IsCoinbase { get; set; }
|
public bool IsCoinbase { get; set; }
|
||||||
public bool Frozen { get; set; }
|
public bool Frozen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
|
||||||
|
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
|
||||||
|
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
|
||||||
|
/// </summary>
|
||||||
|
public bool Verified { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,12 @@ public static class WalletStore
|
|||||||
File.WriteAllText(tmp, content);
|
File.WriteAllText(tmp, content);
|
||||||
File.Move(tmp, path, overwrite: true);
|
File.Move(tmp, path, overwrite: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
|
||||||
|
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
|
||||||
|
/// transactions/headers), where the synchronous version would block the UI.
|
||||||
|
/// </summary>
|
||||||
|
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
|
||||||
|
Task.Run(() => Save(doc, path, password));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var unverified = utxos.Where(u =>
|
||||||
|
!u.Frozen && u.Height > 0 && !u.Verified &&
|
||||||
|
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
|
||||||
|
if (unverified.Count > 0)
|
||||||
|
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
|
||||||
|
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
|
||||||
|
|
||||||
throw new WalletSpendException(reasons.Length > 0
|
throw new WalletSpendException(reasons.Length > 0
|
||||||
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||||
: "No spendable UTXOs selected.");
|
: "No spendable UTXOs selected.");
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ using PalladiumWallet.Core.Spv;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
||||||
|
/// <param name="CoinbaseTag">
|
||||||
|
/// Printable ASCII runs (e.g. pool tags like "/slush/") extracted from the coinbase
|
||||||
|
/// scriptSig; null for non-coinbase inputs or if no printable text was found.
|
||||||
|
/// </param>
|
||||||
public sealed record TxInputInfo(
|
public sealed record TxInputInfo(
|
||||||
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
|
string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase,
|
||||||
|
string? CoinbaseTag = null);
|
||||||
|
|
||||||
/// <summary>An output of a transaction.</summary>
|
/// <summary>An output of a transaction.</summary>
|
||||||
|
/// <param name="OpReturnText">Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise.</param>
|
||||||
public sealed record TxOutputInfo(
|
public sealed record TxOutputInfo(
|
||||||
uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
|
uint Index, long AmountSats, string? Address, string ScriptType, string? OpReturnText, bool IsMine);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Complete transaction data assembled by querying the server: the raw transaction
|
/// Complete transaction data assembled by querying the server: the raw transaction
|
||||||
@@ -105,8 +111,9 @@ public static class TransactionInspector
|
|||||||
{
|
{
|
||||||
var o = tx.Outputs[i];
|
var o = tx.Outputs[i];
|
||||||
var addr = AddrOf(o.ScriptPubKey);
|
var addr = AddrOf(o.ScriptPubKey);
|
||||||
|
var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null;
|
||||||
outputs.Add(new TxOutputInfo(
|
outputs.Add(new TxOutputInfo(
|
||||||
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
|
(uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), opReturn,
|
||||||
addr is not null && ownedAddresses.Contains(addr)));
|
addr is not null && ownedAddresses.Contains(addr)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ public static class TransactionInspector
|
|||||||
{
|
{
|
||||||
if (tx.IsCoinBase)
|
if (tx.IsCoinBase)
|
||||||
{
|
{
|
||||||
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
|
inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true, CoinbaseTagOf(inp.ScriptSig.ToBytes())));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,4 +196,48 @@ public static class TransactionInspector
|
|||||||
}
|
}
|
||||||
catch { return "—"; }
|
catch { return "—"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text.</summary>
|
||||||
|
private static string? OpReturnTextOf(Script script)
|
||||||
|
{
|
||||||
|
if (!script.IsUnspendable) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = script.ToOps().Skip(1)
|
||||||
|
.Where(op => op.PushData is { Length: > 0 })
|
||||||
|
.SelectMany(op => op.PushData)
|
||||||
|
.ToArray();
|
||||||
|
return data.Length == 0 ? null : DecodeUtf8OrHex(data);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DecodeUtf8OrHex(byte[] data)
|
||||||
|
{
|
||||||
|
var text = System.Text.Encoding.UTF8.GetString(data);
|
||||||
|
var looksLikeText = !text.Contains('�') && text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t');
|
||||||
|
return looksLikeText ? text : "0x" + Convert.ToHexString(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool
|
||||||
|
/// tags like "/slush/" embedded among the binary BIP34 height and extranonce.
|
||||||
|
/// </summary>
|
||||||
|
private static string? CoinbaseTagOf(byte[] scriptSig)
|
||||||
|
{
|
||||||
|
var runs = new List<string>();
|
||||||
|
var run = new System.Text.StringBuilder();
|
||||||
|
void Flush()
|
||||||
|
{
|
||||||
|
if (run.Length >= 4) runs.Add(run.ToString());
|
||||||
|
run.Clear();
|
||||||
|
}
|
||||||
|
foreach (var b in scriptSig)
|
||||||
|
{
|
||||||
|
if (b is >= 0x20 and <= 0x7E) run.Append((char)b);
|
||||||
|
else Flush();
|
||||||
|
}
|
||||||
|
Flush();
|
||||||
|
return runs.Count == 0 ? null : string.Join(" ", runs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ public static class UtxoSpendability
|
|||||||
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||||
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
||||||
|
|
||||||
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</summary>
|
/// <summary>
|
||||||
|
/// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle
|
||||||
|
/// proof has actually been checked. Without the <see cref="CachedUtxo.Verified"/> gate a
|
||||||
|
/// malicious server could report a fake confirmed UTXO and have it spent before background
|
||||||
|
/// verification ever caught the forgery.
|
||||||
|
/// </summary>
|
||||||
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||||
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
!utxo.Frozen && utxo.Height > 0 && utxo.Verified
|
||||||
|
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hello world
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ábÊco ábaco0ábacoábÊ
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ábaco ábaco ábaco
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0,001
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
79228162514264337593543950335
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.50000000
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","‡50002"]]]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
not-a-key
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
xpub661MyMwAqRbcF
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Version":99}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Spv;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Fuzz;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fuzz targets over the parsers that consume untrusted input (server responses,
|
||||||
|
/// wallet files, user-pasted keys/addresses/amounts). Each target enforces the
|
||||||
|
/// parser's error contract: the exception types documented as its failure mode
|
||||||
|
/// are swallowed, anything else escapes and is reported as a crash by the fuzzer.
|
||||||
|
/// </summary>
|
||||||
|
public static class FuzzTargets
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyDictionary<string, Action<byte[]>> All =
|
||||||
|
new Dictionary<string, Action<byte[]>>
|
||||||
|
{
|
||||||
|
["header"] = Header,
|
||||||
|
["merkle"] = Merkle,
|
||||||
|
["slip132"] = Slip132Keys,
|
||||||
|
["bip39"] = Bip39Mnemonic,
|
||||||
|
["address"] = Address,
|
||||||
|
["coinamount"] = CoinAmountParse,
|
||||||
|
["walletdoc"] = WalletDoc,
|
||||||
|
["encfile"] = EncFile,
|
||||||
|
["peers"] = Peers,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Runs one input through a named target (used by replay and the regression test).</summary>
|
||||||
|
public static void Run(string target, byte[] data) => All[target](data);
|
||||||
|
|
||||||
|
private static string Utf8(byte[] data) => Encoding.UTF8.GetString(data);
|
||||||
|
|
||||||
|
// 80-byte block headers arrive from the server both as raw bytes and as hex.
|
||||||
|
// Contract: exactly 80 bytes never throws; any other length is ArgumentException;
|
||||||
|
// the hex overload may also reject non-hex text with FormatException.
|
||||||
|
private static void Header(byte[] data)
|
||||||
|
{
|
||||||
|
try { BlockHeaderInfo.Parse(data); }
|
||||||
|
catch (ArgumentException) when (data.Length != BlockHeaderInfo.Size) { }
|
||||||
|
|
||||||
|
try { BlockHeaderInfo.Parse(Utf8(data)); }
|
||||||
|
catch (FormatException) { }
|
||||||
|
catch (ArgumentException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merkle proof verification runs on server-supplied txid/pos/branch.
|
||||||
|
// Contract: never throws, for any position (including negative) and branch.
|
||||||
|
private static void Merkle(byte[] data)
|
||||||
|
{
|
||||||
|
if (data.Length < 68)
|
||||||
|
return;
|
||||||
|
var txid = new uint256(data.AsSpan(0, 32));
|
||||||
|
var position = BitConverter.ToInt32(data, 32);
|
||||||
|
var root = new uint256(data.AsSpan(36, 32));
|
||||||
|
var branch = new List<uint256>();
|
||||||
|
for (var offset = 68; offset + 32 <= data.Length; offset += 32)
|
||||||
|
branch.Add(new uint256(data.AsSpan(offset, 32)));
|
||||||
|
|
||||||
|
MerkleProof.Verify(txid, position, branch, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLIP-132 extended keys are pasted by the user on import.
|
||||||
|
// Contract: the Try* pair never throws, on any profile.
|
||||||
|
private static void Slip132Keys(byte[] data)
|
||||||
|
{
|
||||||
|
var text = Utf8(data);
|
||||||
|
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
|
||||||
|
{
|
||||||
|
Slip132.TryDecodePublic(text, profile, out _, out _);
|
||||||
|
Slip132.TryDecodePrivate(text, profile, out _, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mnemonics are pasted by the user on restore. Contract: TryParse never throws,
|
||||||
|
// with auto-detected and explicit wordlist language alike.
|
||||||
|
private static void Bip39Mnemonic(byte[] data)
|
||||||
|
{
|
||||||
|
var text = Utf8(data);
|
||||||
|
Bip39.TryParse(text, out _);
|
||||||
|
if (data.Length > 0)
|
||||||
|
Bip39.TryParse(text, out _, (MnemonicLanguage)(data[0] % 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient addresses are pasted by the user (or scanned from a QR).
|
||||||
|
// Contract: NBitcoin rejects anything invalid with FormatException.
|
||||||
|
private static void Address(byte[] data)
|
||||||
|
{
|
||||||
|
try { BitcoinAddress.Create(Utf8(data), PalladiumNetworks.Mainnet); }
|
||||||
|
catch (FormatException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amounts are typed by the user in the currently selected unit.
|
||||||
|
// Contract: TryParse* never throws for any of the official units.
|
||||||
|
private static void CoinAmountParse(byte[] data)
|
||||||
|
{
|
||||||
|
var text = Utf8(data);
|
||||||
|
foreach (var unit in CoinAmount.Units)
|
||||||
|
CoinAmount.TryParseIn(text, unit, out _);
|
||||||
|
CoinAmount.TryParseCoins(text, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plaintext wallet document is read from disk at every open.
|
||||||
|
// Contract: malformed JSON or schema is JsonException/InvalidDataException.
|
||||||
|
private static void WalletDoc(byte[] data)
|
||||||
|
{
|
||||||
|
try { WalletDocument.FromJson(Utf8(data)); }
|
||||||
|
catch (JsonException) { }
|
||||||
|
catch (InvalidDataException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The encrypted container wraps the document on disk; a tampered file must
|
||||||
|
// fail with the two typed exceptions, never a raw parser error, and
|
||||||
|
// IsEncrypted (the format sniffer) must never throw at all.
|
||||||
|
private static void EncFile(byte[] data)
|
||||||
|
{
|
||||||
|
var text = Utf8(data);
|
||||||
|
EncryptedFile.IsEncrypted(text);
|
||||||
|
try { EncryptedFile.Decrypt(text, "fuzz-password"); }
|
||||||
|
catch (WrongPasswordException) { }
|
||||||
|
catch (InvalidDataException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// server.peers.subscribe responses are attacker-controlled JSON.
|
||||||
|
// Contract: any well-formed JSON of any shape parses to a (possibly empty)
|
||||||
|
// peer list without throwing.
|
||||||
|
private static void Peers(byte[] data)
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
try { doc = JsonDocument.Parse(data); }
|
||||||
|
catch (JsonException) { return; }
|
||||||
|
using (doc)
|
||||||
|
ElectrumApi.ParsePeers(doc.RootElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<!-- Fuzzing wants stable, non-inlined instrumentation points. -->
|
||||||
|
<TieredCompilation>false</TieredCompilation>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SharpFuzz" Version="2.3.0" />
|
||||||
|
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Seed corpus travels next to the binary so replay and the regression
|
||||||
|
test in PalladiumWallet.Tests can find it via AppContext.BaseDirectory. -->
|
||||||
|
<None Include="Corpus\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System.Text;
|
||||||
|
using PalladiumWallet.Fuzz;
|
||||||
|
|
||||||
|
// Fuzzing entry point. One binary, one target per process (fuzzers want a
|
||||||
|
// single deterministic execution path). Modes:
|
||||||
|
//
|
||||||
|
// <target> --afl run under afl-fuzz (instrumented Core, see fuzz.sh)
|
||||||
|
// <target> --libfuzzer run under libfuzzer-dotnet
|
||||||
|
// <target> --random N [seed] built-in dumb fuzzer: N corpus mutations (no tooling needed)
|
||||||
|
// <target> <file|dir> ... replay saved inputs (used by the regression test / triage)
|
||||||
|
// --make-seeds <dir> regenerate the seed corpus
|
||||||
|
//
|
||||||
|
// A "crash" is any exception escaping the target: each target already swallows
|
||||||
|
// the exception types that are part of the parser's documented contract.
|
||||||
|
|
||||||
|
if (args.Length >= 2 && args[0] == "--make-seeds")
|
||||||
|
{
|
||||||
|
SeedCorpus.WriteAll(args[1]);
|
||||||
|
Console.WriteLine($"Seed corpus written to {args[1]}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.Length < 2 || !FuzzTargets.All.TryGetValue(args[0], out var target))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Usage: PalladiumWallet.Fuzz <target> --afl | --libfuzzer | --random N [seed] | <file|dir>...");
|
||||||
|
Console.Error.WriteLine(" PalladiumWallet.Fuzz --make-seeds <dir>");
|
||||||
|
Console.Error.WriteLine($"Targets: {string.Join(' ', FuzzTargets.All.Keys)}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (args[1])
|
||||||
|
{
|
||||||
|
case "--afl":
|
||||||
|
SharpFuzz.Fuzzer.Run(stream =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
stream.CopyTo(ms);
|
||||||
|
target(ms.ToArray());
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case "--libfuzzer":
|
||||||
|
SharpFuzz.Fuzzer.LibFuzzer.Run(span => target(span.ToArray()));
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case "--random":
|
||||||
|
{
|
||||||
|
var iterations = args.Length > 2 ? int.Parse(args[2]) : 100_000;
|
||||||
|
var seed = args.Length > 3 ? ulong.Parse(args[3]) : 0xF022_5EEDUL;
|
||||||
|
return RandomFuzz.Run(args[0], target, iterations, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
var failures = 0;
|
||||||
|
foreach (var path in args[1..])
|
||||||
|
foreach (var file in Directory.Exists(path)
|
||||||
|
? Directory.EnumerateFiles(path).OrderBy(f => f, StringComparer.Ordinal)
|
||||||
|
: (IEnumerable<string>)[path])
|
||||||
|
{
|
||||||
|
var data = File.ReadAllBytes(file);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
target(data);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures++;
|
||||||
|
Console.Error.WriteLine($"CRASH {args[0]} on {file}:");
|
||||||
|
Console.Error.WriteLine(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal corpus-mutation fuzzer for smoke runs and CI: not coverage-guided,
|
||||||
|
/// but catches shallow contract violations without afl++/libFuzzer installed.
|
||||||
|
/// Deterministic for a given seed; on crash it saves the input for replay.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RandomFuzz
|
||||||
|
{
|
||||||
|
public static int Run(string name, Action<byte[]> target, int iterations, ulong seed)
|
||||||
|
{
|
||||||
|
var corpus = SeedCorpus.For(name);
|
||||||
|
var rng = seed;
|
||||||
|
ulong Next() { rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17; return rng; }
|
||||||
|
|
||||||
|
for (var i = 0; i < iterations; i++)
|
||||||
|
{
|
||||||
|
var data = (byte[])corpus[(int)(Next() % (ulong)corpus.Count)].Clone();
|
||||||
|
var mutations = 1 + (int)(Next() % 8);
|
||||||
|
for (var m = 0; m < mutations && data.Length > 0; m++)
|
||||||
|
switch (Next() % 4)
|
||||||
|
{
|
||||||
|
case 0: data[Next() % (ulong)data.Length] = (byte)Next(); break;
|
||||||
|
case 1: data[Next() % (ulong)data.Length] ^= (byte)(1 << (int)(Next() % 8)); break;
|
||||||
|
case 2: Array.Resize(ref data, (int)(Next() % 512) + 1); break;
|
||||||
|
case 3: data = [.. data, .. data[..(int)(Next() % (ulong)data.Length)]]; break;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
target(data);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var crashFile = $"crash-{name}-{i}.bin";
|
||||||
|
File.WriteAllBytes(crashFile, data);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"CRASH {name} at iteration {i}: {ex.GetType().Name}: {ex.Message} (input saved to {crashFile})");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.WriteLine($"{name}: {iterations} random mutations, no contract violations");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seed inputs per target: small valid (or near-valid) examples that give the
|
||||||
|
/// mutation engines a meaningful starting shape. Regenerate with --make-seeds.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SeedCorpus
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, (string Name, byte[] Data)[]> Seeds = new()
|
||||||
|
{
|
||||||
|
["header"] =
|
||||||
|
[
|
||||||
|
("genesis", Convert.FromHexString(
|
||||||
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
|
||||||
|
("genesis-hex", Encoding.UTF8.GetBytes(
|
||||||
|
"0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c")),
|
||||||
|
("short", [0x01, 0x00, 0x00, 0x00]),
|
||||||
|
],
|
||||||
|
["merkle"] =
|
||||||
|
[
|
||||||
|
("one-level", [.. new byte[32], .. BitConverter.GetBytes(1), .. new byte[32], .. Enumerable.Repeat((byte)0xAA, 32)]),
|
||||||
|
("no-branch", [.. Enumerable.Repeat((byte)0x11, 32), .. BitConverter.GetBytes(0), .. Enumerable.Repeat((byte)0x11, 32)]),
|
||||||
|
],
|
||||||
|
["slip132"] =
|
||||||
|
[
|
||||||
|
("zpub", Encoding.UTF8.GetBytes(
|
||||||
|
"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs")),
|
||||||
|
("xpub-prefix", Encoding.UTF8.GetBytes("xpub661MyMwAqRbcF")),
|
||||||
|
("garbage", Encoding.UTF8.GetBytes("not-a-key")),
|
||||||
|
],
|
||||||
|
["bip39"] =
|
||||||
|
[
|
||||||
|
("english-12", Encoding.UTF8.GetBytes(
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")),
|
||||||
|
("spanish-word", Encoding.UTF8.GetBytes("ábaco ábaco ábaco")),
|
||||||
|
("empty", []),
|
||||||
|
// Regression: NBitcoin AutoDetect → "Unknown" language → NotSupportedException
|
||||||
|
// escaped TryParse (fixed by catching it).
|
||||||
|
("regression-notsupported", Convert.FromHexString(
|
||||||
|
"c3a162ca636f20c3a16261636f30c3a16261636fc3a162ca")),
|
||||||
|
],
|
||||||
|
["address"] =
|
||||||
|
[
|
||||||
|
("bech32", Encoding.UTF8.GetBytes("plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay")),
|
||||||
|
("legacy-ish", Encoding.UTF8.GetBytes("PB6q3PB6q3PB6q3PB6q3PB6q3PB6q3PB6q")),
|
||||||
|
("garbage", Encoding.UTF8.GetBytes("hello world")),
|
||||||
|
],
|
||||||
|
["coinamount"] =
|
||||||
|
[
|
||||||
|
("plain", Encoding.UTF8.GetBytes("1.50000000")),
|
||||||
|
("comma", Encoding.UTF8.GetBytes("0,001")),
|
||||||
|
("huge", Encoding.UTF8.GetBytes("79228162514264337593543950335")),
|
||||||
|
],
|
||||||
|
["walletdoc"] =
|
||||||
|
[
|
||||||
|
("minimal", Encoding.UTF8.GetBytes(
|
||||||
|
"""{"Version":1,"Network":"mainnet","ScriptKind":"NativeSegwit"}""")),
|
||||||
|
("bad-version", Encoding.UTF8.GetBytes("""{"Version":99}""")),
|
||||||
|
("not-json", Encoding.UTF8.GetBytes("hello")),
|
||||||
|
],
|
||||||
|
["encfile"] =
|
||||||
|
[
|
||||||
|
// Well-formed container with a tiny iteration count so every replay and
|
||||||
|
// mutated descendant skips the full PBKDF2 cost (decryption then fails
|
||||||
|
// with the typed WrongPasswordException — exactly the contract under test).
|
||||||
|
("container", Encoding.UTF8.GetBytes(
|
||||||
|
"""{"Format":"plm-wallet-aesgcm-v1","Iterations":2,"Salt":"AAAAAAAAAAAAAAAAAAAAAA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":"AAAA"}""")),
|
||||||
|
("wrong-format", Encoding.UTF8.GetBytes(
|
||||||
|
"""{"Format":"other","Iterations":2,"Salt":"","Nonce":"","Tag":"","Data":""}""")),
|
||||||
|
("not-json", Encoding.UTF8.GetBytes("hello")),
|
||||||
|
],
|
||||||
|
["peers"] =
|
||||||
|
[
|
||||||
|
("typical", Encoding.UTF8.GetBytes(
|
||||||
|
"""[["1.2.3.4","peer.example",["v1.4.2","p10","t50001","s50002"]]]""")),
|
||||||
|
("odd-shapes", Encoding.UTF8.GetBytes("""[[1,2,3],{"a":1},"x",[["h","n",[42,"t"]]]]""")),
|
||||||
|
// Regression: raw invalid-UTF-8 byte inside a JSON string parses but cannot
|
||||||
|
// be transcoded by GetString() → InvalidOperationException (fixed in AsString).
|
||||||
|
("regression-bad-utf8", Convert.FromHexString(
|
||||||
|
"5b5b22312e322e332e34222c22706565722e6578616d706c65222c5b2276312e342e32222c22703130222c227435303030" +
|
||||||
|
"31222c22873530303032225d5d5d")),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IReadOnlyList<byte[]> For(string target) =>
|
||||||
|
Seeds[target].Select(s => s.Data).ToList();
|
||||||
|
|
||||||
|
public static void WriteAll(string root)
|
||||||
|
{
|
||||||
|
foreach (var (target, seeds) in Seeds)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(root, target);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
foreach (var (name, data) in seeds)
|
||||||
|
File.WriteAllBytes(Path.Combine(dir, name + ".bin"), data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Fuzzing
|
||||||
|
|
||||||
|
Fuzz targets over every parser that consumes **untrusted input**: server
|
||||||
|
responses (block headers, Merkle proofs, peer lists), wallet files (plaintext
|
||||||
|
document and encrypted container), and user-pasted text (mnemonics, SLIP-132
|
||||||
|
keys, addresses, amounts).
|
||||||
|
|
||||||
|
Each target encodes the parser's **error contract**: the exception types
|
||||||
|
documented as its failure mode are swallowed, anything else escaping is a
|
||||||
|
finding. Targets: `header` `merkle` `slip132` `bip39` `address` `coinamount`
|
||||||
|
`walletdoc` `encfile` `peers` (see `FuzzTargets.cs` for each contract).
|
||||||
|
|
||||||
|
## Three ways to run
|
||||||
|
|
||||||
|
**1. Corpus replay — automatic.** The seed corpus (including regression inputs
|
||||||
|
for every crash found so far) replays through all targets on every
|
||||||
|
`dotnet test` run via `FuzzCorpusTests`, so fixed findings cannot come back.
|
||||||
|
|
||||||
|
**2. Built-in random smoke — no tooling.** Not coverage-guided, but catches
|
||||||
|
shallow contract violations in seconds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run -- bip39 --random 50000 # target, iterations [, seed]
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Coverage-guided campaign — afl++.** The real thing; run it before a
|
||||||
|
release or after touching any parser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install afl++
|
||||||
|
dotnet tool install --global SharpFuzz.CommandLine
|
||||||
|
./fuzz.sh header # one target per campaign
|
||||||
|
./fuzz.sh peers -V 3600 # extra args go to afl-fuzz
|
||||||
|
```
|
||||||
|
|
||||||
|
`fuzz.sh` builds Release, instruments `PalladiumWallet.Core.dll` (and NBitcoin)
|
||||||
|
with SharpFuzz, and launches afl-fuzz with the target's seed corpus. Findings
|
||||||
|
land in `findings/<target>/crashes/`.
|
||||||
|
|
||||||
|
## Triage workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run -- <target> findings/<target>/crashes/<file> # replay: full stack trace
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix the parser (typed exception or graceful rejection — see the hardening
|
||||||
|
commits for `Bip39.TryParse`, `ElectrumApi.ParsePeers`, `EncryptedFile.Decrypt`
|
||||||
|
as examples), then add the input to `SeedCorpus` in `Program.cs` as a
|
||||||
|
`regression-*` seed and regenerate with `dotnet run -- --make-seeds Corpus`.
|
||||||
|
|
||||||
|
## Findings so far
|
||||||
|
|
||||||
|
The first smoke run found two real bugs, both reachable from untrusted input:
|
||||||
|
|
||||||
|
- `Bip39.TryParse` crashed with `NotSupportedException` on restore text
|
||||||
|
resembling no wordlist (NBitcoin's `Wordlist.AutoDetect` throws for its
|
||||||
|
internal "Unknown" language).
|
||||||
|
- `ElectrumApi.ParsePeers` crashed with `InvalidOperationException` on a peer
|
||||||
|
list containing a JSON string with invalid UTF-8 bytes (parses fine,
|
||||||
|
fails at `GetString()` transcoding) — attacker-controlled server data.
|
||||||
|
|
||||||
|
Plus two hardening changes made so the `encfile`/`peers` contracts could be
|
||||||
|
strict at all: `EncryptedFile.Decrypt` maps every malformed-container failure
|
||||||
|
to `InvalidDataException` and bounds the PBKDF2 iteration count (a tampered
|
||||||
|
file could previously demand 2^31 iterations, hanging the wallet at open), and
|
||||||
|
`ParsePeers` tolerates any JSON shape.
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Coverage-guided fuzzing with afl++ + SharpFuzz.
|
||||||
|
#
|
||||||
|
# One-time setup:
|
||||||
|
# apt install afl++ (or build from source)
|
||||||
|
# dotnet tool install --global SharpFuzz.CommandLine
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./fuzz.sh <target> [afl-fuzz args...]
|
||||||
|
# ./fuzz.sh header
|
||||||
|
# ./fuzz.sh peers -V 3600 # time-limited campaign (seconds)
|
||||||
|
#
|
||||||
|
# Targets: header merkle slip132 bip39 address coinamount walletdoc encfile peers
|
||||||
|
#
|
||||||
|
# Findings land in findings/<target>/crashes/: replay one with
|
||||||
|
# dotnet run -- <target> findings/<target>/crashes/<file>
|
||||||
|
# then add it to the seed corpus in Program.cs as a regression input.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
TARGET="${1:?usage: ./fuzz.sh <target> [afl-fuzz args...]}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
command -v afl-fuzz >/dev/null || { echo "afl-fuzz not found: apt install afl++"; exit 1; }
|
||||||
|
command -v sharpfuzz >/dev/null || { echo "sharpfuzz not found: dotnet tool install --global SharpFuzz.CommandLine"; exit 1; }
|
||||||
|
|
||||||
|
dotnet build -c Release
|
||||||
|
BIN="bin/Release/net10.0"
|
||||||
|
|
||||||
|
# Instrument the assemblies under test (idempotent: SharpFuzz skips already-
|
||||||
|
# instrumented DLLs). Core is the real target; NBitcoin gives the fuzzer
|
||||||
|
# visibility into the library our parsers delegate to.
|
||||||
|
sharpfuzz "$BIN/PalladiumWallet.Core.dll"
|
||||||
|
sharpfuzz "$BIN/NBitcoin.dll" || true
|
||||||
|
|
||||||
|
mkdir -p "findings/$TARGET"
|
||||||
|
afl-fuzz -i "Corpus/$TARGET" -o "findings/$TARGET" -t 5000 "$@" \
|
||||||
|
-- dotnet "$BIN/PalladiumWallet.Fuzz.dll" "$TARGET" --afl
|
||||||
@@ -126,4 +126,15 @@ public class Bip39Tests
|
|||||||
Assert.Throws<ArgumentOutOfRangeException>(
|
Assert.Throws<ArgumentOutOfRangeException>(
|
||||||
() => Bip39.Generate(MnemonicLength.Twelve, (MnemonicLanguage)99));
|
() => Bip39.Generate(MnemonicLength.Twelve, (MnemonicLanguage)99));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Testo_che_non_somiglia_a_nessuna_wordlist_viene_rifiutato_senza_eccezioni()
|
||||||
|
{
|
||||||
|
// NBitcoin's Wordlist.AutoDetect throws NotSupportedException("Unknown")
|
||||||
|
// for text resembling no wordlist instead of failing gracefully (found by
|
||||||
|
// fuzzing): TryParse must swallow it and return false — this string reaches
|
||||||
|
// TryParse straight from the user's restore input.
|
||||||
|
Assert.False(Bip39.TryParse("ábÊco ábaco0ábaco ábÊ", out var mnemonic));
|
||||||
|
Assert.Null(mnemonic);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using PalladiumWallet.Fuzz;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replays the fuzzing seed corpus (tests/PalladiumWallet.Fuzz/Corpus, copied
|
||||||
|
/// next to the test binary) through every fuzz target on each test run: the
|
||||||
|
/// corpus includes the regression inputs for crashes found by past campaigns,
|
||||||
|
/// so a fixed contract violation can never silently come back. The real
|
||||||
|
/// coverage-guided campaigns run separately via tests/PalladiumWallet.Fuzz/fuzz.sh.
|
||||||
|
/// </summary>
|
||||||
|
public class FuzzCorpusTests
|
||||||
|
{
|
||||||
|
public static TheoryData<string> Targets()
|
||||||
|
{
|
||||||
|
var data = new TheoryData<string>();
|
||||||
|
foreach (var name in FuzzTargets.All.Keys)
|
||||||
|
data.Add(name);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Targets))]
|
||||||
|
public void Il_corpus_del_target_rispetta_il_contratto_del_parser(string target)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", target);
|
||||||
|
Assert.True(Directory.Exists(dir), $"seed corpus missing for '{target}' at {dir}");
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(dir))
|
||||||
|
{
|
||||||
|
// Any escaping exception is a contract violation: the target itself
|
||||||
|
// swallows the exception types documented as the parser's failure mode.
|
||||||
|
FuzzTargets.Run(target, File.ReadAllBytes(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,35 @@ public class PeerParsingTests
|
|||||||
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
|
Assert.Equal(new PeerInfo("nodo.esempio.org", 0, null, "1.4"), peers[1]);
|
||||||
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
Assert.Equal(new PeerInfo("solo-ssl.esempio.org", null, 50002, "1.4"), peers[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"not":"an array"}""")]
|
||||||
|
[InlineData(""" "just a string" """)]
|
||||||
|
[InlineData("42")]
|
||||||
|
[InlineData("""[[1,2,3]]""")] // numeric ip/hostname
|
||||||
|
[InlineData("""[["h","n",42]]""")] // features not an array
|
||||||
|
[InlineData("""[["h","n",[42,{"x":1},null]]]""")] // non-string features
|
||||||
|
public void Una_risposta_peers_di_forma_inattesa_produce_una_lista_vuota(string json)
|
||||||
|
{
|
||||||
|
// The response is untrusted server data: any shape must degrade to an
|
||||||
|
// empty list, never throw (found by fuzzing).
|
||||||
|
Assert.Empty(ElectrumApi.ParsePeers(JsonDocument.Parse(json).RootElement));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Una_stringa_json_con_utf8_invalido_viene_ignorata_senza_eccezioni()
|
||||||
|
{
|
||||||
|
// Raw 0x87 inside a JSON string: JsonDocument.Parse accepts the bytes but
|
||||||
|
// GetString() cannot transcode them to UTF-16 (found by fuzzing). The
|
||||||
|
// whole entry degrades to "no usable host/feature", not an exception.
|
||||||
|
var bytes = "[[\"1.2.3.4\",\"#\",[\"v1\",\"t#\"]]]"u8.ToArray();
|
||||||
|
var hostIdx = Array.IndexOf(bytes, (byte)'#');
|
||||||
|
bytes[hostIdx] = 0x87;
|
||||||
|
bytes[Array.LastIndexOf(bytes, (byte)'#')] = 0x87;
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(bytes);
|
||||||
|
Assert.Empty(ElectrumApi.ParsePeers(doc.RootElement));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ServerRegistryTests
|
public class ServerRegistryTests
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ public class WalletSynchronizerTests
|
|||||||
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
Headers.TryGetValue(p[0].GetInt32(), out var hex)
|
||||||
? hex
|
? hex
|
||||||
: throw new FakeElectrumError(-32600, "no such block"));
|
: throw new FakeElectrumError(-32600, "no such block"));
|
||||||
|
server.Handle("blockchain.block.headers", p =>
|
||||||
|
{
|
||||||
|
var start = p[0].GetInt32();
|
||||||
|
var count = p[1].GetInt32();
|
||||||
|
var hexes = new List<string>();
|
||||||
|
for (var h = start; hexes.Count < count && Headers.TryGetValue(h, out var hex); h++)
|
||||||
|
hexes.Add(hex);
|
||||||
|
return new { count = hexes.Count, hex = string.Concat(hexes) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +241,10 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Equal(0, result.ConfirmedSats);
|
Assert.Equal(0, result.ConfirmedSats);
|
||||||
Assert.Equal(250_000, result.UnconfirmedSats);
|
Assert.Equal(250_000, result.UnconfirmedSats);
|
||||||
var entry = Assert.Single(result.History);
|
var entry = Assert.Single(result.History);
|
||||||
Assert.False(entry.Verified);
|
// Verified means "its Merkle proof was checked" — a mempool tx has no proof to
|
||||||
|
// check yet, so it's vacuously true; "not confirmed" is signalled by Height <= 0,
|
||||||
|
// not by Verified (no merkle/header RPCs happen, asserted below).
|
||||||
|
Assert.True(entry.Verified);
|
||||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
|
||||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
}
|
}
|
||||||
@@ -318,8 +330,11 @@ public class WalletSynchronizerTests
|
|||||||
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
||||||
|
|
||||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||||
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint.
|
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
|
||||||
Assert.Equal(6, server.CallCount("blockchain.block.header"));
|
// covering 100..105 back to the checkpoint already includes the tx's own header —
|
||||||
|
// no separate single-header call needed.
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -352,12 +367,13 @@ public class WalletSynchronizerTests
|
|||||||
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
||||||
|
|
||||||
// 103 <= the memoized 105: anchoring must early-return without re-walking,
|
// 103 <= the memoized 105: anchoring must early-return without re-walking,
|
||||||
// so the header call count stays at the 6 of the first walk (103 is cached).
|
// so the header/range call counts stay at those of the first walk (103 is cached).
|
||||||
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
||||||
var result = await sync.SyncOnceAsync();
|
var result = await sync.SyncOnceAsync();
|
||||||
|
|
||||||
Assert.Equal(1_500_000, result.ConfirmedSats);
|
Assert.Equal(1_500_000, result.ConfirmedSats);
|
||||||
Assert.Equal(6, server.CallCount("blockchain.block.header"));
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
|
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -426,6 +442,89 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Equal(1, server.CallCount("blockchain.block.header"));
|
Assert.Equal(1, server.CallCount("blockchain.block.header"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- verifica progressiva (§7.4) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PartialResult_arriva_prima_della_proof_poi_il_risultato_finale_e_completamente_verificato()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario();
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
// Gate the Merkle-proof response so the download phase can complete (and fire
|
||||||
|
// PartialResult) well before verification does.
|
||||||
|
using var gate = new ManualResetEventSlim(false);
|
||||||
|
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||||
|
{
|
||||||
|
gate.Wait();
|
||||||
|
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
|
||||||
|
});
|
||||||
|
|
||||||
|
var sync = new WalletSynchronizer(account, client);
|
||||||
|
SyncResult? partial = null;
|
||||||
|
var partialReceived = new TaskCompletionSource();
|
||||||
|
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
|
||||||
|
|
||||||
|
var syncTask = sync.SyncOnceAsync();
|
||||||
|
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.NotNull(partial);
|
||||||
|
Assert.False(Assert.Single(partial!.History).Verified);
|
||||||
|
Assert.False(Assert.Single(partial.Utxos).Verified);
|
||||||
|
Assert.Equal(1_000_000, partial.PendingVerificationSats);
|
||||||
|
Assert.Equal(1_000_000, partial.ConfirmedSats); // reported confirmed, just not yet proof-checked
|
||||||
|
|
||||||
|
gate.Set();
|
||||||
|
var final = await syncTask;
|
||||||
|
|
||||||
|
Assert.True(Assert.Single(final.History).Verified);
|
||||||
|
Assert.True(Assert.Single(final.Utxos).Verified);
|
||||||
|
Assert.Equal(0, final.PendingVerificationSats);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_coinbase_immaturo_e_non_ancora_verificato_non_produce_un_saldo_spendibile_negativo()
|
||||||
|
{
|
||||||
|
// Regression: ImmatureSats and PendingVerificationSats can overlap (an immature
|
||||||
|
// coinbase can also be unverified) — "ConfirmedSats - ImmatureSats - PendingVerificationSats"
|
||||||
|
// double-subtracts that overlap and can go negative. SpendableSats must be computed
|
||||||
|
// directly from IsSpendable instead.
|
||||||
|
var account = Account();
|
||||||
|
var scenario = new Scenario { TipHeight = 200 };
|
||||||
|
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121 — immature.
|
||||||
|
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(scenario);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
using var gate = new ManualResetEventSlim(false);
|
||||||
|
server.Handle("blockchain.transaction.get_merkle", p =>
|
||||||
|
{
|
||||||
|
gate.Wait();
|
||||||
|
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
|
||||||
|
});
|
||||||
|
|
||||||
|
var sync = new WalletSynchronizer(account, client);
|
||||||
|
var partialReceived = new TaskCompletionSource();
|
||||||
|
SyncResult? partial = null;
|
||||||
|
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
|
||||||
|
|
||||||
|
var syncTask = sync.SyncOnceAsync();
|
||||||
|
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Immature AND unverified at the same time: both non-spendable subsets are the
|
||||||
|
// full 5 PLM, but the actual spendable balance must be exactly zero, not negative.
|
||||||
|
Assert.Equal(5_000_000_000, partial!.ImmatureSats);
|
||||||
|
Assert.Equal(5_000_000_000, partial.PendingVerificationSats);
|
||||||
|
Assert.Equal(0, partial.SpendableSats);
|
||||||
|
|
||||||
|
gate.Set();
|
||||||
|
await syncTask;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- resilienza ----
|
// ---- resilienza ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ public class StorageTests
|
|||||||
Assert.NotEqual(c1, c2);
|
Assert.NotEqual(c1, c2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not json at all")] // broken JSON
|
||||||
|
[InlineData("null")] // JSON null
|
||||||
|
[InlineData("""{"Format":"plm-wallet-aesgcm-v1"}""")] // missing fields → null strings
|
||||||
|
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"$$$","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // bad base64
|
||||||
|
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"AA==","Nonce":"AA==","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // wrong nonce size
|
||||||
|
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":0,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // iterations 0
|
||||||
|
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":2147483647,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // PBKDF2 DoS
|
||||||
|
public void Un_contenitore_malformato_produce_sempre_InvalidDataException(string container)
|
||||||
|
{
|
||||||
|
// A tampered/corrupted file must map to the two typed exceptions, never to a
|
||||||
|
// raw JsonException/FormatException/ArgumentNullException (found by fuzzing);
|
||||||
|
// the absurd iteration count would otherwise hang the wallet at open.
|
||||||
|
Assert.Throws<InvalidDataException>(() => EncryptedFile.Decrypt(container, "pass"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Ogni_encrypt_produce_salt_diverso()
|
public void Ogni_encrypt_produce_salt_diverso()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,12 +33,28 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
Txid = txid, Vout = 0, ValueSats = sats,
|
Txid = txid, Vout = 0, ValueSats = sats,
|
||||||
Address = account.GetReceiveAddress(0).ToString(),
|
Address = account.GetReceiveAddress(0).ToString(),
|
||||||
IsChange = false, AddressIndex = 0, Height = 100,
|
IsChange = false, AddressIndex = 0, Height = 100, Verified = true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
|
return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Un_utxo_confermato_ma_non_ancora_verificato_non_e_spendibile()
|
||||||
|
{
|
||||||
|
// Confirmed by the server, but its Merkle proof hasn't been checked yet (progressive
|
||||||
|
// background verification, §7.4): must never be treated as spendable — otherwise a
|
||||||
|
// malicious server could get a fabricated balance spent before the forgery is caught.
|
||||||
|
var account = Account();
|
||||||
|
var (utxos, txs) = Fund(account, 1_000_000);
|
||||||
|
utxos[0].Verified = false;
|
||||||
|
|
||||||
|
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
|
||||||
|
utxos, txs, account.GetReceiveAddress(5), amountSats: 400_000,
|
||||||
|
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
|
||||||
|
Assert.Contains("awaiting Merkle-proof verification", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
|
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
|
||||||
{
|
{
|
||||||
@@ -169,7 +185,7 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
|
||||||
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
Address = mainnetAccount.GetReceiveAddress(0).ToString(),
|
||||||
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
|
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false, Verified = true },
|
||||||
};
|
};
|
||||||
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
||||||
|
|
||||||
@@ -295,7 +311,7 @@ public class TransactionFactoryTests
|
|||||||
{
|
{
|
||||||
Txid = txid, Vout = 0, ValueSats = 300_000,
|
Txid = txid, Vout = 0, ValueSats = 300_000,
|
||||||
Address = account.GetReceiveAddress(i).ToString(),
|
Address = account.GetReceiveAddress(i).ToString(),
|
||||||
IsChange = false, AddressIndex = i, Height = 100,
|
IsChange = false, AddressIndex = i, Height = 100, Verified = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,139 @@ public class TransactionInspectorTests
|
|||||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_output_OP_RETURN_con_testo_UTF8_viene_decodificato()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var tx = Net.CreateTransaction();
|
||||||
|
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("hello palladium"u8.ToArray()));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(tx);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: 1_000_000, verified: true);
|
||||||
|
|
||||||
|
var opReturn = Assert.Single(details.Outputs, o => o.Index == 1);
|
||||||
|
Assert.Null(opReturn.Address);
|
||||||
|
Assert.Equal("hello palladium", opReturn.OpReturnText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_output_OP_RETURN_con_dati_binari_ricade_su_hex()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
byte[] data = [0x00, 0x01, 0xFF, 0xFE, 0x02];
|
||||||
|
var tx = Net.CreateTransaction();
|
||||||
|
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey(data));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(tx);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: 1_000_000, verified: true);
|
||||||
|
|
||||||
|
var opReturn = Assert.Single(details.Outputs, o => o.Index == 1);
|
||||||
|
Assert.Equal("0x" + Convert.ToHexString(data), opReturn.OpReturnText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Piu_output_OP_RETURN_nella_stessa_tx_sono_decodificati_singolarmente()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var tx = Net.CreateTransaction();
|
||||||
|
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0));
|
||||||
|
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("first"u8.ToArray()));
|
||||||
|
tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("second"u8.ToArray()));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(tx);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: 1_000_000, verified: true);
|
||||||
|
|
||||||
|
Assert.Equal("first", details.Outputs.Single(o => o.Index == 1).OpReturnText);
|
||||||
|
Assert.Equal("second", details.Outputs.Single(o => o.Index == 2).OpReturnText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_output_normale_non_ha_testo_OP_RETURN()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
var (server, client) = await StartAsync(funding, spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: -610_000, verified: true);
|
||||||
|
|
||||||
|
Assert.All(details.Outputs, o => Assert.Null(o.OpReturnText));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Il_tag_pool_nello_scriptSig_della_coinbase_viene_estratto()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var coinbase = Net.CreateTransaction();
|
||||||
|
// BIP34 height push (binary) followed by a pool tag (printable ASCII).
|
||||||
|
var scriptSig = new Script(Op.GetPushOp(190), Op.GetPushOp("/slush/"u8.ToArray()));
|
||||||
|
coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig));
|
||||||
|
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(coinbase);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
|
||||||
|
Owned(account), netSats: 5_000_000_000, verified: true);
|
||||||
|
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.True(input.IsCoinbase);
|
||||||
|
Assert.Contains("/slush/", input.CoinbaseTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Uno_scriptSig_coinbase_senza_testo_stampabile_non_produce_tag()
|
||||||
|
{
|
||||||
|
var account = Account();
|
||||||
|
var coinbase = Net.CreateTransaction();
|
||||||
|
var scriptSig = new Script(Op.GetPushOp([0x00, 0x01, 0x02]));
|
||||||
|
coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig));
|
||||||
|
coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
var (server, client) = await StartAsync(coinbase);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190,
|
||||||
|
Owned(account), netSats: 5_000_000_000, verified: true);
|
||||||
|
|
||||||
|
var input = Assert.Single(details.Inputs);
|
||||||
|
Assert.Null(input.CoinbaseTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Un_input_normale_non_ha_tag_coinbase()
|
||||||
|
{
|
||||||
|
var (funding, spend, _, account) = SpendPair();
|
||||||
|
var (server, client) = await StartAsync(funding, spend);
|
||||||
|
await using var _ = server; await using var __ = client;
|
||||||
|
|
||||||
|
var details = await TransactionInspector.FetchAsync(
|
||||||
|
client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101,
|
||||||
|
Owned(account), netSats: -610_000, verified: true);
|
||||||
|
|
||||||
|
Assert.All(details.Inputs, i => Assert.Null(i.CoinbaseTag));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user