12 Commits

Author SHA1 Message Date
davide 1a4fefadc3 fix(wallet): keep transactions under the standard 100 KvB relay limit
Sending a large amount from a wallet with many small UTXOs could
produce a transaction over the standard relay size limit, surfaced
only as a cryptic "Invalid transaction: Transaction's size is too
high" from builder.Verify() after everything else had already
succeeded — with no way for the user to recover other than manually
picking fewer coins.

Build() now orders spendable UTXOs largest-first and binary-searches
the smallest prefix of them that produces a valid transaction, so it
naturally prefers big coins over dust and avoids the limit whenever a
smaller input set can cover the amount. NBitcoin's own coin selector
already refuses to assemble a combination over the size cap and
reports it via NotEnoughFundsException with a distinctive message
rather than ever handing back an oversized transaction, so that case
is now recognized and translated into a clear, actionable error
instead of being read as "insufficient funds".
2026-07-17 19:44:41 +02:00
davide 3460e53b4f feat(spv): verify Merkle proofs progressively, gate spendability on it
Balance/history now render as soon as tx downloads finish instead of
blocking on every historical Merkle proof, critical for mobile where
proof-checking can take much longer than the download itself. Proofs
continue to be checked in the background and each tx's Verified flag
catches up progressively; header ranges are now fetched in batches
(blockchain.block.headers) instead of one call per header to keep this
fast over high-latency links.

Coin selection (UtxoSpendability.IsSpendable) refuses to spend a UTXO
until its Merkle proof is actually checked, regardless of confirmation
count, so a server that fabricates a confirmed balance can get it
displayed early but never spent before the forgery is caught. The disk
cache only ever persists the fully-verified end state of a sync.

UI surfaces the new PendingVerificationSats/SpendableSats split with a
"verifying..." badge, and the sync save now runs off the UI thread to
avoid freezing on slower hardware.
2026-07-17 19:43:18 +02:00
davide feb765663c chore(chain): add mainnet checkpoint at height 475124 2026-07-17 15:14:24 +02:00
davide 322ce8f305 chore(release): bump version to 1.0.0
Fills in the CHANGELOG.md entry for the checkpoint-anchoring security fix,
fuzzing infrastructure and its findings, OP_RETURN/coinbase-tag decoding,
and localization fixes since 0.9.1 (see previous commits), and bumps
<Version>/versionCode across the App and Android head csproj files ahead
of the tag.
2026-07-17 14:02:52 +02:00
davide 077835a30b feat(wallet): decode OP_RETURN payloads and coinbase pool tags in tx details
Transaction detail view silently dropped OP_RETURN messages and coinbase
scriptSig text (e.g. pool tags), since TransactionInspector discarded
scriptPubKey/scriptSig bytes once no destination address could be derived.
Each output/input is decoded independently, so multiple OP_RETURN outputs
in one tx are all shown.
2026-07-09 08:35:39 +02:00
davide 663460d62e feat(ui): add user guide button to help overlay
Adds a "User guide" button next to "Report a bug" in the Help > Info
tab, linking to USERGUIDE.md on GitHub via the same Launcher pattern
used for bug reports and release pages.
2026-07-07 22:35:19 +02:00
davide be818a50a8 docs: tighten CLAUDE.md/AGENTS.md formatting for scannability
Convert dense prose paragraphs to bullet lists (Commands, Architecture,
GUI conventions, Working rules) and trim redundant wording; no content
removed. Both files kept in sync per the header sync rule.
2026-07-07 22:29:46 +02:00
davide 6f4ae679e5 docs: document fuzzing infrastructure and its security guarantees
Sync AGENTS.md/CLAUDE.md with the fuzzing workflow, add a README section
pointing to tests/PalladiumWallet.Fuzz, and record in SECURITY.md the two
guarantees the fuzz suite enforces: a bounded PBKDF2 iteration count on read
and a typed InvalidDataException for any malformed encrypted container.
2026-07-07 22:06:45 +02:00
davide cdede17683 feat(fuzz): add SharpFuzz-based fuzzing for untrusted-input parsers
New tests/PalladiumWallet.Fuzz project: one target per parser that
consumes untrusted input (block headers, Merkle proofs, peer lists,
wallet files, user-pasted mnemonics/keys/addresses/amounts), each
encoding the parser's documented error contract - any exception beyond
that contract is a finding, reported as a crash.

Three ways to run: the seed corpus (with regression inputs for every
crash found so far, including the two fixed in prior commits) replays
automatically inside dotnet test via FuzzCorpusTests, so a fixed
contract violation can't silently return; a built-in random-mutation
mode needs no external tooling (dotnet run -- <target> --random N);
and fuzz.sh drives real coverage-guided campaigns via afl++ +
SharpFuzz instrumentation for pre-release or post-parser-change runs.
2026-07-07 22:05:38 +02:00
davide a264669151 fix(storage): EncryptedFile.Decrypt must reject malformed containers cleanly
A tampered or corrupted wallet file could reach Decrypt with broken
JSON, missing fields, invalid base64, or a wrong nonce/tag size, all of
which previously escaped as raw JsonException/FormatException/
ArgumentNullException instead of the documented WrongPasswordException/
InvalidDataException contract. Worse, the iteration count is read
straight from the (attacker-controlled) container with no upper bound:
a tampered file demanding e.g. 2^31 PBKDF2 iterations would hang the
wallet at open - a DoS via a file the user merely tries to open. Now
every malformed shape maps to InvalidDataException and the iteration
count is clamped to a sane maximum (10,000,000, well above the current
600,000 default).
2026-07-07 22:05:08 +02:00
davide d9c75eaec2 fix(net): ParsePeers must tolerate any JSON shape from the server
server.peers.subscribe is attacker-controlled data: ParsePeers assumed
the standard [ip, hostname, [features...]] shape and threw on anything
else (non-array response, wrong element types), and GetString() on a
JSON string containing invalid UTF-8 bytes threw InvalidOperationException
instead of failing to parse — both found by fuzzing. Any unexpected
shape or unparseable string now degrades to "no usable data" instead of
propagating an exception into the sync/discovery path.
2026-07-07 21:52:00 +02:00
davide c868c17505 fix(crypto): Bip39.TryParse must not throw on unrecognisable text
Wordlist.AutoDetect lands on NBitcoin's internal "Unknown" language for
text resembling no wordlist and throws NotSupportedException instead
of failing gracefully — found by fuzzing the restore-mnemonic input.
TryParse now treats that (and the same failure mode from the Mnemonic
constructor) as "not a valid mnemonic", consistent with every other
rejection path.
2026-07-07 21:51:31 +02:00
68 changed files with 1925 additions and 261 deletions
+4
View File
@@ -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
+47 -32
View File
@@ -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 codesay 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
View File
@@ -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
+47 -32
View File
@@ -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 codesay 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
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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
--- ---
+1
View File
@@ -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
+3
View File
@@ -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.",
+1 -1
View File
@@ -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)
+59 -19
View File
@@ -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
+1 -1
View File
@@ -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)
+18 -4
View File
@@ -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"/>
<Button Content="{Binding Loc[help.bug.report]}" <StackPanel Orientation="Horizontal" Spacing="8">
Click="OnOpenBugReportClick" <Button Content="{Binding Loc[help.bug.report]}"
HorizontalAlignment="Left"/> Click="OnOpenBugReportClick"
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">
+8
View File
@@ -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
View File
@@ -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)
+4 -2
View File
@@ -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),
], ],
}; };
+31 -14
View File
@@ -58,28 +58,45 @@ 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)]; {
wordlist = ToWordlist(language.Value);
foreach (var wordlist in candidates) }
else
{ {
try try
{ {
var parsed = new Mnemonic(text, wordlist); wordlist = Wordlist.AutoDetect(text);
// The constructor does NOT verify the checksum — explicit check is mandatory.
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
{
mnemonic = parsed;
return true;
}
} }
catch (FormatException) catch (NotSupportedException)
{ {
// Words outside the wordlist or invalid count — try the next candidate. // AutoDetect lands on NBitcoin's internal "Unknown" language for
// text resembling no wordlist and throws instead of returning a
// default (found by fuzzing) — not a valid mnemonic, plain and simple.
return false;
} }
} }
try
{
var parsed = new Mnemonic(text, wordlist);
// The constructor does NOT verify the checksum — explicit check is mandatory.
if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24)
{
mnemonic = parsed;
return true;
}
}
catch (FormatException)
{
// Words outside the wordlist or invalid count.
}
catch (NotSupportedException)
{
// Defensive: same NBitcoin failure mode surfacing from the constructor.
}
return false; return false;
} }
+46 -4
View File
@@ -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;
}
}
} }
+186 -46
View File
@@ -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); {
_txCache[txid] = Transaction.Parse(raw, network); var raw = await client.GetTransactionAsync(txid, ct);
var n = Interlocked.Increment(ref dlDone); _txCache[txid] = Transaction.Parse(raw, network);
if (n % 50 == 0 || n == missing.Count) var n = Interlocked.Increment(ref dlDone);
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…"); if (n % 50 == 0 || n == missing.Count)
}, ct)); Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
}, ct)));
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () => SyncResult BuildSnapshot() =>
{ BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
var (txid, height) = kv;
var proofTask = client.GetMerkleAsync(txid, height, ct);
var headerTask = _headerFetches.GetOrAdd(height,
h => client.GetBlockHeaderAsync(h, ct));
var proof = await proofTask;
var header = BlockHeaderInfo.Parse(await headerTask);
await AnchorToCheckpointAsync(height, ct);
if (!MerkleProof.Verify(
uint256.Parse(txid), proof.Pos,
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
throw new SpvVerificationException(
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
var n = Interlocked.Increment(ref merkDone);
if (n % 50 == 0 || n == toVerify.Count)
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
}, ct));
await Task.WhenAll(dlTasks.Concat(merkTasks)); PartialResult?.Invoke(BuildSnapshot());
foreach (var (txid, height) in toVerify)
_verifiedAtHeight[txid] = height;
}
var merkDone = 0;
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
{
var (txid, height) = kv;
var proofTask = client.GetMerkleAsync(txid, height, ct);
// Anchor first: on a checkpointed height this fills _headerFetches[height]
// via the batched range fetch (§7.3), so the header lookup below is a cache
// hit instead of a second individual blockchain.block.header RPC per tx —
// halves round-trips for this stage on mainnet, where it matters most on
// high-latency mobile links. Falls back to an individual fetch when no
// checkpoint covers this height (testnet/regtest today).
await AnchorToCheckpointAsync(height, ct);
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
var header = BlockHeaderInfo.Parse(headerHex);
var proof = await proofTask;
if (!MerkleProof.Verify(
uint256.Parse(txid), proof.Pos,
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
throw new SpvVerificationException(
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
_verifiedAtHeight[txid] = height;
var n = Interlocked.Increment(ref merkDone);
if (n % 50 == 0 || n == toVerify.Count)
Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
if (n % PartialResultBatchSize == 0)
PartialResult?.Invoke(BuildSnapshot());
}, ct)).ToList();
if (merkTasks.Count > 0)
await Task.WhenAll(merkTasks);
return BuildSnapshot();
}
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
// every single verified proof would make the background verification phase itself O(n²) for
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
// visibly without that cost.
private const int PartialResultBatchSize = 200;
private bool IsTxVerified(string txid, int height) =>
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
/// <summary>
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
/// transaction downloads finish (proofs still pending), and again as verification progresses,
/// each time reflecting whichever transactions have been proof-checked so far.
/// </summary>
private SyncResult BuildResult(
int tipHeight,
List<TrackedAddress> tracked,
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
Dictionary<string, int> txHeights,
int nextReceive,
int nextChange)
{
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]); var 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") ||
+41 -9
View File
@@ -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)
{ {
+24
View File
@@ -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; }
} }
+8
View File
@@ -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));
} }
+118 -7
View File
@@ -29,6 +29,8 @@ public sealed class BuiltTransaction
/// </summary> /// </summary>
public sealed class TransactionFactory(IWalletAccount account) public sealed class TransactionFactory(IWalletAccount account)
{ {
private const int MaxStandardTransactionVirtualSize = 100_000;
private Network Network => PalladiumNetworks.For(account.Profile.Kind); private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary> /// <summary>
@@ -68,9 +70,9 @@ public sealed class TransactionFactory(IWalletAccount account)
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList(); u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (immature.Count > 0) if (immature.Count > 0)
{ {
var best = immature.Max(u => u.Confirmations(tipHeight)); var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
var threshold = profile.CoinbaseMaturity + 1; var threshold = profile.CoinbaseMaturity + 1;
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). "); reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). ");
} }
var underConf = utxos.Where(u => var underConf = utxos.Where(u =>
@@ -78,20 +80,96 @@ public sealed class TransactionFactory(IWalletAccount account)
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList(); u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (underConf.Count > 0) if (underConf.Count > 0)
{ {
var best = underConf.Max(u => u.Confirmations(tipHeight)); var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). "); reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). ");
} }
var unverified = utxos.Where(u =>
!u.Frozen && u.Height > 0 && !u.Verified &&
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
if (unverified.Count > 0)
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
throw new WalletSpendException(reasons.Length > 0 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.");
} }
var coins = spendable.Select(u => new Coin( var ordered = spendable
.OrderByDescending(u => u.ValueSats)
.ThenBy(u => u.Height)
.ThenBy(u => u.Txid, StringComparer.Ordinal)
.ThenBy(u => u.Vout)
.ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
if (sendAll)
{
try
{
return BuildWithSelectedUtxos(
ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count);
}
catch (TransactionTooLargeException ex)
{
throw new WalletSpendException(ex.Message);
}
}
NotEnoughFundsException? lastInsufficientFunds = null;
TransactionTooLargeException? tooLarge = null;
BuiltTransaction? best = null;
var low = 1;
var high = ordered.Count;
while (low <= high)
{
var count = low + ((high - low) / 2);
try
{
best = BuildWithSelectedUtxos(
ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex,
sendAll: false, totalSpendableCount: ordered.Count);
high = count - 1;
}
catch (NotEnoughFundsException ex)
{
lastInsufficientFunds = ex;
low = count + 1;
}
catch (TransactionTooLargeException ex)
{
tooLarge = ex;
high = count - 1;
}
}
if (best is not null)
return best;
if (tooLarge is not null)
throw new WalletSpendException(tooLarge.Message);
throw new WalletSpendException(lastInsufficientFunds is null
? "Insufficient funds."
: $"Insufficient funds: {lastInsufficientFunds.Message}");
}
private BuiltTransaction BuildWithSelectedUtxos(
IReadOnlyList<CachedUtxo> selectedUtxos,
IReadOnlyDictionary<string, Transaction> transactions,
BitcoinAddress destination,
long amountSats,
FeeRate feeRate,
int changeIndex,
bool sendAll,
int totalSpendableCount)
{
var coins = selectedUtxos.Select(u => new Coin(
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout), new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
transactions[u.Txid].Outputs[u.Vout])).ToList(); transactions[u.Txid].Outputs[u.Vout])).ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
var builder = Network.CreateTransactionBuilder(); var builder = Network.CreateTransactionBuilder();
builder.SetVersion(2); builder.SetVersion(2);
// RBF sequence to allow fee bumping (§6.6). // RBF sequence to allow fee bumping (§6.6).
@@ -107,7 +185,7 @@ public sealed class TransactionFactory(IWalletAccount account)
if (!account.IsWatchOnly) if (!account.IsWatchOnly)
{ {
builder.AddKeys(spendable builder.AddKeys(selectedUtxos
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex)) .Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
.OfType<Key>() .OfType<Key>()
.ToArray()); .ToArray());
@@ -118,11 +196,27 @@ public sealed class TransactionFactory(IWalletAccount account)
{ {
tx = builder.BuildTransaction(sign: !account.IsWatchOnly); tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
} }
catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase))
{
// NBitcoin's coin selector refuses to assemble a combination over the standard size
// cap itself and reports it through NotEnoughFundsException rather than ever handing
// back an oversized transaction — the GetVirtualSize() check below is unreachable for
// this case and exists only as a defense-in-depth net for other NBitcoin versions.
throw new TransactionTooLargeException(
BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll));
}
catch (NotEnoughFundsException) when (!sendAll)
{
throw;
}
catch (NotEnoughFundsException ex) catch (NotEnoughFundsException ex)
{ {
throw new WalletSpendException($"Insufficient funds: {ex.Message}"); throw new WalletSpendException($"Insufficient funds: {ex.Message}");
} }
if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize)
throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize()));
if (!account.IsWatchOnly) if (!account.IsWatchOnly)
{ {
if (!builder.Verify(tx, out TransactionPolicyError[] errors)) if (!builder.Verify(tx, out TransactionPolicyError[] errors))
@@ -140,6 +234,21 @@ public sealed class TransactionFactory(IWalletAccount account)
}; };
} }
private static string BuildTooLargeMessage(
int selectedInputCount,
int totalSpendableCount,
bool sendAll,
int? actualVirtualSize = null)
{
var prefix = sendAll
? "Send-all cannot fit in one standard transaction"
: "Transaction cannot fit in one standard transaction";
var size = actualVirtualSize is { } vsize ? $"{vsize} vB exceeds" : "Estimated size exceeds";
return $"{prefix}: {size} the {MaxStandardTransactionVirtualSize} vB standard relay limit " +
$"with {selectedInputCount}/{totalSpendableCount} spendable input(s). Send a smaller amount or consolidate in multiple smaller transactions.";
}
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins) private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
{ {
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet(); var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
@@ -147,6 +256,8 @@ public sealed class TransactionFactory(IWalletAccount account)
.Sum(c => (Money)c.Amount); .Sum(c => (Money)c.Amount);
return inputSum - tx.Outputs.Sum(o => o.Value); return inputSum - tx.Outputs.Sum(o => o.Value);
} }
private sealed class TransactionTooLargeException(string message) : Exception(message);
} }
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary> /// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
+55 -4
View File
@@ -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);
}
} }
+8 -2
View File
@@ -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.
@@ -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
+141
View File
@@ -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>
+215
View File
@@ -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);
}
}
}
+66
View File
@@ -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.
+38
View File
@@ -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,53 @@ 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 });
} }
private static void AddFund(
HdAccount account,
List<CachedUtxo> utxos,
Dictionary<string, Transaction> transactions,
int index,
long sats)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)index)));
funding.Outputs.Add(Money.Satoshis(sats), account.GetReceiveAddress(index));
var txid = funding.GetHash().ToString();
transactions[txid] = funding;
utxos.Add(new CachedUtxo
{
Txid = txid,
Vout = 0,
ValueSats = sats,
Address = account.GetReceiveAddress(index).ToString(),
IsChange = false,
AddressIndex = index,
Height = 100,
Verified = true,
});
}
[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 +210,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 +336,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,
}); });
} }
@@ -309,6 +350,42 @@ public class TransactionFactoryTests
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000); Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
} }
[Fact]
public void Automatic_coin_selection_uses_large_utxos_before_dust()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
AddFund(account, allUtxos, allTxs, index: 0, sats: 2_000_000);
for (var i = 1; i <= 1_200; i++)
AddFund(account, allUtxos, allTxs, i, sats: 10_000);
var built = new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(1_250), amountSats: 500_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
Assert.Single(built.Transaction.Inputs);
Assert.True(built.Transaction.GetVirtualSize() < 100_000);
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 500_000);
}
[Fact]
public void Spending_more_than_the_standard_input_limit_reports_a_clear_error()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
for (var i = 0; i < 1_600; i++)
AddFund(account, allUtxos, allTxs, i, sats: 10_000);
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(1_650), amountSats: 15_300_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100));
Assert.Contains("standard relay limit", ex.Message);
Assert.Contains("multiple smaller transactions", ex.Message);
}
[Fact] [Fact]
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee() public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
{ {
@@ -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()
{ {