Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d5ed094a0 | ||
|
|
ed8de06609 | ||
|
|
9010ebce59 | ||
|
|
305f22ed0e | ||
|
|
24368a07b5 | ||
|
|
a08e972c4c | ||
|
|
4e3d93e167 | ||
|
|
c8ba2294f2 | ||
|
|
92fd783658 | ||
|
|
eca4d4ac0c | ||
|
|
06d1f59c2b | ||
|
|
8bcee27c23 | ||
|
|
77f00d1b4e | ||
|
|
65c515bf7a | ||
|
|
3be761d97e | ||
|
|
cb551efce4 | ||
|
|
d431996ddc | ||
|
|
d85c1e39ce | ||
|
|
530b7feaa5 | ||
|
|
54d07f8e92 | ||
|
|
cb93927d15 | ||
|
|
9c0a408c49 | ||
|
|
928cb31cdf | ||
|
|
aec68ea047 | ||
|
|
b292590cdc | ||
|
|
ce8d777c63 | ||
|
|
42e8dfdb63 | ||
|
|
8ac4a05c44 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/davide3011/PalladiumWallet/security/policy
|
||||
url: https://github.com/palladium-coin/palladium-wallet/security/policy
|
||||
about: Please report security vulnerabilities privately, not as a public issue. See SECURITY.md.
|
||||
@@ -0,0 +1,98 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# Cancel superseded runs of the same branch/PR.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: true
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
# Build/test only the test project and what it references (Core, Fuzz, ServerProbe):
|
||||
# the Android head needs a workload and SDK that CI does not provision.
|
||||
- name: Restore
|
||||
run: dotnet restore tests/PalladiumWallet.Tests
|
||||
|
||||
- name: Build
|
||||
run: dotnet build tests/PalladiumWallet.Tests --no-restore -c Release
|
||||
|
||||
# Offline and deterministic: unit, property-based (CsCheck), fuzz-corpus replay and the
|
||||
# in-process FakeElectrumServer tests. LiveServerTests is a no-op without
|
||||
# PALLADIUM_TEST_SERVERS, so no third-party server is contacted.
|
||||
- name: Test
|
||||
run: >
|
||||
dotnet test tests/PalladiumWallet.Tests --no-build -c Release
|
||||
--settings tests/coverage.runsettings
|
||||
--logger "trx;LogFileName=test-results.trx"
|
||||
--collect:"XPlat Code Coverage"
|
||||
--results-directory TestResults
|
||||
--blame-hang-timeout 5m --blame-hang-dump-type none
|
||||
|
||||
# Coverage gate (Ubuntu only, one report is enough): fails the job if Core drops below the
|
||||
# thresholds. App and Cli are reported but not gated yet.
|
||||
- name: Coverage gate
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
report=$(ls TestResults/*/coverage.cobertura.xml | head -1)
|
||||
python3 tests/check-coverage.py "$report" PalladiumWallet.Core:95:88 PalladiumWallet.App PalladiumWallet.Cli
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-${{ matrix.os }}
|
||||
path: TestResults
|
||||
retention-days: 14
|
||||
|
||||
fuzz-smoke:
|
||||
name: Fuzz smoke (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [header, merkle, slip132, bip39, address, coinamount, walletdoc, encfile, peers]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
# Random-input smoke run of each untrusted-input parser; any escaping exception fails the job.
|
||||
# The coverage-guided campaign (afl++) stays a manual step, see tests/PalladiumWallet.Fuzz/fuzz.sh.
|
||||
- name: Fuzz ${{ matrix.target }}
|
||||
run: dotnet run -c Release --project tests/PalladiumWallet.Fuzz -- ${{ matrix.target }} --random 20000
|
||||
|
||||
docs:
|
||||
name: Docs checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check docs
|
||||
run: ./docs/check-docs.sh
|
||||
@@ -24,6 +24,7 @@ packages/
|
||||
TestResults/
|
||||
coverage/
|
||||
coverage.*
|
||||
!tests/coverage.runsettings
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
*.trx
|
||||
@@ -93,6 +94,12 @@ settings.local.json
|
||||
# Local agent/tooling metadata
|
||||
.agents/
|
||||
.codex/
|
||||
.claude/
|
||||
|
||||
# Android packages and local SDK config
|
||||
*.apk
|
||||
*.aab
|
||||
local.properties
|
||||
|
||||
# Fuzzing artifacts (tests/PalladiumWallet.Fuzz)
|
||||
findings/
|
||||
|
||||
@@ -33,6 +33,7 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
docs/ docs-as-code process + ADRs (one per feature), checked by docs/check-docs.sh
|
||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||
```
|
||||
|
||||
@@ -49,10 +50,12 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --settings tests/coverage.runsettings --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML); CI gates `Core` with `tests/check-coverage.py` (95% lines / 88% branches)
|
||||
- ViewModel tests are headless (`Avalonia.Headless` via `UiSession`, xunit 2) and CLI tests invoke the real entry point in-process: tests under `tests/PalladiumWallet.Tests/App/` that touch process-wide state (`AppPaths`, `Loc`, `Console`, `MainWindowViewModel.UpdateCheck`/`ProfileOverride`) go in xUnit collection `"AppPaths"`; never let a test reach GitHub or a real indexing server
|
||||
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Live server probe** (`tests/PalladiumWallet.ServerProbe`): read-only conformance/health check for real Electrum/Fulcrum/ElectrumX servers (`dotnet run --project tests/PalladiumWallet.ServerProbe -- --server host:port --ssl`, or `--registry`). Also runnable from `dotnet test` via env vars: `PALLADIUM_TEST_SERVERS=host:port,… [PALLADIUM_TEST_SSL=1] [PALLADIUM_TEST_NET=…] dotnet test --filter LiveServer` (no-op when unset, so the default run stays offline)
|
||||
- **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`
|
||||
@@ -60,6 +63,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
- 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)
|
||||
- **CI** (`.github/workflows/ci.yml`, ADR 0032): on every push to `main` and every PR it runs the offline test suite on Ubuntu and Windows, a fuzz smoke per parser target, and `docs/check-docs.sh`. Keep `dotnet test tests/PalladiumWallet.Tests` green locally first; the Android head is deliberately not built in CI
|
||||
- **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`
|
||||
@@ -100,5 +104,8 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
|
||||
- **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.
|
||||
- **Docs stay in sync (docs-as-code):** whenever a feature is added or an existing one changes, update the documentation in the same commit — `docs/` (see `docs/docs-as-code.md`), plus `README.md`, `USERGUIDE.md`, `SECURITY.md` and `CHANGELOG.md` where affected.
|
||||
- Every new feature gets an ADR in `docs/adr/` (from `docs/adr/template.md`, listed in `docs/adr/README.md`); a changed decision gets a new ADR that supersedes the old one, never a rewrite of an Accepted ADR
|
||||
- Run `./docs/check-docs.sh` before committing; it must pass (ADR numbering/structure, index, cited code paths, links, CLAUDE.md/AGENTS.md sync)
|
||||
- **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
|
||||
+122
@@ -5,6 +5,128 @@ Technical changelog for PalladiumWallet. Format loosely follows
|
||||
by subsystem rather than strictly by date, since `0.9.0` is the first
|
||||
release and covers the full history from the initial commit.
|
||||
|
||||
## [1.2.0] — 2026-11-03
|
||||
|
||||
Hardens SPV verification (header anchoring fix, recent-block witness cross-check, chain identity
|
||||
check on connect), makes the network layer fail fast on dead or hostile servers, adds server fee
|
||||
presets and safer CLI password handling, and fixes a Windows-visible sync race. Also adds a
|
||||
linux-arm64 build target, CI on GitHub Actions and a much larger automated test suite.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Docs-as-code process: `docs/` with one ADR per feature, checked by `docs/check-docs.sh`;
|
||||
the extended test strategy is documented.
|
||||
|
||||
- README: the multi-network claim now states that the GUI is mainnet-only and that testnet/regtest
|
||||
(CLI only) need an explicit `--server`.
|
||||
|
||||
### Added
|
||||
|
||||
- `linux-arm64` reproducible desktop build target (`./docker/build.sh linux-arm64`), cross-published
|
||||
from the x64 image, for Raspberry Pi and other 64-bit ARM boards.
|
||||
- CI on GitHub Actions (ADR 0032): offline test suite on Ubuntu and Windows, fuzz smoke per parser
|
||||
target and docs checks, on every push to `main` and every PR.
|
||||
- `tests/PalladiumWallet.ServerProbe`: read-only conformance/health check for real
|
||||
Electrum/Fulcrum/ElectrumX servers, also runnable from `dotnet test` via environment variables.
|
||||
- Recent blocks (above the newest checkpoint, which cannot be anchored) are now cross-checked with
|
||||
a second server: the header at the same height must be identical. The witness is taken only from
|
||||
the hardcoded bootstrap servers, one extra pinned connection opened on demand. A disagreement at
|
||||
3+ blocks of depth aborts the sync; a shallower one leaves the proof unverified until the next
|
||||
sync (possible reorganisation); with no reachable witness behaviour is unchanged. A bounded
|
||||
proof-of-work check was evaluated and rejected (see ADR 0042).
|
||||
- History rows and the transaction detail now flag a confirmed transaction in a block above the
|
||||
newest hardcoded checkpoint as **not anchored** (orange label with an explanatory tooltip).
|
||||
Such a block cannot be chained to a checkpoint (ADR 0040), so only the Merkle proof against the
|
||||
server's header backs it; the flag makes the reduced guarantee visible. Not shown on
|
||||
testnet/regtest, which have no checkpoints. See ADR 0041.
|
||||
- Fee presets from the server: after connecting, the Send tab offers Fast, Normal and Economy
|
||||
rates from `blockchain.estimatefee` (2, 6 and 24 blocks) and shows them with the relay fee;
|
||||
Normal is preselected unless the user already typed a rate. Estimates are converted from
|
||||
PLM/kB to sat/vB, never go below the relay fee, and implausible values are ignored; without
|
||||
an estimate the manual field and the 1 sat/vB default remain. A manual rate below the relay
|
||||
fee is refused before building, and a fee above 1% of the amount sent is flagged in the
|
||||
preview. Applies to Send and Donate. See ADR 0039.
|
||||
|
||||
### Changed
|
||||
|
||||
- **CLI (behaviour change):** the wallet password no longer has to be a command-line argument.
|
||||
It can come from `--password-env NAME`, `--password-stdin`, or a no-echo terminal prompt
|
||||
(with a confirmation when creating); with no terminal and no password the command fails
|
||||
instead of hanging. `--password P` still works but prints a warning on stderr (shell history,
|
||||
process list). Plaintext storage is now an explicit opt-in: `create`/`restore*` without a
|
||||
password and without `--no-encryption` fail, where before they silently wrote the seed
|
||||
unencrypted. Scripts that relied on omitting `--password` must add `--no-encryption`.
|
||||
|
||||
### Security
|
||||
|
||||
- **SPV header anchoring fixed (important).** The chain of headers used to be walked *from* the
|
||||
nearest checkpoint below a transaction's height *up to* it. With proof of work skipped
|
||||
(LWMA), that proves nothing: a malicious server could fabricate a header for the block of a
|
||||
fake transaction and chain fake headers after a real checkpoint, and every link would pass.
|
||||
The anchor now goes the other way: the header must hash-chain *up to* the nearest checkpoint
|
||||
at or above its height, whose hash is hardcoded, which cannot be forged. This also covers
|
||||
blocks below the first checkpoint (heights < 20,000), which were previously unanchored. Blocks
|
||||
above the newest checkpoint still have nothing to anchor to (only the Merkle proof is
|
||||
checked): add a recent checkpoint at each release. A wallet cache from an older version is
|
||||
re-verified once (its "verified" claims are not trusted); headers and transactions are
|
||||
reused. See ADR 0040.
|
||||
- Private keys are no longer precomputed for every address in the UI. Address rows and the
|
||||
address detail sheet carry only public data and derivation coordinates; the WIF is derived
|
||||
when the user reveals it (after the password) and dropped when hidden, when the sheet closes
|
||||
or when the wallet closes. Before, the WIF of every listed address sat in UI-bound strings for
|
||||
the whole session.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sync result overwritten by a stale partial snapshot.** At the end of a sync the final verified
|
||||
snapshot was applied only after `await`ing the wallet save; a partial (not yet verified)
|
||||
snapshot still queued on the UI thread could run during that await, replace the cache and be
|
||||
displayed instead, leaving transactions shown as unverified after a completed sync. The run is
|
||||
now finalised and applied before the save. Deterministically visible on Windows, where the save
|
||||
really yields.
|
||||
- Adding a contact now validates the address for the wallet's network and rejects duplicates
|
||||
(case-insensitive); whitespace and line breaks in a pasted address are removed. Before, any
|
||||
string was saved and the error only appeared at send time. Contacts already stored with an
|
||||
invalid address still load and display.
|
||||
- The wallet now verifies the chain identity of a server right after connecting: the
|
||||
`genesis_hash` from `server.features` must match the profile and, on mainnet (which shares
|
||||
Bitcoin's genesis), the header at the highest checkpoint not above the server's tip must match
|
||||
the hardcoded one. A server on another chain is refused with an explicit message and the next
|
||||
known server is tried. Applies to the GUI, peer discovery, the CLI and the server probe.
|
||||
- `ElectrumClient`: a single server reply is now capped at 16 MiB (`maxLineBytes` in
|
||||
`ConnectAsync`). A hostile server could stream an endless line with no newline and exhaust the
|
||||
client's memory; the connection now fails with `InvalidDataException` as soon as the cap is
|
||||
exceeded.
|
||||
- `ElectrumClient`: every request is now bounded (30 s, 10 s for the handshake; configurable in
|
||||
`ConnectAsync`). A server that stops answering without closing the socket made a running sync
|
||||
wait forever; now the request fails with `TimeoutException`, the connection is marked dead
|
||||
(`Disconnected` is raised, pending and later requests fail fast) and the app reconnects.
|
||||
- Fee rate parsing no longer depends on the system culture: on Italian/German systems `1.5`
|
||||
was read as `15` sat/vB (10x the intended fee). The Send form, the Donate form and the CLI
|
||||
`--feerate` now share `CoinAmount.TryParseFeeRate` (invariant culture, `.` or `,` as decimal
|
||||
separator, no thousands separators, range (0, 1000] sat/vB). Donate and the CLI no longer
|
||||
silently fall back to 1 sat/vB on an invalid value: they report an error.
|
||||
- `ElectrumClient`: after a protocol error (e.g. a malformed line from the server) the read
|
||||
loop died but the client kept reporting `IsConnected == true`, and any later request waited
|
||||
forever for a response that could never arrive. The read loop now marks the connection dead
|
||||
before failing the pending requests, and `RequestAsync` fails fast with `IOException` on a
|
||||
dead connection.
|
||||
|
||||
### Testing
|
||||
|
||||
- Headless ViewModel tests (Avalonia.Headless, no display): wizard flows, password/lock handling,
|
||||
seed and private-key reveal gating, contacts, settings, sync and send against the fake server.
|
||||
- In-process end-to-end CLI tests (create/restore/info/sync/send against the fake server).
|
||||
- Localization consistency tests (7 languages, placeholders, keys used in sources exist).
|
||||
- Error-path tests for `Core` (empty header range during anchoring, send-all limits, `SyncCache`
|
||||
roundtrip, derivation edge cases, corrupted config), concurrency tests (in-flight cap, disposal
|
||||
under load, wallet-lock contention).
|
||||
- Coverage settings (`tests/coverage.runsettings`) and a coverage gate in CI (`Core`: 95% lines /
|
||||
88% branches); thread pool minimum raised in tests to avoid starvation; async UI test bodies are
|
||||
now driven to completion (they previously passed vacuously).
|
||||
- Test seams: `MainWindowViewModel.UpdateCheck` and `MainWindowViewModel.ProfileOverride`
|
||||
(internal) so ViewModel tests never reach GitHub or a real indexing server.
|
||||
|
||||
## [1.1.0] — 2026-07-19
|
||||
|
||||
Adds a pure address-only watch-only mode (Core + CLI + App wizard + Send
|
||||
|
||||
@@ -33,6 +33,7 @@ src/App/ shared Avalonia UI library (App, Views, ViewModels, Loc, Asset
|
||||
src/App.Desktop/ desktop head (WinExe): Program.cs, app.manifest, .ico → runnable
|
||||
src/App.Android/ Android head (net10.0-android): MainApplication/MainActivity → apk
|
||||
src/Cli/ CLI on the same Core tests/ xUnit
|
||||
docs/ docs-as-code process + ADRs (one per feature), checked by docs/check-docs.sh
|
||||
docker/ reproducible release builds (build.sh + pinned Dockerfiles) → dist/
|
||||
```
|
||||
|
||||
@@ -49,10 +50,12 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
- **Test** (headless, primary verification layer): `dotnet test`
|
||||
- Single test: `dotnet test --filter "FullyQualifiedName~TestName"`
|
||||
- Property-based tests (CsCheck, `PropertyTests.cs`) run in the same command, ~30s
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML)
|
||||
- Coverage: `dotnet test tests/PalladiumWallet.Tests --settings tests/coverage.runsettings --collect:"XPlat Code Coverage"` (coverlet, Cobertura XML); CI gates `Core` with `tests/check-coverage.py` (95% lines / 88% branches)
|
||||
- ViewModel tests are headless (`Avalonia.Headless` via `UiSession`, xunit 2) and CLI tests invoke the real entry point in-process: tests under `tests/PalladiumWallet.Tests/App/` that touch process-wide state (`AppPaths`, `Loc`, `Console`, `MainWindowViewModel.UpdateCheck`/`ProfileOverride`) go in xUnit collection `"AppPaths"`; never let a test reach GitHub or a real indexing server
|
||||
- Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test
|
||||
- Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design)
|
||||
- Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static)
|
||||
- **Live server probe** (`tests/PalladiumWallet.ServerProbe`): read-only conformance/health check for real Electrum/Fulcrum/ElectrumX servers (`dotnet run --project tests/PalladiumWallet.ServerProbe -- --server host:port --ssl`, or `--registry`). Also runnable from `dotnet test` via env vars: `PALLADIUM_TEST_SERVERS=host:port,… [PALLADIUM_TEST_SSL=1] [PALLADIUM_TEST_NET=…] dotnet test --filter LiveServer` (no-op when unset, so the default run stays offline)
|
||||
- **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`
|
||||
@@ -60,6 +63,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
- 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)
|
||||
- **CI** (`.github/workflows/ci.yml`, ADR 0032): on every push to `main` and every PR it runs the offline test suite on Ubuntu and Windows, a fuzz smoke per parser target, and `docs/check-docs.sh`. Keep `dotnet test tests/PalladiumWallet.Tests` green locally first; the Android head is deliberately not built in CI
|
||||
- **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`
|
||||
@@ -100,5 +104,8 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
|
||||
- **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.
|
||||
- **Docs stay in sync (docs-as-code):** whenever a feature is added or an existing one changes, update the documentation in the same commit — `docs/` (see `docs/docs-as-code.md`), plus `README.md`, `USERGUIDE.md`, `SECURITY.md` and `CHANGELOG.md` where affected.
|
||||
- Every new feature gets an ADR in `docs/adr/` (from `docs/adr/template.md`, listed in `docs/adr/README.md`); a changed decision gets a new ADR that supersedes the old one, never a rewrite of an Accepted ADR
|
||||
- Run `./docs/check-docs.sh` before committing; it must pass (ADR numbering/structure, index, cited code paths, links, CLAUDE.md/AGENTS.md sync)
|
||||
- **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
|
||||
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tes
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.ServerProbe", "tests\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj", "{4CB51620-11B7-47E9-9971-A999E49D1CE1}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -117,6 +119,18 @@ Global
|
||||
{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
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -130,5 +144,6 @@ Global
|
||||
{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}
|
||||
{4CB51620-11B7-47E9-9971-A999E49D1CE1} = {FDF1822C-58D6-4B35-93EA-6A85E1292933}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -10,7 +10,7 @@ Unlike generic wallets adapted to many coins, Palladium Wallet is designed aroun
|
||||
- **Security**: seed and private keys encrypted on disk (AES-GCM, PBKDF2-SHA512), never in plaintext in logs or on the wire; every server response is validated with Merkle proofs + checkpoints.
|
||||
- **HD wallet** (BIP39/BIP32), SegWit/wrapped/legacy addresses, watch-only from xpub or from plain addresses (no key material at all).
|
||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped); watch-only wallets export an unsigned PSBT for offline signing. Multisig script kinds are defined in the network profile but not yet implemented (planned, see `Core/Crypto/DerivationPaths.cs`).
|
||||
- **Multi-network**: mainnet, testnet, regtest.
|
||||
- **Multi-network**: mainnet, testnet, regtest in the core and the CLI (`--net`); the GUI works on mainnet only. Testnet and regtest ship without bootstrap servers or checkpoints, so pass `--server host:port`.
|
||||
- **Cross-platform**: desktop (Windows/Linux) and Android share one Avalonia UI; a **CLI** runs on the same core.
|
||||
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German, Chinese (Simplified).
|
||||
|
||||
@@ -46,7 +46,7 @@ For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto a
|
||||
(alternatively, the installer from <https://dotnet.microsoft.com/download/dotnet/10.0>)
|
||||
2. Clone the repository and restore dependencies:
|
||||
```powershell
|
||||
git clone <repo-URL>
|
||||
git clone https://github.com/palladium-coin/palladium-wallet.git
|
||||
cd PalladiumWallet
|
||||
dotnet restore
|
||||
```
|
||||
@@ -61,7 +61,7 @@ For desktop and the CLI you only need the **.NET 10 SDK**. The core and crypto a
|
||||
(add the two `export` lines to your `~/.bashrc` to make them permanent)
|
||||
2. Clone and restore:
|
||||
```bash
|
||||
git clone <repo-URL>
|
||||
git clone https://github.com/palladium-coin/palladium-wallet.git
|
||||
cd PalladiumWallet
|
||||
dotnet restore
|
||||
```
|
||||
@@ -124,6 +124,8 @@ or run it on an emulator (see *Android emulator (developer setup)*).
|
||||
|
||||
Tests are the **primary verification layer** — the core logic and crypto run headless, without the GUI or a real network.
|
||||
|
||||
Every push to `main` and every pull request runs the offline suite on Ubuntu and Windows, plus a fuzz smoke and the docs checks, via GitHub Actions (`.github/workflows/ci.yml`).
|
||||
|
||||
Run the whole suite:
|
||||
```bash
|
||||
dotnet test
|
||||
@@ -139,9 +141,15 @@ Run only the tests in one project:
|
||||
dotnet test tests/PalladiumWallet.Tests
|
||||
```
|
||||
|
||||
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`):
|
||||
Measure code coverage (the [coverlet](https://github.com/coverlet-coverage/coverlet) collector is already referenced; the report is a Cobertura XML under `TestResults/`). `tests/coverage.runsettings` excludes the test tooling, and `tests/check-coverage.py` applies the same gate as CI:
|
||||
```bash
|
||||
dotnet test tests/PalladiumWallet.Tests --collect:"XPlat Code Coverage"
|
||||
dotnet test tests/PalladiumWallet.Tests --settings tests/coverage.runsettings --collect:"XPlat Code Coverage"
|
||||
python3 tests/check-coverage.py TestResults/*/coverage.cobertura.xml PalladiumWallet.Core:95:88
|
||||
```
|
||||
|
||||
Run the tests against a real indexing server (opt-in, skipped when the variable is unset):
|
||||
```bash
|
||||
PALLADIUM_TEST_SERVERS=host:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer
|
||||
```
|
||||
|
||||
> Cross-implementation tests compare addresses, txids and PSBTs against reference golden vectors: a different address or txid is a blocking bug.
|
||||
@@ -157,6 +165,7 @@ The tests mirror the `Core` layout (`tests/PalladiumWallet.Tests/<area>/`):
|
||||
| `Spv/` | Scripthash (vectors computed independently in Python), Merkle proofs (Bitcoin block 100000 + random trees), header parsing and PoW target validation, and the full **`WalletSynchronizer`**: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse — including the paths where a lying server fails Merkle verification or serves a header chain that does not anchor to a hardcoded checkpoint, and the sync must abort |
|
||||
| `Net/` | JSON-RPC transport (pipelining, error mapping, notifications, disconnection, cancellation, oversized responses), typed protocol wrappers, peer discovery/persistence, TLS trust-on-first-use pinning end-to-end (pin, match, mismatch, reset), the update check end-to-end via a stubbed HTTP transport (newer/equal/older tags, HTTP errors, malformed JSON, offline — all best-effort to null) |
|
||||
| `Storage/` | AES-GCM encryption (roundtrip, tampering, fresh salt/nonce), wallet document schema/versioning, atomic saves, single-instance lock, data-path resolution with its full precedence chain (override → portable → pointer file → default) |
|
||||
| `App/` | Headless ViewModel tests (Avalonia.Headless): wizard flows, password and file-lock handling, seed and private-key reveal gating, contacts, settings, sync and send against the fake server; in-process end-to-end CLI tests; localization consistency (7 languages, placeholders, keys used in sources exist) |
|
||||
| `Wallet/` | Transaction building and signing (spendability rules, coinbase maturity, dust change, multi-UTXO selection, all standard destination types, watch-only PSBT flow, a golden txid for the PSBT signing path, standardness-policy rejection of absurd fees), transaction detail assembly (fees, mine/theirs attribution, RBF, coinbase), amount parsing/formatting, corrupted-wallet-file rejection |
|
||||
|
||||
Network-facing code is tested against an **in-process fake ElectrumX server**
|
||||
@@ -363,17 +372,21 @@ validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md).
|
||||
### CLI in brief
|
||||
```bash
|
||||
# Wallet
|
||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P]
|
||||
dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--no-encryption]
|
||||
dotnet run --project src/Cli -- restore "<mnemonic>" [...]
|
||||
dotnet run --project src/Cli -- restore-xpub <slip132-key> [--net ...] [--password P]
|
||||
dotnet run --project src/Cli -- restore-address <addr1,addr2,...> [--net ...] [--password P]
|
||||
dotnet run --project src/Cli -- info [--net ...] [--password P]
|
||||
dotnet run --project src/Cli -- restore-xpub <slip132-key> [--net ...] [--no-encryption]
|
||||
dotnet run --project src/Cli -- restore-address <addr1,addr2,...> [--net ...] [--no-encryption]
|
||||
dotnet run --project src/Cli -- info [--net ...]
|
||||
|
||||
# Network
|
||||
dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl]
|
||||
dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast]
|
||||
dotnet run --project src/Cli -- servers [--discover]
|
||||
```
|
||||
|
||||
The wallet password is read from `--password-env NAME`, `--password-stdin` or a no-echo terminal
|
||||
prompt; a new wallet is stored unencrypted only with `--no-encryption` (`--password P` still works
|
||||
but is unsafe and warns). See USERGUIDE section 17.
|
||||
The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json` (override with `--file`).
|
||||
Run without arguments for the full command list (also covers `newseed`, `addresses`, `reset-certs`);
|
||||
see [USERGUIDE.md §17](USERGUIDE.md#17-command-line-interface-cli) for complete flag reference.
|
||||
|
||||
+10
-2
@@ -6,7 +6,7 @@ Palladium Wallet is a self-custody SPV wallet. It is designed to protect funds a
|
||||
|
||||
- Theft of the wallet file at rest (AES-256-GCM encryption with PBKDF2-HMAC-SHA512)
|
||||
- Memory snooping of private keys after unlock (keys are held only in process memory, never written to disk in plaintext unless the user explicitly disables encryption)
|
||||
- Fraudulent transaction injection by a malicious server (every confirmed transaction is verified with a Merkle proof against SPV-validated block headers anchored to hardcoded checkpoints)
|
||||
- Fraudulent transaction injection by a malicious server (every confirmed transaction is verified with a Merkle proof against a block header that hash-chains up to a hardcoded checkpoint; the blocks above the newest checkpoint are the known exception, see the SPV trust model)
|
||||
|
||||
It does **not** protect against:
|
||||
|
||||
@@ -21,7 +21,7 @@ It does **not** protect against:
|
||||
|
||||
This wallet is an SPV client, not a full node. It validates:
|
||||
|
||||
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) back to the nearest hardcoded checkpoint at or below its height, and that checkpoint's hash must match exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`). Currently populated for mainnet only (every 20,000 blocks); testnet/regtest have no checkpoints yet, so headers there are trusted at face value
|
||||
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) **up to the nearest hardcoded checkpoint at or above its height**, and the header at that checkpoint height must hash to the checkpoint exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`, ADR 0040). The direction matters: with PoW skipped, a chain built *from* a checkpoint *up to* a header proves nothing, because anyone can fabricate such a chain; a chain ending in a known hash cannot be forged without inverting SHA-256. Mainnet checkpoints run every 20,000 blocks from 20,000 (so even the oldest blocks are covered) and are extended at each release. **Known limit:** a transaction in a block *above the highest checkpoint* has no checkpoint to anchor to, so only its Merkle proof against the header the server supplied is checked there; the window is the blocks mined since the last release. The wallet flags such transactions as **not anchored** in the history list and in the transaction detail (ADR 0041), so the reduced guarantee is visible instead of silent. For those blocks the wallet also cross-checks the header with a second server taken from the hardcoded bootstrap list (never from announced peers): a different header at a safe depth (3+ blocks) aborts the sync, a shallower disagreement leaves the proof unverified until the next sync, and with no reachable witness only the Merkle proof applies (ADR 0042). This raises the bar from one lying server to two; it does not help against two colluding servers or an attacker in front of both. Testnet/regtest have no checkpoints yet, so headers there are trusted at face value
|
||||
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
|
||||
|
||||
It does **not** validate:
|
||||
@@ -35,12 +35,19 @@ The indexing server (ElectrumX-compatible, port 50001/50002) is a **semi-trusted
|
||||
- Lie about unconfirmed (mempool) transactions — the wallet shows mempool transactions as unconfirmed and non-spendable
|
||||
- Refuse to relay a broadcast transaction
|
||||
- Delay reporting of new blocks
|
||||
- Suggest misleading fee estimates or relay fee (the wallet ignores implausible values, never goes below the relay fee, and always shows the absolute fee, flagging one above 1% of the amount — ADR 0039)
|
||||
|
||||
It cannot (given correct Merkle verification):
|
||||
|
||||
- Fabricate a confirmed transaction with a valid Merkle proof
|
||||
- Forge a payment to a wrong address
|
||||
|
||||
**Chain identity.** Right after the handshake the wallet checks that the server is on the Palladium chain (`Core/Spv/ChainVerifier.cs`, ADR 0036): `genesis_hash` from `server.features` must match the profile, and the header at the highest checkpoint not above the server's tip must hash to the hardcoded checkpoint. Mainnet shares Bitcoin's genesis, so the checkpoint header is what actually distinguishes it; testnet/regtest (no checkpoints) rely on the genesis alone. A server whose tip is below the first checkpoint is accepted on the genesis check alone.
|
||||
|
||||
**Network resource limits.** Server input is bounded so a hostile server cannot hang or kill the
|
||||
client: every request has a timeout (30 s; 10 s for the handshake; a timeout drops the connection)
|
||||
and a single reply line is capped at 16 MiB (`Core/Net/ElectrumClient.cs`, ADR 0034 and 0035).
|
||||
|
||||
**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`,
|
||||
@@ -61,6 +68,7 @@ a sync, never a partial one, so no unverified data survives a restart.
|
||||
|
||||
- The BIP39 mnemonic and derived private keys exist only in process memory after unlock
|
||||
- Private keys are never written to disk, logged, or sent over the network
|
||||
- The address list and the address detail sheet hold no private key: the WIF of an address is derived only when the user reveals it (after the wallet password, if the wallet is encrypted) and is dropped when it is hidden, when the sheet closes or when the wallet closes (ADR 0037). The key is a managed string and cannot be zeroed, so it remains in process memory until the garbage collector reclaims it
|
||||
- The wallet file stores the encrypted seed (with password) or the encrypted/plaintext `WalletDocument` — the document contains the account xpub and sync cache, not the raw seed when watch-only
|
||||
- Watch-only wallets (`restore-xpub`, or `restore-address` for pure address imports) hold no private keys and cannot sign transactions; `TransactionFactory` only calls `AddKeys`/signs when the account reports a private key, so an address-only import can never produce a signed transaction, only an exportable unsigned PSBT
|
||||
|
||||
|
||||
+53
-22
@@ -138,7 +138,7 @@ a sync cache (balances, history, verified proofs).
|
||||
- If you set a password, the file is encrypted with **AES-256-GCM**; the key is derived with
|
||||
**PBKDF2-HMAC-SHA512, 600,000 iterations, random 16-byte salt** (fresh salt and nonce on
|
||||
every save). Tampering with the file is detected before decryption.
|
||||
- If you **decline encryption** (the wizard checkbox, or omitting `--password` in the CLI),
|
||||
- If you **decline encryption** (the wizard checkbox, or `--no-encryption` in the CLI),
|
||||
**the seed is stored in plaintext on disk**. The wizard warns you explicitly. Only do this
|
||||
on a disk you fully trust (e.g. an encrypted volume).
|
||||
- There are **no password strength requirements and no attempt limits**. An empty password is
|
||||
@@ -379,10 +379,19 @@ wallet must be spendable (not watch-only) to actually broadcast.
|
||||
than 1 satoshi (0.00000001 PLM).
|
||||
- **Send all** — checkbox; disables the amount field and sends the entire spendable balance,
|
||||
**with the fee deducted from the amount** (the recipient receives balance − fee).
|
||||
- **Fee (sat/vB)** — a single manually-entered fee rate. **Default: 1 sat/vB.** There are no
|
||||
presets and no automatic fee estimation. On the Palladium chain, blocks are rarely full,
|
||||
so 1 sat/vB normally confirms in the next few blocks; raise it if a transaction lingers in
|
||||
the mempool. Must be a number greater than zero.
|
||||
- **Fee (sat/vB)** — the fee rate, which you can always type by hand. **Default: 1 sat/vB.**
|
||||
Once connected, the wallet asks the server for the minimum relay fee and for estimates, and
|
||||
shows three presets — **Fast** (~2 blocks), **Normal** (~6) and **Economy** (~24) — with the
|
||||
numbers underneath; **Normal** is preselected unless you already typed a rate. An estimate
|
||||
never goes below the relay fee, and an implausible one (above 1000 sat/vB) is ignored. If the
|
||||
server gives no estimate the presets are hidden and the manual field stays at 1. A manual rate
|
||||
below the server's relay fee is refused with a message (the node would reject it anyway).
|
||||
The estimate comes from the server, which is not trusted: the preview always shows the
|
||||
**absolute fee**, and warns when it is more than 1% of the amount sent. On the Palladium
|
||||
chain, blocks are rarely full, so 1 sat/vB normally confirms in the next few blocks; raise it
|
||||
if a transaction lingers in the mempool. Must be a number greater than zero and at most 1000;
|
||||
`.` or `,` is the decimal separator (no thousands separators), regardless of the system
|
||||
language.
|
||||
- **QR scan** (Android only) — camera button; reads a plain address or a
|
||||
`palladium:` payment URI (the address part is used).
|
||||
|
||||
@@ -439,7 +448,7 @@ software of your choice, then broadcast the finalized transaction.
|
||||
|
||||
The Help overlay includes a donation form (developer address
|
||||
`plm1qdq3gu2zvg9lyr8gxd6yln4wavc5tlp8prmvfay`) using the same prepare/confirm flow and the
|
||||
fee rate from the Send tab (falling back to 1 sat/vB if invalid). Entirely optional.
|
||||
fee rate from the Send tab (an invalid rate is reported as an error). Entirely optional.
|
||||
|
||||
---
|
||||
|
||||
@@ -507,9 +516,11 @@ them is normal.
|
||||
|
||||
A simple per-wallet address book, on its own tab:
|
||||
|
||||
- **Add**: name + address, both required (whitespace is trimmed). Note: the address format
|
||||
is **not validated when saving** — it is only validated when you actually send to it. Copy
|
||||
addresses carefully.
|
||||
- **Add**: name + address, both required. The address is **validated when saving** against
|
||||
the wallet's network: an invalid or wrong-network address, or one already in the list, is
|
||||
refused with a message. Spaces and line breaks in a pasted address are removed. Contacts
|
||||
saved by an older version without validation are still shown; the send form validates the
|
||||
destination again when you use them.
|
||||
- **Remove selected**: deletes the highlighted contact. There is no edit function — remove
|
||||
and re-add.
|
||||
- Contacts are used by the **From contacts** dropdown on the Send tab.
|
||||
@@ -567,9 +578,14 @@ or servers instead of resetting.
|
||||
Each sync: gets the chain tip → scans your receive and change address chains (gap limit
|
||||
20) → downloads your transactions → **verifies a Merkle proof for every confirmed
|
||||
transaction** against its block header → rebuilds your UTXO set and balances locally.
|
||||
On mainnet, each block header used for a proof is also chained back (via its previous-block
|
||||
hash) to the nearest built-in checkpoint, so a server cannot substitute a fabricated header
|
||||
for a real one. Verified data is cached in the wallet file, so later syncs only fetch what
|
||||
On mainnet, each block header used for a proof is also chained up (via the previous-block
|
||||
hashes) to the nearest built-in checkpoint above it, which must match exactly, so a server
|
||||
cannot substitute a fabricated header for a real one. The blocks mined after the newest
|
||||
checkpoint (shipped with each release) cannot be anchored this way: for those only the Merkle
|
||||
proof is checked, which is why updating the wallet regularly matters. Such transactions carry an
|
||||
orange **"not anchored"** label in the History tab and in the transaction detail (hover it for
|
||||
the explanation); it disappears once you update to a release whose newest checkpoint covers
|
||||
that block. The label is informational: the transaction is still Merkle-verified, and its block header is also compared with a second built-in server when one is reachable (if they disagree about a block that is a few blocks deep the sync stops with a "server is not trustworthy" error; for a very recent block the transaction just stays "verifying" until the next sync). Verified data is cached in the wallet file, so later syncs only fetch what
|
||||
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
|
||||
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
|
||||
unverified data; switch servers.
|
||||
@@ -582,7 +598,7 @@ resumes from the cache instead of restarting.
|
||||
transaction requires its own Merkle-proof round trip to the indexing server
|
||||
(`blockchain.transaction.get_merkle`, one request per transaction — the Electrum-style
|
||||
protocol has no batched form of this call), plus, on mainnet, chaining the covering block
|
||||
header back to the nearest hardcoded checkpoint. Sync time therefore scales with the number
|
||||
header up to the nearest hardcoded checkpoint above it. Sync time therefore scales with the number
|
||||
of confirmed transactions in the wallet's history, not with wall-clock time since creation.
|
||||
On **Android**, the first sync is typically slower still than on desktop for the same
|
||||
wallet: mobile networks add higher round-trip latency and lower sustained throughput than a
|
||||
@@ -590,7 +606,9 @@ desktop's wired/Wi-Fi connection, and every proof round trip pays that latency i
|
||||
Every subsequent sync is fast: verified proofs, raw transaction bytes, downloaded block
|
||||
headers, and the checkpoint hash-chain anchoring state are all persisted into the wallet
|
||||
file's cache, so a resumed or later sync only fetches and verifies what changed since the
|
||||
last one — even across an app restart.
|
||||
last one — even across an app restart. After updating from a version older than the
|
||||
checkpoint-direction fix (ADR 0040), the first sync re-verifies every cached proof once
|
||||
(the cached headers and transactions are reused).
|
||||
|
||||
**Do not use this wallet as a mining payout address.** Pool or solo mining payouts typically
|
||||
arrive as many small, frequent transactions, and — because of the per-transaction Merkle
|
||||
@@ -738,8 +756,19 @@ binary); with no arguments it prints usage.
|
||||
**Shared conventions.** Default wallet file:
|
||||
`~/.palladium-wallet/<network>/wallets/default.wallet.json` — override with `--file PATH`.
|
||||
Default network mainnet — override with `--net testnet|regtest` (the CLI is the only way to
|
||||
use test networks). `--password P` both decrypts an existing file and encrypts on save;
|
||||
**omitting it on `create`/`restore` writes the seed in plaintext** (a warning is printed).
|
||||
use test networks).
|
||||
|
||||
**Wallet password.** `create`/`restore*` ask for a password and encrypt the file; encrypted
|
||||
wallets need it for `info`/`sync`/`send`. Supply it, in order of preference, with:
|
||||
`--password-env NAME` (read from an environment variable), `--password-stdin` (first line of
|
||||
stdin), or nothing — in a terminal you are prompted (no echo, with a confirmation when
|
||||
creating); with no terminal the command fails instead of hanging. `--password P` still works
|
||||
but is unsafe (it stays in the shell history and the process list) and prints a warning.
|
||||
Only **one** of the three options may be given. A new wallet is stored **unencrypted only
|
||||
with the explicit `--no-encryption`** (a warning is printed); forgetting the password no
|
||||
longer produces a plaintext wallet. Examples:
|
||||
`printf '%s\n' "$PW" | dotnet run --project src/Cli -- restore "<words>" --password-stdin`,
|
||||
`PLM_PW=… dotnet run --project src/Cli -- info --password-env PLM_PW`.
|
||||
`--kind legacy|wrapped|segwit` selects the script type (default Native SegWit — note that
|
||||
only the literal values `legacy` and `wrapped` switch away from the default). Errors print
|
||||
to stderr and set exit code 1.
|
||||
@@ -752,7 +781,7 @@ newseed [--words 24]
|
||||
Prints a fresh mnemonic (12 words unless `--words 24`) and writes nothing to disk.
|
||||
|
||||
```
|
||||
create [--words 24] [--kind K] [--net N] [--passphrase W] [--password P] [--file PATH] [--path m/...]
|
||||
create [--words 24] [--kind K] [--net N] [--passphrase W] [--no-encryption] [--file PATH] [--path m/...]
|
||||
```
|
||||
Generates a mnemonic, prints it once, saves the wallet, prints the file path and the first
|
||||
receive address. `--path` overrides the derivation path (advanced; you must remember it to
|
||||
@@ -764,13 +793,13 @@ restore "<mnemonic>" [same options as create]
|
||||
Restores from an existing mnemonic (quoted, words space-separated).
|
||||
|
||||
```
|
||||
restore-xpub <slip132-key> [--net N] [--password P] [--file PATH]
|
||||
restore-xpub <slip132-key> [--net N] [--no-encryption] [--file PATH]
|
||||
```
|
||||
Creates a **watch-only** wallet from an xpub/ypub/zpub; the script type is inferred from
|
||||
the key prefix.
|
||||
|
||||
```
|
||||
info [--net N] [--password P] [--file PATH] [--addresses]
|
||||
info [--net N] [--file PATH] [--addresses]
|
||||
```
|
||||
Prints the wallet's identity and, if it has ever synced, the balance breakdown (spendable /
|
||||
maturing / pending), tip height, transaction count and next receive address.
|
||||
@@ -786,7 +815,7 @@ before restoring.
|
||||
### Network commands
|
||||
|
||||
```
|
||||
sync [--server host[:port]] [--ssl] [--net N] [--password P] [--file PATH]
|
||||
sync [--server host[:port]] [--ssl] [--net N] [--file PATH]
|
||||
```
|
||||
Connects (default: the first known server; port defaults to 50001, or 50002 with `--ssl`),
|
||||
synchronizes with full Merkle verification, persists the cache into the wallet file, and
|
||||
@@ -795,7 +824,7 @@ marked as unverified.
|
||||
|
||||
```
|
||||
send --to ADDRESS (--amount X | --all) [--feerate R] [--broadcast]
|
||||
[--server ...] [--ssl] [--net N] [--password P] [--file PATH]
|
||||
[--server ...] [--ssl] [--net N] [--file PATH]
|
||||
```
|
||||
Builds a transaction from the synced cache (`sync` must have been run first). `--amount` is
|
||||
in **PLM**; `--all` sends everything minus the fee. `--feerate` is in sat/vB, **default 1**.
|
||||
@@ -832,7 +861,9 @@ Reset SSL certificates* — see
|
||||
| *Wrong password.* | The password does not decrypt the file (or the file was tampered with — the two are indistinguishable by design). | Retry; check keyboard layout/caps lock. If truly lost, restore from seed ([15.2](#152-restoring-step-by-step)). |
|
||||
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
|
||||
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
|
||||
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
|
||||
| History shows an orange *"not anchored"* next to a transaction | The block is newer than the newest checkpoint built into this version, so its header cannot be chained to a hardcoded hash; only the Merkle proof backs it. | Normal for the most recent blocks. Update the wallet to a newer release to anchor them; for large amounts, wait until it is no longer flagged. |
|
||||
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain up to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
|
||||
| *The server host:port is not on the Palladium chain (different genesis or checkpoint)…* | The server answered, but serves another chain (or a fork) than the wallet expects. Nothing was synced from it. | Pick another server; the wallet moves on to the next known one on its own. See [12.3](#123-what-synchronization-actually-does). |
|
||||
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
|
||||
| Restored wallet shows zero balance | Wrong passphrase, wrong script type, or funds beyond the gap limit. | See [15.2](#152-restoring-step-by-step) — the coins are not lost. |
|
||||
| *Invalid mnemonic (wrong words or checksum)* | A word is misspelled, not on the BIP39 list, or the checksum fails. | Compare against your paper backup, word by word. |
|
||||
|
||||
+22
-10
@@ -72,10 +72,11 @@ Running without arguments shows an interactive menu — pick a single target or
|
||||
./docker/build.sh [TARGET] [--rebuild]
|
||||
|
||||
Targets:
|
||||
windows Win x64 single-file executable (native libs embedded)
|
||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All three targets
|
||||
windows Win x64 single-file executable (native libs embedded)
|
||||
linux Linux x64 single-file binary (runs as-is, nothing to install)
|
||||
linux-arm64 Linux ARM64 single-file binary (runs as-is, nothing to install)
|
||||
android Android APK (release-signed, prompts for keystore passwords)
|
||||
all All targets above
|
||||
|
||||
Options:
|
||||
--rebuild Force rebuild of the Docker images (needed after editing a Dockerfile)
|
||||
@@ -86,6 +87,7 @@ Examples:
|
||||
```bash
|
||||
./docker/build.sh all # build everything
|
||||
./docker/build.sh windows # Windows only
|
||||
./docker/build.sh linux-arm64 # Linux ARM64 only
|
||||
./docker/build.sh android --rebuild # Android, rebuilding the image first
|
||||
```
|
||||
|
||||
@@ -96,11 +98,12 @@ Examples:
|
||||
All artifacts land in `dist/` at the repository root. The version number is
|
||||
read automatically from `<Version>` in `src/App/PalladiumWallet.App.csproj`.
|
||||
|
||||
| Target | Path |
|
||||
|---------|--------------------------------------------------|
|
||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||
| Target | Path |
|
||||
|-------------|-----------------------------------------------------------|
|
||||
| Windows | `dist/windows/PalladiumWallet-{ver}-win-x64.exe` |
|
||||
| Linux | `dist/linux/PalladiumWallet-{ver}-linux-x64` |
|
||||
| Linux ARM64 | `dist/linux-arm64/PalladiumWallet-{ver}-linux-arm64` |
|
||||
| Android | `dist/android/PalladiumWallet-{ver}.apk` |
|
||||
|
||||
**Windows** — a single self-contained `.exe` (runtime and native libraries
|
||||
embedded). Copy it to any 64-bit Windows 10/11 machine and double-click.
|
||||
@@ -119,6 +122,15 @@ effectively all of them); no .NET or other packages to install. If you
|
||||
transfer it through a channel that strips permissions (e.g. a web download),
|
||||
restore the execute bit with `chmod +x`.
|
||||
|
||||
**Linux ARM64** — same as above, cross-published for `aarch64` (e.g.
|
||||
Raspberry Pi 4/5, ARM-based SBCs/laptops running a 64-bit distro). The build
|
||||
runs on an x64 Docker host — .NET's self-contained publish cross-targets
|
||||
`linux-arm64` without needing ARM hardware. Run it the same way:
|
||||
|
||||
```bash
|
||||
./PalladiumWallet-{ver}-linux-arm64
|
||||
```
|
||||
|
||||
**Android** — a release-signed APK for sideloading: transfer it to the phone
|
||||
and open it (enable "install from unknown sources" if prompted), or install
|
||||
via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
||||
@@ -138,7 +150,7 @@ via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+
|
||||
|
||||
| Image | Dockerfile | Used for | Size |
|
||||
|---------------------|----------------------|-----------------|---------|
|
||||
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux | ~1.5 GB |
|
||||
| `plm-build-desktop` | `Dockerfile.desktop` | windows + linux + linux-arm64 | ~1.5 GB |
|
||||
| `plm-build-android` | `Dockerfile.android` | android | ~5 GB |
|
||||
|
||||
Images are built automatically the first time a target needs them and reused
|
||||
|
||||
+32
-12
@@ -27,10 +27,11 @@ usage() {
|
||||
$(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS]
|
||||
|
||||
$(bold "Targets:")
|
||||
windows Win x64 single-file executable → dist/windows/
|
||||
linux Linux x64 single-file binary → dist/linux/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All three targets
|
||||
windows Win x64 single-file executable → dist/windows/
|
||||
linux Linux x64 single-file binary → dist/linux/
|
||||
linux-arm64 Linux ARM64 single-file binary → dist/linux-arm64/
|
||||
android Android APK (release-signed) → dist/android/
|
||||
all All targets above
|
||||
|
||||
$(bold "Options:")
|
||||
--rebuild Force rebuild of Docker images (e.g. after Dockerfile change)
|
||||
@@ -50,9 +51,9 @@ TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
windows|linux|android|all) TARGET="$arg" ;;
|
||||
--rebuild) REBUILD=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
windows|linux|linux-arm64|android|all) TARGET="$arg" ;;
|
||||
--rebuild) REBUILD=true ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) err "Unknown argument: $arg"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -62,10 +63,10 @@ if [[ -z "$TARGET" ]]; then
|
||||
bold "PalladiumWallet — reproducible build"
|
||||
echo ""
|
||||
PS3="Select target: "
|
||||
options=("windows" "linux" "android" "all" "quit")
|
||||
options=("windows" "linux" "linux-arm64" "android" "all" "quit")
|
||||
select opt in "${options[@]}"; do
|
||||
case "$opt" in
|
||||
windows|linux|android|all) TARGET="$opt"; break ;;
|
||||
windows|linux|linux-arm64|android|all) TARGET="$opt"; break ;;
|
||||
quit) echo "Aborted."; exit 0 ;;
|
||||
*) echo "Invalid choice, try again." ;;
|
||||
esac
|
||||
@@ -172,6 +173,23 @@ build_linux() {
|
||||
ok "Linux → dist/linux/PalladiumWallet-${VERSION}-linux-x64"
|
||||
}
|
||||
|
||||
build_linux_arm64() {
|
||||
ensure_desktop_image
|
||||
info "Building Linux ARM64 …"
|
||||
run_build "$IMAGE_DESKTOP" \
|
||||
"dotnet publish src/App.Desktop \
|
||||
-r linux-arm64 \
|
||||
-c Release \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
--self-contained \
|
||||
-o /tmp/linux-arm64-out
|
||||
install -m 755 /tmp/linux-arm64-out/PalladiumWallet \
|
||||
\"/output/PalladiumWallet-${VERSION}-linux-arm64\"" \
|
||||
"${DIST_DIR}/linux-arm64"
|
||||
ok "Linux ARM64 → dist/linux-arm64/PalladiumWallet-${VERSION}-linux-arm64"
|
||||
}
|
||||
|
||||
build_android() {
|
||||
ensure_android_image
|
||||
|
||||
@@ -221,12 +239,14 @@ build_android() {
|
||||
START=$(date +%s)
|
||||
|
||||
case "$TARGET" in
|
||||
windows) build_windows ;;
|
||||
linux) build_linux ;;
|
||||
android) build_android ;;
|
||||
windows) build_windows ;;
|
||||
linux) build_linux ;;
|
||||
linux-arm64) build_linux_arm64 ;;
|
||||
android) build_android ;;
|
||||
all)
|
||||
build_windows
|
||||
build_linux
|
||||
build_linux_arm64
|
||||
build_android
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [docs-as-code.md](docs-as-code.md) | How documentation is written, reviewed and checked |
|
||||
| [adr/](adr/README.md) | Architecture Decision Records, one per feature |
|
||||
|
||||
Related, at the repository root: [README](../README.md), [USERGUIDE](../USERGUIDE.md),
|
||||
[SECURITY](../SECURITY.md), [CHANGELOG](../CHANGELOG.md).
|
||||
|
||||
Check the docs before committing: `./docs/check-docs.sh`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0001. Record architecture decisions as ADRs
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Process
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Design choices in this wallet (SPV trust model, key handling, storage format) have direct financial impact and are easy to forget or to undo by accident. The reasoning currently lives in code comments, `SECURITY.md` and `CHANGELOG.md`, which record *what* but rarely *why one option was chosen over another*.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep one Architecture Decision Record per feature in `docs/adr/`, in Markdown, versioned with the code and reviewed in the same pull request as the change they describe. Format and workflow are defined in `docs/docs-as-code.md` and `docs/adr/template.md`. An accepted ADR is never rewritten: a changed decision gets a new ADR that supersedes the old one.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The rationale survives contributor turnover and is reviewable like code.
|
||||
- Decisions recorded retroactively (0002 onwards) reconstruct intent from code, comments and the changelog; they are marked as such and should be corrected if the original author remembers differently.
|
||||
- Small overhead per feature: an ADR is expected when a decision is hard to reverse or touches cryptography, SPV validation, key handling or the file format.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/docs-as-code.md`
|
||||
- `docs/adr/template.md`
|
||||
- `docs/check-docs.sh`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0002. One shared Avalonia UI with thin platform heads
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Architecture
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The wallet targets Windows, Linux and Android. Maintaining separate UIs would triple the surface to test and make behaviour drift between platforms.
|
||||
|
||||
## Decision
|
||||
|
||||
Write the UI once in the library `src/App` (Avalonia UI 12, .NET 10). `MainView` (a `UserControl`) is the shared root: hosted by `MainWindow` on desktop and used as the single-view root on Android. The heads `src/App.Desktop` (WinExe) and `src/App.Android` carry only the entry point and platform packages. Top-level services (file picker, clipboard) are reached through `TopLevel.GetTopLevel(this)`; platform differences are gated by `MainWindowViewModel.IsDesktop`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One code path for UI logic and one localization table.
|
||||
- UI must stay mobile-friendly (no OS windows, see 0021) and avoid desktop-only APIs outside `IsDesktop` guards.
|
||||
- Android needs its own workload, SDK and signing flow (0028, 0029).
|
||||
|
||||
## References
|
||||
|
||||
- `src/App/Views/MainView.axaml`
|
||||
- `src/App.Desktop`
|
||||
- `src/App.Android`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0003. Layered architecture: Core knows nothing about the UI
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Architecture
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The same wallet logic must be driven by a GUI, a CLI and headless tests. If cryptography or network code leaked into view code it could not be tested without a display and could not be audited in isolation.
|
||||
|
||||
## Decision
|
||||
|
||||
Layers depend only downward: GUI → wallet domain → SPV/sync → network → cryptography → persistence. `src/Core` (`Chain/ Crypto/ Wallet/ Spv/ Net/ Storage/`) has no UI dependency. `src/App` and `src/Cli` depend only on `Core`; the UI reaches the network and cryptography exclusively through the wallet domain.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dotnet test` covers almost all behaviour headlessly.
|
||||
- The CLI is a second front end for free (0024).
|
||||
- Violations (e.g. a ViewModel opening a socket) are review blockers, not style issues.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core`
|
||||
- `src/Cli/Program.cs`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0004. Centralize all chain constants in a network profile
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Chain
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Palladium is a Bitcoin-derived chain with its own address prefixes, BIP32 headers, bech32 HRP, genesis, ports and coin type, and it runs on mainnet, testnet and regtest. Scattered magic numbers make a wrong-network address or key a silent fund-safety bug.
|
||||
|
||||
## Decision
|
||||
|
||||
Define every chain constant once in `Core/Chain` (`ChainProfile`, `ChainProfiles`, `PalladiumNetworks`): address prefixes (P2PKH 55, P2SH 5), bech32 HRP `plm`, SLIP-132 headers, genesis hash, default ports (50001/50002), BIP44 coin type 746, maturity and confirmation policy, bootstrap servers and checkpoints. The profile is selected per network and passed into the code that needs it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new network or parameter change is a one-file edit.
|
||||
- Testnet and regtest ship with empty bootstrap-server and checkpoint lists (TODO in code), so they rely on a user-supplied server and have no checkpoint anchoring (see 0006).
|
||||
- Mainnet currently reuses Bitcoin's genesis hash, as noted in the profile.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Chain/ChainProfile.cs`
|
||||
- `src/Core/Chain/ChainProfiles.cs`
|
||||
- `src/Core/Chain/PalladiumNetworks.cs`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0005. Use NBitcoin for protocol primitives, hand-write only the rest
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Crypto
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Reimplementing BIP32/39, address encoding, transaction serialization, signing or PSBT is a classic source of subtle, fund-losing bugs.
|
||||
|
||||
## Decision
|
||||
|
||||
Build on NBitcoin for the custom network definition, BIP32/39, addresses, transactions, PSBT, signing, encoding and hashing. Hand-written code is limited to what NBitcoin does not provide for this chain: the Electrum JSON-RPC client (0009), SPV sync with Merkle verification and checkpoint anchoring (0006, 0007), coin selection and fee policy (0017), SLIP-132 variants beyond xpub/xprv (0014) and the encrypted wallet container (0012).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Smaller audited surface; cross-implementation golden vectors catch divergence.
|
||||
- NBitcoin assumes Bitcoin retargeting, which is why PoW validation is skipped (0006).
|
||||
- A dependency upgrade can change behaviour; golden-vector tests must pass before bumping.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Chain/PalladiumNetworks.cs`
|
||||
- `src/Core/Crypto/Slip132.cs`
|
||||
@@ -0,0 +1,27 @@
|
||||
# 0006. Anchor headers to hardcoded checkpoints and skip PoW validation
|
||||
|
||||
- **Status:** Accepted (the anchoring direction is superseded by [0040](0040-anchor-headers-up-to-a-checkpoint.md))
|
||||
- **Area:** SPV
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Palladium uses LWMA difficulty with 2-minute blocks. An SPV client cannot recompute LWMA without the full header history, and NBitcoin assumes Bitcoin's retargeting rules, so checking proof of work client-side would either be wrong or require downloading every header.
|
||||
|
||||
## Decision
|
||||
|
||||
Set `SkipPowValidation = true` in the profile and anchor trust to hardcoded checkpoints instead. For each header used in a Merkle proof, `WalletSynchronizer.AnchorToCheckpointAsync` downloads the intervening headers, verifies the prev-hash chain back to the nearest checkpoint at or below that height, and requires that checkpoint's hash to match exactly. Mainnet carries a checkpoint every 20,000 blocks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A server cannot feed an alternative history below the newest checkpoint.
|
||||
- Headers above the newest checkpoint are linked but their work is not verified; this is an accepted limitation documented in `SECURITY.md`.
|
||||
- Testnet/regtest have no checkpoints, so headers there are trusted at face value.
|
||||
- Checkpoints must be extended with releases; a wrong checkpoint would reject every honest server.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Spv/WalletSynchronizer.cs`
|
||||
- `src/Core/Spv/BlockHeaderInfo.cs`
|
||||
- `src/Core/Chain/ChainProfiles.cs`
|
||||
- `SECURITY.md`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0007. Verify a Merkle proof for every confirmed transaction
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** SPV
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The indexing server is semi-trusted: it can lie about history. Without proof of inclusion it could fabricate a confirmed payment.
|
||||
|
||||
## Decision
|
||||
|
||||
Every confirmed transaction must come with a Merkle branch that `MerkleProof.Verify` checks against the merkle root of an anchored header (0006). The txid, position and branch are taken from the server, the root only from a verified header. A mismatch fails the sync with `SpvVerificationException`; unconfirmed transactions are shown as such and are never spendable.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A malicious server cannot fabricate a confirmed transaction with a valid proof.
|
||||
- Cost: one proof request per confirmed transaction, mitigated by caching verified state (0013) and progressive verification (0008).
|
||||
- Script execution and double-spend detection are not covered; see `SECURITY.md`.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Spv/MerkleProof.cs`
|
||||
- `src/Core/Spv/WalletSynchronizer.cs`
|
||||
- `SECURITY.md`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0008. Progressive verification: show early, spend only after proof
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** SPV
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
On wallets with thousands of historical transactions, blocking the whole sync on every Merkle proof made the app unusable, especially on mobile hardware.
|
||||
|
||||
## Decision
|
||||
|
||||
`WalletSynchronizer` raises `PartialResult` as soon as transaction downloads finish, then keeps verifying proofs in the background and refreshes the result periodically. Each cached transaction and UTXO carries a `Verified` flag. The UI marks unverified entries ("verifying…") and shows `PendingVerificationSats` separately. The invariant: `UtxoSpendability.IsSpendable` requires `Verified`, so unverified coins can be displayed but never spent. `SyncOnceAsync` still completes only when verification is finished, and the disk cache stores only the fully verified end state.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fast time-to-usable-balance.
|
||||
- A forged balance can be displayed briefly but never spent before the proof failure aborts the sync.
|
||||
- Balances have overlapping subsets (immature vs. pending verification); consumers must use `SpendableSats`, never subtract the subsets.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Spv/WalletSynchronizer.cs`
|
||||
- `src/Core/Wallet/UtxoSpendability.cs`
|
||||
- `SECURITY.md`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0009. Hand-written Electrum JSON-RPC client with pipelining and an in-flight cap
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Network
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The wallet talks to an ElectrumX/Fulcrum-style indexing server (ports 50001/50002, not the node's P2P port 2333). No library matched the needs: protocol 1.4, a testable in-process transport and control over throughput on large wallets.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement `ElectrumClient` directly over `TcpClient`/`SslStream`, newline-delimited JSON-RPC 2.0. A single-reader channel batches writes (like Electrum's buffered writer); a read loop completes pending requests by id; a semaphore caps in-flight requests (`DefaultMaxInFlight = 32`, configurable per connection) to avoid server throttling and disconnects. Typed helpers live in `ElectrumApi`. The class is deliberately not an interface: tests run against a real in-process server instead (0025).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Full control over request pipelining and error mapping (`ElectrumServerException`).
|
||||
- No single in-flight value suits every server, hence the per-connection knob.
|
||||
- Protocol quirks (e.g. `server.version` allowed once per connection) are our responsibility; the server probe (0027) exists to catch server differences.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Net/ElectrumClient.cs`
|
||||
- `src/Core/Net/ElectrumApi.cs`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0010. TOFU certificate pinning for indexing servers
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Network
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Indexing servers commonly use self-signed certificates, so CA validation is not available, yet silent MITM substitution must still be detectable.
|
||||
|
||||
## Decision
|
||||
|
||||
Trust On First Use: the certificate fingerprint is pinned on first successful TLS connection (`CertificatePinStore`, persisted per network in `server-certs.json`). A later mismatch raises `CertificatePinMismatchException` and blocks the connection until the user explicitly resets the pin (GUI, or CLI `reset-certs`). A corrupted pin file must not permanently block reconnection.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Detects certificate substitution after first contact.
|
||||
- Does not protect the first connection; users cannot distinguish a legitimate rotation from an attack, so the reset is deliberately manual.
|
||||
- Reflects `SECURITY.md`; any change to the pinning policy must update it in the same commit.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Net/CertificatePinStore.cs`
|
||||
- `SECURITY.md`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0011. Server registry seeded by bootstrap list and peer discovery
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Network
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The wallet needs a server without hardcoding a single point of failure, and users should be able to pick their own.
|
||||
|
||||
## Decision
|
||||
|
||||
`ServerRegistry` starts from the profile's bootstrap servers, adds peers announced via `server.peers.subscribe` (missing ports fall back to profile defaults) and persists the list per network. The first entry is the default when the user specifies none. The last successfully connected server is stored in `AppConfig`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Works out of the box and survives bootstrap servers disappearing.
|
||||
- There is no multi-server pooling or cross-checking yet (listed as a limitation in `SECURITY.md`).
|
||||
- Discovered peers are unauthenticated hints; every response is still verified (0007).
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Net/ServerRegistry.cs`
|
||||
- `src/Core/Storage/AppConfig.cs`
|
||||
@@ -0,0 +1,26 @@
|
||||
# 0012. Wallet file encrypted with AES-256-GCM and PBKDF2-HMAC-SHA512
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Storage
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The wallet file contains the seed and keys; theft of the file must not mean theft of funds.
|
||||
|
||||
## Decision
|
||||
|
||||
`EncryptedFile` wraps the whole wallet document in a self-describing JSON container (`plm-wallet-aesgcm-v1`: kdf, iterations, salt, nonce, ciphertext, tag). AES-256-GCM, key from PBKDF2-HMAC-SHA512 with 600,000 iterations, a fresh 16-byte salt and 12-byte nonce on every save. The iteration count is stored in the file, so it can be raised later, and is capped at 10,000,000 on read because it is attacker-controlled. A wrong password or tampering raises `WrongPasswordException`; any malformed container raises `InvalidDataException`, never a raw parser error. Writes are atomic (temp file + rename).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tampering is detected before decryption; parameter upgrades stay backward compatible.
|
||||
- The GCM tag cannot tell a wrong password from a corrupted file.
|
||||
- Users may opt out of encryption (`Save` with a null password), only with an explicit UI warning.
|
||||
- Changes to parameters or format must update `SECURITY.md` in the same commit.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Storage/EncryptedFile.cs`
|
||||
- `src/Core/Storage/WalletStore.cs`
|
||||
- `SECURITY.md`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0013. Versioned JSON wallet document with an offline sync cache
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Storage
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Wallet metadata (labels, contacts, gap limit) and the last synced state must persist across sessions and across app upgrades; balances should be viewable offline.
|
||||
|
||||
## Decision
|
||||
|
||||
`WalletDocument` is a versioned JSON schema (`CurrentVersion = 1`) with a migration hook on open. It holds the seed or keys (so it must be encrypted, 0012), account xpub and path, labels, contacts, gap limit (default 20) and a `SyncCache` with balance/history. Only confirmed transactions are cached because unconfirmed ones can change (RBF); only the fully verified end state of a sync is persisted (0008).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reopening a wallet is fast and works offline.
|
||||
- Schema changes require a version bump and a migration, tested before release.
|
||||
- A large cache makes saves heavy, so `WalletStore.SaveAsync` moves serialization and I/O off the UI thread.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Storage/WalletDocument.cs`
|
||||
- `src/Core/Storage/WalletStore.cs`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0014. BIP32/39 HD wallets with BIP44/49/84 paths and SLIP-132 keys
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Crypto
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Users expect seed compatibility with other wallets and a choice of address format.
|
||||
|
||||
## Decision
|
||||
|
||||
Derive accounts with BIP39 + BIP32 using purposes 44 (legacy P2PKH), 49 (wrapped P2SH-P2WPKH) and 84 (native P2WPKH), coin type 746 from the profile (`DerivationPaths`). Extended keys are encoded and parsed as SLIP-132 (xpub/ypub/zpub families) with headers from the profile (`Slip132`), since NBitcoin only registers xprv/xpub. An optional BIP39 passphrase is supported. Native SegWit is the default kind.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Restore from seed or xpub/xprv reproduces the same addresses; golden vectors compare against a reference wallet.
|
||||
- Taproot (BIP86) and multisig (BIP48) constants exist, but derivation for them is not implemented (see 0031).
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Crypto/DerivationPaths.cs`
|
||||
- `src/Core/Crypto/Slip132.cs`
|
||||
- `src/Core/Crypto/HdAccount.cs`
|
||||
- `src/Core/Crypto/Bip39.cs`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0015. Imported-key and watch-only accounts
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Wallet
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Users need to import WIF keys, track an xpub, or watch plain addresses without exposing key material.
|
||||
|
||||
## Decision
|
||||
|
||||
`IWalletAccount` abstracts HD and imported accounts. `ImportedKeyAccount` holds a fixed address list (no HD derivation; change returns to the first imported address). Watch-only accounts (from xpub, or from addresses with no keys) report no private key, and `TransactionFactory` only signs when the account has one, so they can only ever produce an unsigned PSBT.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Watch-only is structurally read-only, not just disabled in the UI.
|
||||
- Imported-key wallets reuse addresses for change, a privacy trade-off.
|
||||
- Pure address imports derive the script kind only informationally.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Crypto/IWalletAccount.cs`
|
||||
- `src/Core/Crypto/ImportedKeyAccount.cs`
|
||||
- `src/Core/Wallet/WalletLoader.cs`
|
||||
@@ -0,0 +1,23 @@
|
||||
# 0016. Route every signing flow through PSBT
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Wallet
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Offline, air-gapped and hardware-assisted signing all need a transport format, and a wallet that signs through a different path for each case multiplies the code that handles keys.
|
||||
|
||||
## Decision
|
||||
|
||||
`TransactionFactory.Build` produces a `BuiltTransaction` that always carries a PSBT. Accounts that hold keys also return the signed transaction; watch-only accounts return the unsigned PSBT for export and offline signing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One build path, interoperable with other PSBT tools.
|
||||
- Hardware-wallet and multisig integration (not in v1) can reuse the same format.
|
||||
- Cross-implementation tests compare PSBTs to a reference wallet; a differing PSBT or txid is a blocking bug.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Wallet/TransactionFactory.cs`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0017. Coin selection, fee and confirmation policy
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Wallet
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Fund safety depends on never spending immature or unverified coins and on producing standard, relayable transactions.
|
||||
|
||||
## Decision
|
||||
|
||||
`TransactionFactory` supports automatic or manual UTXO selection, a fixed fee rate in sat/vByte, send-all with the fee subtracted, change on the internal chain, and RBF enabled on every transaction. It rejects transactions above 100,000 vbytes. `UtxoSpendability.IsSpendable` is the single gate shared by coin selection and balance reporting: a coinbase output needs maturity + 1 confirmations (121 on mainnet), a regular output needs `MinConfirmations` (policy, not consensus), frozen UTXOs are excluded, and the Merkle proof must be verified (0008).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Balances and coin selection cannot disagree.
|
||||
- Users may see "insufficient funds" despite a larger visible balance when part of it is immature or unverified.
|
||||
- RBF is set but fee bumping is not exposed in the UI (see `SECURITY.md`).
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Wallet/TransactionFactory.cs`
|
||||
- `src/Core/Wallet/UtxoSpendability.cs`
|
||||
@@ -0,0 +1,22 @@
|
||||
# 0018. Exclusive OS-level lock on the open wallet file
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Storage
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Two processes opening the same wallet could overwrite each other's saves and corrupt state.
|
||||
|
||||
## Decision
|
||||
|
||||
`WalletLock.TryAcquire` holds a `FileStream` opened with `FileShare.None` on a companion `.lock` file for the session. The OS releases it on exit or crash, so there are no stale locks to clean up. A second opener gets `null`; permission errors propagate so the UI can show a distinct message.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Safe multi-instance behaviour without lock-file housekeeping.
|
||||
- Protects against concurrent app instances only, not against external tools editing the file.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Storage/WalletLock.cs`
|
||||
@@ -0,0 +1,23 @@
|
||||
# 0019. Resolve the data root by explicit precedence
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Storage
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Desktop users want portable installs or a custom folder; Android must use its sandbox; existing installs must keep their data.
|
||||
|
||||
## Decision
|
||||
|
||||
`AppPaths.DataRoot()` resolves in order: `OverrideDataRoot` (set by the Android head to the app sandbox, or by tests), portable `palladium-data` folder next to the executable, user-chosen folder stored in a pointer file at a fixed bootstrap location, legacy location if it already holds data, then the platform default (`%APPDATA%\PalladiumWallet` on Windows, `~/.palladium-wallet` on Linux). Each network gets its own subfolder (config, wallets, certificates, servers).
|
||||
|
||||
## Consequences
|
||||
|
||||
- First-run wizard asks for a location only when none is determined; Android skips that step.
|
||||
- Static override seams complicate tests, which therefore share the xUnit collection `AppPaths`.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Storage/AppPaths.cs`
|
||||
- `src/App.Android`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0020. A single ViewModel split into partial classes by feature
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** UI
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The UI is one `MainView` with many overlays sharing wallet, sync and connection state; many small ViewModels would need heavy cross-wiring.
|
||||
|
||||
## Decision
|
||||
|
||||
Use one `MainWindowViewModel` (CommunityToolkit.Mvvm: `[ObservableProperty]`, `[RelayCommand]`) that drives `Core` directly, split into partial classes by feature (`.Send`, `.Receive`, `.Sync`, `.Settings`, `.Wizard`, `.Contacts`, `.Update`, `.WalletInfo`, `.Donate`). New feature logic goes into the matching partial or a new one.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Shared state needs no messaging layer.
|
||||
- The class is large; discipline on partial boundaries is what keeps it navigable.
|
||||
- Heavy network work must run off the UI thread (`Task.Run`).
|
||||
|
||||
## References
|
||||
|
||||
- `src/App/ViewModels/MainWindowViewModel.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Send.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Sync.cs`
|
||||
@@ -0,0 +1,23 @@
|
||||
# 0021. In-app overlays instead of OS windows
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** UI
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Secondary windows and popups are slow on WSLg and do not exist on Android's single-view root.
|
||||
|
||||
## Decision
|
||||
|
||||
Details (address, transaction), settings and help are full-screen `Border`s gated by an `IsXxxOpen` flag, with Open/Close commands, a backdrop handler and an Esc handler in `MainView`'s code-behind. Close buttons bind via `$parent[UserControl]`, not `$parent[Window]`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Instant open/close and identical behaviour on desktop and Android.
|
||||
- Overlay state lives in the ViewModel and must be closed on navigation.
|
||||
|
||||
## References
|
||||
|
||||
- `src/App/Views/MainView.axaml`
|
||||
- `src/App/Views/MainView.axaml.cs`
|
||||
@@ -0,0 +1,23 @@
|
||||
# 0022. In-code localization table for seven languages
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** UI
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The wallet targets users across several markets and must work offline, with the strings available identically on every platform.
|
||||
|
||||
## Decision
|
||||
|
||||
`Loc` holds a key → 7-language table (it, en, es, fr, pt, de, zh). XAML uses `{Binding Loc[key]}`, C# uses `Loc.Tr("key")`. On language change the `Loc` instance is replaced so bindings refresh. An unknown language falls back to English. The picker in the Settings overlay is a hand-written list of `RadioButton`s bound to `IsLangXx` properties, so a new language needs a column, a button and a property.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No resource-file tooling and no runtime lookup failures.
|
||||
- Adding a language touches three places; forgetting the picker leaves it unreachable.
|
||||
|
||||
## References
|
||||
|
||||
- `src/App/Localization/Loc.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Settings.cs`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 0023. Best-effort update check against GitHub releases
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Distribution
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Android builds are signed sideloads and desktop builds are single-file executables, so there is no store to notify users of fixes.
|
||||
|
||||
## Decision
|
||||
|
||||
`UpdateChecker.CheckAsync` compares the running version (single source: `<Version>` in the App csproj) with the latest GitHub release tag at startup. Any network or parse failure, or an up-to-date app, resolves to null; the UI prompts only when a newer release exists. The HTTP handler is injectable so tests never reach GitHub.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users learn about security fixes promptly; no telemetry is sent beyond the release API request.
|
||||
- The check leaks to GitHub that a wallet started; this is not covered by a Tor/proxy option.
|
||||
- The repository URL is hardcoded in the source and must be kept in sync if the project moves.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Net/UpdateChecker.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Update.cs`
|
||||
- `tests/PalladiumWallet.Tests/Net/UpdateCheckerTests.cs`
|
||||
@@ -0,0 +1,22 @@
|
||||
# 0024. A CLI front end on the same Core
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Distribution
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Scripting, headless servers and quick manual verification need the wallet without a GUI.
|
||||
|
||||
## Decision
|
||||
|
||||
`src/Cli` exposes `create`, `restore`, `restore-xpub`, `restore-address`, `info`, `sync`, `send`, `servers`, `reset-certs`, `newseed` and `addresses` on the same `Core` as the GUI, with `--net`, `--file`, `--server host:port` and `--ssl` options. The default wallet file is `~/.palladium-wallet/<network>/wallets/default.wallet.json`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Core behaviour is exercised outside the GUI.
|
||||
- Passwords on the command line are visible to other local users and shell history; the CLI is a developer tool, not a hardened interface.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Cli/Program.cs`
|
||||
@@ -0,0 +1,23 @@
|
||||
# 0025. Test the network layer against an in-process fake server
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Testing
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
SPV code must be proven to reject lying servers, which cannot be done reliably against real servers, and mocks of `ElectrumClient` would hide transport bugs.
|
||||
|
||||
## Decision
|
||||
|
||||
`tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` is a loopback TCP server with optional TLS, per-method handlers and call counters. Tests program it to return wrong Merkle proofs, drop connections or throttle, and assert that `ElectrumClient`, `WalletSynchronizer`, `TransactionInspector` and certificate pinning react correctly. The client is not made an interface on purpose.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Real sockets, real serialization, deterministic adversarial scenarios.
|
||||
- New protocol behaviour means extending the fake, not mocking.
|
||||
|
||||
## References
|
||||
|
||||
- `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs`
|
||||
- `tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0026. Fuzz every untrusted-input parser
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Testing
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Headers, Merkle branches, extended keys, mnemonics, addresses, amounts, wallet documents, encrypted files and peer lists all arrive from outside the trust boundary. A crash or unexpected exception there is a bug or a denial of service.
|
||||
|
||||
## Decision
|
||||
|
||||
`tests/PalladiumWallet.Fuzz` (SharpFuzz) has one target per parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract: any other escaping exception is a finding. The seed corpus, including regression inputs for past findings, replays inside `dotnet test` through `FuzzCorpusTests`. Property-based tests (CsCheck) complement it for roundtrip invariants.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Past findings (corrupted pin file, JSON wallet detection) became permanent regression inputs.
|
||||
- A new parser requires a new fuzz target and seed entries.
|
||||
- Coverage-guided campaigns need afl++, so they are manual rather than part of the default test run.
|
||||
|
||||
## References
|
||||
|
||||
- `tests/PalladiumWallet.Fuzz/Program.cs`
|
||||
- `tests/PalladiumWallet.Fuzz/fuzz.sh`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0027. Server probe tool and opt-in live-server test
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Testing
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Servers differ (Fulcrum, ElectrumX, electrs), can be on the wrong chain, lag behind or serve invalid proofs. The fake server cannot reveal how a real deployment behaves.
|
||||
|
||||
## Decision
|
||||
|
||||
`tests/PalladiumWallet.ServerProbe` is a read-only CLI that runs black-box checks against real servers through the wallet's own `ElectrumClient`: handshake and TLS fingerprint, `server.features` (genesis and `sha256`), tip freshness, genesis header, header linkage, a Merkle proof of a recent coinbase against the header root, fees, peers, error handling, latency and a pipelined burst, plus a cross-server tip comparison. `LiveServerTests` runs it from `dotnet test` only when `PALLADIUM_TEST_SERVERS` is set, so the default run stays offline.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Operators and developers can qualify a server before using it.
|
||||
- The live test depends on third-party availability and is therefore opt-in.
|
||||
- The probe reuses production client code, so a pass means the wallet can use the server.
|
||||
|
||||
## References
|
||||
|
||||
- `tests/PalladiumWallet.ServerProbe/Program.cs`
|
||||
- `tests/PalladiumWallet.Tests/Net/LiveServerTests.cs`
|
||||
@@ -0,0 +1,26 @@
|
||||
# 0028. Reproducible release builds inside pinned Docker images
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Distribution
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Users of a wallet must be able to trust that a shipped binary comes from the published source, and builds must not drift with whatever SDK a developer has installed.
|
||||
|
||||
## Decision
|
||||
|
||||
`docker/build.sh [windows|linux|android|all]` builds each target in a container whose toolchain (.NET SDK, JDK, Android SDK and workload) is pinned in `docker/Dockerfile.*`; artifacts land in `dist/`. The desktop single-file publish needs `IncludeNativeLibrariesForSelfExtract=true`, otherwise Avalonia's native libraries stay outside the executable and it silently fails to start.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Dockerfiles are the auditable definition of how releases are made.
|
||||
- This pins the environment; bit-for-bit identical output is not guaranteed by .NET (timestamps, signing).
|
||||
- The Android workload dictates the SDK API level (error XA5207 means bump `ANDROID_SDK_PLATFORM`).
|
||||
|
||||
## References
|
||||
|
||||
- `docker/build.sh`
|
||||
- `docker/Dockerfile.desktop`
|
||||
- `docker/Dockerfile.android`
|
||||
- `docker/README.md`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0029. Persistent Android release keystore kept out of git
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Distribution
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Android refuses to update an installed app signed with a different key, and the user would have to uninstall, which deletes the wallet data.
|
||||
|
||||
## Decision
|
||||
|
||||
Release APKs are signed with one persistent keystore generated once by `docker/keystore/generate-keystore.sh`. `release.keystore` is ignored by git; passwords are supplied through environment variables or prompts at each build and are never stored. `docker/build.sh android` refuses to run without the keystore.
|
||||
|
||||
## Consequences
|
||||
|
||||
- In-place updates keep working across releases.
|
||||
- Losing the keystore or its passwords permanently breaks in-place updates; it must be backed up outside the repository.
|
||||
|
||||
## References
|
||||
|
||||
- `docker/keystore/generate-keystore.sh`
|
||||
- `docker/keystore/README.md`
|
||||
- `.gitignore`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0030. Single-source versioning with a mandatory changelog entry
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Distribution
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
The App version, Android's display version and Android's `versionCode` must stay consistent, and `versionCode` must strictly increase or users cannot update in place.
|
||||
|
||||
## Decision
|
||||
|
||||
`<Version>` in `src/App/PalladiumWallet.App.csproj` is the only source. `update-version.sh` updates it, mirrors `ApplicationDisplayVersion` in the Android head, increments `ApplicationVersion` and stubs a `CHANGELOG.md` entry. The changelog entry is filled in before or with the tag; commit and tag are manual.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The title bar, update checker (0023) and release builds read the same value.
|
||||
- The changelog is the technical record of what shipped, not optional bookkeeping.
|
||||
|
||||
## References
|
||||
|
||||
- `update-version.sh`
|
||||
- `CHANGELOG.md`
|
||||
- `src/App/PalladiumWallet.App.csproj`
|
||||
@@ -0,0 +1,24 @@
|
||||
# 0031. Explicitly defer multisig, Lightning, Tor and hardware wallets
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Scope
|
||||
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
|
||||
|
||||
## Context
|
||||
|
||||
Each of these adds large attack surface or UX complexity. Shipping a half-working version in a wallet is worse than not shipping it.
|
||||
|
||||
## Decision
|
||||
|
||||
The first release excludes Lightning, M-of-N multisig, hardware-wallet integration, Tor/proxy support, multi-server pooling and RBF/CPFP fee-bumping UI. Where the profile already defines the constants (multisig SLIP-132 headers, BIP48/BIP86 purposes), the unimplemented paths throw rather than silently producing an incorrect wallet. Coin control exists in `TransactionFactory` (manual UTXO selection) and freezing in the cache; `SECURITY.md` lists the limitations users must know.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A smaller, auditable v1.
|
||||
- Network traffic reveals which addresses are queried (no Tor); this is stated in the threat model.
|
||||
- Each deferred feature needs its own ADR when taken up.
|
||||
|
||||
## References
|
||||
|
||||
- `SECURITY.md`
|
||||
- `src/Core/Crypto/DerivationPaths.cs`
|
||||
@@ -0,0 +1,34 @@
|
||||
# 0032. Run tests, fuzz smoke and docs checks in GitHub Actions
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Testing
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
The suite (unit, property-based, fuzz-corpus replay, in-process fake-server tests) is the primary verification layer for a wallet where a regression can lose funds, but it only ran on a developer's machine. Nothing prevented a failing change, a broken docs index or a Windows-only break from reaching `main`.
|
||||
|
||||
## Decision
|
||||
|
||||
`.github/workflows/ci.yml` runs on every push to `main` and on every pull request, with read-only permissions and cancellation of superseded runs:
|
||||
|
||||
- **test**: restore, build (Release) and `dotnet test` of `tests/PalladiumWallet.Tests` on `ubuntu-latest` and `windows-latest`, uploading the TRX and coverage results as artifacts. Only the test project and its references are built, because the Android head needs a workload and SDK that CI does not provision.
|
||||
- **fuzz-smoke**: one job per parser target, running `--random 20000` mutations; any escaping exception fails the job.
|
||||
- **docs**: `docs/check-docs.sh`.
|
||||
- A **coverage gate** on the Ubuntu test job fails the run if `Core` drops below the thresholds in the workflow (see 0033).
|
||||
|
||||
The pipeline is offline: `LiveServerTests` does nothing without `PALLADIUM_TEST_SERVERS`, so no third-party indexing server is contacted. Coverage-guided fuzzing (afl++) and release builds stay manual.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A change that breaks tests on either OS, a fuzz contract or the docs checks is visible before merge.
|
||||
- Release, Android and Docker builds are not exercised by CI yet.
|
||||
- Action versions are pinned to major tags; moving to commit SHAs would harden the supply chain further.
|
||||
- Runs consume hosted-runner minutes; the fuzz matrix is the cheapest place to cut if needed.
|
||||
|
||||
## References
|
||||
|
||||
- `.github/workflows/ci.yml`
|
||||
- `docs/check-docs.sh`
|
||||
- `tests/PalladiumWallet.Fuzz/Program.cs`
|
||||
- Related: 0025, 0026, 0027, 0033
|
||||
@@ -0,0 +1,34 @@
|
||||
# 0033. Test the UI layer and CLI headlessly and gate coverage
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Testing
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
`Core` was covered at about 99% of lines, but `App` (ViewModels, localization) and `Cli` had no tests at all, although they hold logic that moves funds and handles secrets: the send flow, password and lock handling, seed and private-key reveal, the wizard. Nothing stopped coverage from silently dropping.
|
||||
|
||||
## Decision
|
||||
|
||||
- **ViewModels** are tested headlessly with `Avalonia.Headless` (no window, no GPU). The helper package `Avalonia.Headless.XUnit` is not used because it requires xunit v3 and the suite is on xunit 2; `UiSession` runs each test body on a shared headless session instead. Tests are sandboxed: the data root is a temp folder, the saved server points at the in-process `FakeElectrumServer`, and two internal seams keep them offline: `MainWindowViewModel.UpdateCheck` (stubs the GitHub check) and `MainWindowViewModel.ProfileOverride` (removes the real bootstrap servers).
|
||||
- **CLI** is tested end to end by invoking the real entry point in-process with a captured console, against the fake server (create, restore, info, sync, send).
|
||||
- **Localization** has consistency tests: every key has all seven languages, placeholders match across languages, and every key used in XAML or C# exists.
|
||||
- **Tests that touch process-wide state** (`AppPaths`, `Loc`, `Console`, the seams above) share the xUnit collection `AppPaths` so they run serially.
|
||||
- **Coverage** is measured with `tests/coverage.runsettings` (test tooling excluded). CI fails if `Core` drops below 95% of lines or 88% of branches (`tests/check-coverage.py`); `App` and `Cli` are reported but not yet gated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Regressions in the wizard, reveal gating, lock handling and send preparation are caught without a display.
|
||||
- Writing the tests found a real defect in `ElectrumClient` (requests hung forever after a protocol error); it is fixed and covered.
|
||||
- `MainView` code-behind (window-bound) and the Android head remain untested.
|
||||
- Headless tests run background timers; the test seams must stay in place for the life of the test process so that leftover ViewModels cannot reach the network.
|
||||
|
||||
## References
|
||||
|
||||
- `tests/PalladiumWallet.Tests/App/HeadlessSetup.cs`
|
||||
- `tests/PalladiumWallet.Tests/App/VmTestBase.cs`
|
||||
- `tests/PalladiumWallet.Tests/App/CliTests.cs`
|
||||
- `tests/PalladiumWallet.Tests/App/LocalizationTests.cs`
|
||||
- `tests/coverage.runsettings`
|
||||
- `tests/check-coverage.py`
|
||||
- Related: 0025, 0026, 0032
|
||||
@@ -0,0 +1,28 @@
|
||||
# 0034. Bound every network request with a timeout
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Network
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
`ElectrumClient.RequestAsync` waited until the server answered or the caller cancelled. The only timeout in the stack was the 8 s ping of the ViewModel keep-alive, which does not run while a sync is in progress. A server that stops answering without closing the socket (half-open TCP, Android Doze, a stalled or hostile server) left a running sync waiting forever, with no error and no automatic recovery.
|
||||
|
||||
## Decision
|
||||
|
||||
- Every request has a timeout, `ElectrumClient.DefaultRequestTimeout` (30 s). The `server.version` handshake has its own, shorter `DefaultHandshakeTimeout` (10 s). Both are parameters of `ConnectAsync` (`requestTimeout`, `handshakeTimeout`), so tests use a few hundred milliseconds.
|
||||
- The timer starts when the request obtains its in-flight slot: waiting behind the cap (ADR 0009) does not count.
|
||||
- On expiry the request fails with `TimeoutException` (naming the method and server) and the **whole connection is marked dead**: the other pending requests fail, `IsConnected` becomes false, `Disconnected` is raised and later requests fail fast with `IOException`. After a missed answer the socket cannot be trusted, and the ViewModel reconnects through the existing path. A late reply is dropped because the request is no longer pending.
|
||||
- The keep-alive is left as it is (it skips ticks while syncing): the request timeout now bounds a stalled sync, so a second mechanism is not needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A stalled server makes a sync fail within the timeout instead of hanging; the existing retry and reconnect logic takes over.
|
||||
- A legitimately slow reply above 30 s is treated as a dead connection. The largest realistic replies (header batches, large transactions, busy-address histories) are far below that on a working link; the value is a constructor parameter if a deployment needs more.
|
||||
- One slow request tears down the shared connection and the requests in flight with it. This is deliberate: it is cheaper than a half-open socket that keeps accepting writes.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Net/ElectrumClient.cs`
|
||||
- `tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs`
|
||||
- Related: 0009
|
||||
@@ -0,0 +1,27 @@
|
||||
# 0035. Cap the length of a server line
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Network
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
`ElectrumClient.ReadLoopAsync` accumulated bytes until it found a newline, with no maximum. A malicious, misconfigured or compromised server could send an endless line and drive memory use up until the process died, which is cheap to do and fatal on Android. `SECURITY.md` treats denial of service against the indexing server as out of scope, but here it is the client that dies, and the fix is small.
|
||||
|
||||
## Decision
|
||||
|
||||
- A single JSON-RPC line is capped at `ElectrumClient.DefaultMaxLineBytes` (16 MiB), a parameter of `ConnectAsync` (`maxLineBytes`). The default is well above the largest legitimate replies: header batches (a few hundred KB), a large transaction (up to about 2 MB of hex) and the history of a very active address (a few MB).
|
||||
- The check runs as the buffer grows, **before** a newline arrives, and also on complete lines, so neither an unterminated stream nor one huge terminated line is buffered past the cap (apart from what a single read delivers).
|
||||
- On excess the read loop fails with `InvalidDataException`. This reuses the protocol-error path: pending requests fail with that exception, the connection is marked dead, `Disconnected(ex)` is raised and later requests fail fast.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One hostile server can no longer exhaust the memory of the client with an unbounded line.
|
||||
- A legitimate reply larger than 16 MiB would be refused; none is expected on this chain, and the cap is a parameter.
|
||||
- No new parser is introduced, so no new fuzz target.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Net/ElectrumClient.cs`
|
||||
- `tests/PalladiumWallet.Tests/Net/ElectrumClientTests.cs`
|
||||
- Related: 0009, 0034
|
||||
@@ -0,0 +1,35 @@
|
||||
# 0036. Verify the chain identity of a server right after connecting
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** SPV
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
The wallet connected to any Electrum server that answered on the configured port and found out it was on another chain only indirectly, and only for transactions at or above the first checkpoint, with a message that did not name the cause. The server probe checked the genesis hash; the wallet did not. A further complication: the mainnet profile reuses Bitcoin's genesis hash, and registry servers report exactly that value, so a genesis check alone cannot tell Palladium from Bitcoin.
|
||||
|
||||
## Decision
|
||||
|
||||
After the handshake, `ChainVerifier.VerifyChainAsync` (`Core/Spv/ChainVerifier.cs`) runs once per connection:
|
||||
|
||||
1. `server.features`: `genesis_hash` must be present and equal the profile's genesis (case-insensitive). A server that does not report it, or errors on the method, is rejected: the protocol requires it, and on testnet/regtest it is the only identity check.
|
||||
2. `blockchain.headers.subscribe` gives the tip height. The highest hardcoded checkpoint with `Height <= tip` is selected.
|
||||
3. The header at that height (`blockchain.block.header`) is hashed and compared with the checkpoint. A mismatch, a server error, or a malformed header means a different chain.
|
||||
|
||||
Any failure throws `WrongChainException` (server, reason). The GUI (connect and peer discovery), the CLI and the server probe all call the same helper; the GUI disposes the connection and moves on to the next candidate server, showing a localised message if none is usable.
|
||||
|
||||
A tip **below the first checkpoint** (a young testnet, a server still syncing) has nothing to compare and is accepted on the genesis check alone. A tip below the *latest* checkpoint is checked against the highest one it has reached; being behind is not an error.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A server on another chain or fork is refused at connect time with an explicit message instead of failing indirectly during a sync.
|
||||
- Mainnet is protected by the checkpoint header, not by the Bitcoin-shared genesis. Testnet and regtest (no checkpoints yet) rely on the genesis alone.
|
||||
- Two extra requests per connection (`server.features`, `blockchain.headers.subscribe`, plus one header on mainnet). The sync asks for the tip again anyway.
|
||||
- The check is made once, at connect time; a server that changes chain mid-connection is still caught by the per-transaction anchoring.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Spv/ChainVerifier.cs`
|
||||
- `tests/PalladiumWallet.Tests/Spv/ChainVerifierTests.cs`
|
||||
- `tests/PalladiumWallet.ServerProbe/Program.cs`
|
||||
- Related: 0009, 0034
|
||||
@@ -0,0 +1,27 @@
|
||||
# 0037. Derive an address private key only when it is revealed
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Security
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
`AddressRow` carried the WIF of its address, computed for every listed address (ten rows before the first sync, every scanned address after it). The reveal gate (password prompt) controlled only display, not presence: for the whole session the WIF of every address sat in managed strings, which cannot be zeroed, inside objects bound to the UI. That does not leak by itself, and `SECURITY.md` already states that keys live in process memory after unlock, but it widened the exposure to a memory dump or a binding bug for no benefit.
|
||||
|
||||
## Decision
|
||||
|
||||
- `AddressRow` and `AddressInfo` hold no key. `AddressRow` keeps `HasPrivKey` (true when the account is not watch-only); `AddressInfo` carries the derivation coordinates (`IsChange`, `Index`).
|
||||
- The ViewModel derives the WIF in the reveal step, after the password check (`RevealPrivKeyInternal`), into `RevealedPrivKey`, which the overlay binds to.
|
||||
- `RevealedPrivKey` is cleared when the key is hidden, when the sheet changes or closes, and when the wallet closes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The exposure window of an address key shrinks from the whole session to the time it is shown.
|
||||
- The WIF string still cannot be zeroed; it lives until the garbage collector reclaims it. This is documented in `SECURITY.md`.
|
||||
- Watch-only wallets still never reveal a key (`HasPrivKey` is false and the derivation returns nothing).
|
||||
|
||||
## References
|
||||
|
||||
- `src/App/ViewModels/MainWindowViewModel.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Receive.cs`
|
||||
- `tests/PalladiumWallet.Tests/App/MainWindowViewModelSecurityTests.cs`
|
||||
@@ -0,0 +1,29 @@
|
||||
# 0038. CLI password sources and explicit plaintext storage
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Distribution
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
The CLI read the wallet password only from `--password`, where it is stored in the shell history and visible to other local users in the process list (ADR 0024 already noted it). Worse, when `--password` was missing in `create`/`restore*` the CLI printed a warning and stored the **seed in plaintext**: a forgotten flag silently produced an unencrypted wallet.
|
||||
|
||||
## Decision
|
||||
|
||||
- Password sources, one at most per command: `--password-env NAME`, `--password-stdin` (first line), or `--password P` (kept for compatibility, prints a warning on stderr). With none, a no-echo prompt on the terminal (`Console.ReadKey(intercept: true)`), with a confirmation when creating a wallet. With no terminal the command fails with a clear message and never hangs.
|
||||
- A new wallet (`create`, `restore`, `restore-xpub`, `restore-address`) is stored unencrypted **only** with `--no-encryption`, which prints a warning and cannot be combined with a password option. An empty password is refused.
|
||||
- Opening: a plaintext wallet needs no password; an encrypted one needs a source. The password used to open is reused to save the wallet back (`sync`).
|
||||
- Implemented in `src/Cli/CliPassword.cs`; the command logic in `Program.cs` only asks it for the password.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A forgotten flag can no longer produce a plaintext wallet; scripts that relied on that must add `--no-encryption` (recorded as a behaviour change in the changelog).
|
||||
- `--password` is not removed, so existing scripts with a password keep working; removing it is left to a major version.
|
||||
- The environment variable route still exposes the password to processes that can read the environment of the CLI; it is safer than an argument but not a hardened interface (the CLI remains a developer tool).
|
||||
|
||||
## References
|
||||
|
||||
- `src/Cli/CliPassword.cs`
|
||||
- `src/Cli/Program.cs`
|
||||
- `tests/PalladiumWallet.Tests/App/CliTests.cs`
|
||||
- Related: 0024
|
||||
@@ -0,0 +1,31 @@
|
||||
# 0039. Server fee estimates, relay floor and a high-fee warning
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** Wallet
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
`EstimateFeeAsync` and `RelayFeeAsync` existed but nothing called them. The default fee rate was the constant 1 sat/vB and the only validation was "greater than zero" (and the F-01 upper bound). A rate below the node's minimum relay fee built a transaction that was rejected only at broadcast, with a generic server error, and a congested chain gave no guidance. ADR 0017 chose a single manual fee rate; this extends it without replacing the manual field.
|
||||
|
||||
## Decision
|
||||
|
||||
- After connecting, the ViewModel asks the server for `blockchain.relayfee` and `blockchain.estimatefee` with targets 2, 6 and 24 blocks (`FeePolicy.FetchAsync`, `Core/Wallet/FeePolicy.cs`). Each call is optional: an error or a malformed answer leaves that value empty.
|
||||
- Values are converted from coin/kB to sat/vB (× 100,000,000 / 1,000), rounded to 2 decimals. `-1` (no estimate), zero, negative, or above `CoinAmount.MaxFeeRate` (1000 sat/vB) is discarded as implausible. An estimate is raised to the relay fee if lower.
|
||||
- The Send form offers Fast, Normal and Economy buttons and a hint line with the numbers; Normal is preselected only if the user has not typed a rate. Without usable estimates the presets are hidden and the manual field keeps the 1 sat/vB default.
|
||||
- A manual rate below the known relay fee is refused before building, with a message naming the minimum. Donate follows the same rules.
|
||||
- The estimate is untrusted input, so the absolute fee is always shown, and the preview adds a warning when the fee exceeds 1% of the amount sent (`FeePolicy.IsHighFee`). The warning is advisory, not blocking.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A rate the node would reject is caught locally with a clear message; the default follows the network instead of a constant.
|
||||
- A malicious server can still suggest a high-but-plausible rate (up to 1000 sat/vB); the bound, the visible absolute fee and the 1% warning limit the damage, and the user can always type a rate.
|
||||
- Presets fetched once per connection are not refreshed during a long session; reconnecting refreshes them.
|
||||
- The relay-fee check relies on a value from the same server; a server that reports a high relay fee can only make the wallet more conservative, not less.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Wallet/FeePolicy.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Send.cs`
|
||||
- `tests/PalladiumWallet.Tests/Wallet/FeePolicyTests.cs`
|
||||
- Related: 0017
|
||||
@@ -0,0 +1,32 @@
|
||||
# 0040. Anchor headers up to a checkpoint above them
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** SPV
|
||||
- **Supersedes:** the anchoring direction in 0006 (the rest of 0006 stands)
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0006 skips proof-of-work validation (LWMA cannot be recomputed by an SPV client) and relies on hardcoded checkpoints. `AnchorToCheckpointAsync` took the nearest checkpoint **at or below** a transaction's height and verified that the headers from the checkpoint **up to** that height were prev-hash linked. With PoW not checked, that chain proves nothing: a hostile server can fabricate a header for the block of a fake transaction (with any Merkle root) and chain fabricated headers after the real checkpoint hash; every link is valid and the Merkle proof then matches the fabricated root. The existing tests built their own synthetic chains, so they could not see it. A related gap (L-01): heights below the first checkpoint had no anchor at all.
|
||||
|
||||
## Decision
|
||||
|
||||
- Anchor **up to** the nearest checkpoint at or above the height. The headers from the transaction's height to the checkpoint height are downloaded, must be prev-hash linked, and the last one must hash to the hardcoded checkpoint. Forging that needs a SHA-256 preimage, not just a consistent set of fake headers.
|
||||
- `_anchoredDownTo[checkpointHeight]` records the lowest height already proven to reach that checkpoint. A later, lower height walks only the new segment, whose top is the already-trusted header; a height inside the range is a no-op. Persisted as `SyncCache.AnchoredDownTo`.
|
||||
- Heights below the first checkpoint are anchored to it (at most one checkpoint interval, 20,000 headers); this closes L-01 without new checkpoints.
|
||||
- **Above the newest checkpoint** there is nothing to anchor to: only the Merkle proof against the server's header is checked. This is a known, documented limit; it is narrowed by adding a recent checkpoint at every release (taken from a trusted node).
|
||||
- A wallet cache without `AnchoredDownTo` (written by an older version) is not trusted for `VerifiedAt`: every proof is verified again once. Cached headers and raw transactions are reused. A preloaded anchor is accepted only if its headers are also cached and the recorded height is not above its checkpoint.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The "headers are anchored to a checkpoint" claim now holds against a fabricating server, for every height up to the newest checkpoint.
|
||||
- Recent transactions (since the last release's checkpoint) stay protected by the Merkle proof alone. Releases should always ship a fresh checkpoint.
|
||||
- Downloading up to 20,000 headers (about 1.6 MB) for an old transaction is bounded and cached.
|
||||
- First sync after upgrading is slower for wallets with many cached proofs (re-verification once).
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Spv/WalletSynchronizer.cs`
|
||||
- `src/Core/Storage/WalletDocument.cs`
|
||||
- `tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs`
|
||||
- Related: 0006, 0036
|
||||
@@ -0,0 +1,28 @@
|
||||
# 0041. Flag transactions above the newest checkpoint as not anchored
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** SPV
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0040 anchors every header used for a Merkle proof up to a hardcoded checkpoint at or above it. Blocks above the newest checkpoint (those mined since the last release) have nothing to anchor to, so for them only the Merkle proof against the header the server supplied is checked. This is a known limit; the user could not see which transactions it applied to.
|
||||
|
||||
## Decision
|
||||
|
||||
- `ChainProfile.IsAboveNewestCheckpoint(height)` is true for a confirmed height above the highest checkpoint of a profile that has checkpoints. It is false for unconfirmed transactions and for profiles without checkpoints (testnet/regtest), where nothing is anchored by design and the notion does not apply.
|
||||
- `HistoryRow` carries `Unanchored`; the History list (desktop and mobile layouts) shows an orange "not anchored" label with a tooltip explaining it, and the transaction detail appends the same text to its status line.
|
||||
- The flag is informational: it does not block spending or change the Merkle verification. Reducing the window itself is done by shipping a recent checkpoint with every release; a second-server cross-check or a bounded proof-of-work check remain open options (see the backlog).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The reduced guarantee is visible per transaction instead of silent, and disappears after an update whose checkpoint covers the block.
|
||||
- Every wallet user sees the label on the most recent transactions between releases; the tooltip and the user guide explain it. It can alarm users unnecessarily, which is the price of honesty here.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Chain/ChainProfile.cs`
|
||||
- `src/App/ViewModels/MainWindowViewModel.Receive.cs`
|
||||
- `src/App/Views/MainView.axaml`
|
||||
- `tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs`
|
||||
- Related: 0040
|
||||
@@ -0,0 +1,35 @@
|
||||
# 0042. Cross-check recent headers with a witness server; no bounded-PoW check
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Area:** SPV
|
||||
- **Recorded:** 2026-11-03
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0040 anchors every header used for a Merkle proof up to a hardcoded checkpoint, but a block above the newest checkpoint has nothing to anchor to: only the header the primary server supplies and the Merkle proof against it are checked. A hostile server could show a payment that never happened in those recent blocks. ADR 0041 made the limit visible; this ADR reduces it.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Witness cross-check.** For a block above the newest checkpoint, `WalletSynchronizer` asks a second server (a "witness") for the header at the same height and requires identical bytes. The witnesses come from `WalletSynchronizer.WitnessProvider`, called lazily once, only when such a header is first needed.
|
||||
- **Witness selection** (`WitnessConnector`): only from the **hardcoded bootstrap list** of the profile, never from the peers a server announces (a hostile server could announce only its own), never the primary server, each verified to be on the chain (ADR 0036). One witness by default (`DefaultMax`). Used by the GUI and the CLI; the GUI closes them with the primary connection.
|
||||
- **Outcomes.** Agreement: the proof verifies as before. A witness that is unreachable, errors, or does not know the block: skipped. No witness at all: unchanged behaviour (Merkle proof only). Disagreement at a depth of at least `CrossCheckMinDepth` (3) blocks: `SpvVerificationException` ("servers disagree"), the sync aborts. Disagreement at a shallower depth may be a reorganisation in progress: the proof is left **unverified** (not spendable, not cached) and decided again at the next sync.
|
||||
- Each height is asked once per synchronizer. Heights covered by a checkpoint are not cross-checked.
|
||||
- The "not anchored" flag of ADR 0041 stays height-based: it is not cleared by a successful cross-check, because that result is not persisted.
|
||||
|
||||
## Rejected: bounded proof-of-work
|
||||
|
||||
The idea was to require recent headers to meet a target no easier than a bound on the newest checkpoint's difficulty. The data in the shipped checkpoints argue against it: between consecutive checkpoints (20,000 blocks) the target changed by up to 8x easier and 200x harder, so a safe bound would be loose; the network is small (about tens of TH/s at the last checkpoint), so work at a fraction of a block's difficulty is cheap to rent; and a bound that is too tight would make the wallet reject honest servers whenever hash rate drops. It would add cost and a false-rejection risk for little protection. Not implemented.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A single lying server can no longer fabricate a recent payment on its own: it would have to control the witness too. A witness run by the same operator as the primary, or an attacker positioned in front of both, defeats it; this is a hardening, not a proof.
|
||||
- One extra connection (TLS, pinned) is opened when a recent block needs checking; none for wallets whose history is all below the newest checkpoint.
|
||||
- A transaction in a block 1-2 deep on which the servers briefly disagree shows as "verifying" until the next sync.
|
||||
- A fresh checkpoint in every release still shrinks the window the cross-check has to cover.
|
||||
|
||||
## References
|
||||
|
||||
- `src/Core/Spv/WalletSynchronizer.cs`
|
||||
- `src/Core/Spv/WitnessConnector.cs`
|
||||
- `tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs`
|
||||
- Related: 0036, 0040, 0041
|
||||
@@ -0,0 +1,51 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
One ADR per feature or significant design decision. Format: `template.md`. Workflow: `../docs-as-code.md`.
|
||||
|
||||
| # | Decision | Area | Status |
|
||||
|---|----------|------|--------|
|
||||
| 0001 | [Record architecture decisions as ADRs](0001-record-architecture-decisions.md) | Process | Accepted |
|
||||
| 0002 | [One shared Avalonia UI with thin platform heads](0002-shared-avalonia-ui-with-platform-heads.md) | Architecture | Accepted |
|
||||
| 0003 | [Layered architecture: Core knows nothing about the UI](0003-core-without-ui-dependency.md) | Architecture | Accepted |
|
||||
| 0004 | [Centralize all chain constants in a network profile](0004-centralized-network-profile.md) | Chain | Accepted |
|
||||
| 0005 | [Use NBitcoin for protocol primitives, hand-write only the rest](0005-nbitcoin-for-protocol-primitives.md) | Crypto | Accepted |
|
||||
| 0006 | [Anchor headers to hardcoded checkpoints and skip PoW validation](0006-spv-checkpoint-anchoring-without-pow.md) | SPV | Accepted |
|
||||
| 0007 | [Verify a Merkle proof for every confirmed transaction](0007-mandatory-merkle-verification.md) | SPV | Accepted |
|
||||
| 0008 | [Progressive verification: show early, spend only after proof](0008-progressive-verification-sync.md) | SPV | Accepted |
|
||||
| 0009 | [Hand-written Electrum JSON-RPC client with pipelining and an in-flight cap](0009-custom-electrum-client.md) | Network | Accepted |
|
||||
| 0010 | [TOFU certificate pinning for indexing servers](0010-tls-tofu-certificate-pinning.md) | Network | Accepted |
|
||||
| 0011 | [Server registry seeded by bootstrap list and peer discovery](0011-server-registry-and-discovery.md) | Network | Accepted |
|
||||
| 0012 | [Wallet file encrypted with AES-256-GCM and PBKDF2-HMAC-SHA512](0012-encrypted-wallet-file.md) | Storage | Accepted |
|
||||
| 0013 | [Versioned JSON wallet document with an offline sync cache](0013-versioned-wallet-document-and-sync-cache.md) | Storage | Accepted |
|
||||
| 0014 | [BIP32/39 HD wallets with BIP44/49/84 paths and SLIP-132 keys](0014-hd-derivation-and-slip132.md) | Crypto | Accepted |
|
||||
| 0015 | [Imported-key and watch-only accounts](0015-imported-keys-and-watch-only-accounts.md) | Wallet | Accepted |
|
||||
| 0016 | [Route every signing flow through PSBT](0016-psbt-centric-signing.md) | Wallet | Accepted |
|
||||
| 0017 | [Coin selection, fee and confirmation policy](0017-coin-selection-fee-and-spendability-policy.md) | Wallet | Accepted |
|
||||
| 0018 | [Exclusive OS-level lock on the open wallet file](0018-exclusive-wallet-file-lock.md) | Storage | Accepted |
|
||||
| 0019 | [Resolve the data root by explicit precedence](0019-data-location-resolution.md) | Storage | Accepted |
|
||||
| 0020 | [A single ViewModel split into partial classes by feature](0020-single-viewmodel-with-feature-partials.md) | UI | Accepted |
|
||||
| 0021 | [In-app overlays instead of OS windows](0021-in-app-overlays-instead-of-windows.md) | UI | Accepted |
|
||||
| 0022 | [In-code localization table for seven languages](0022-localization-seven-languages.md) | UI | Accepted |
|
||||
| 0023 | [Best-effort update check against GitHub releases](0023-github-release-update-check.md) | Distribution | Accepted |
|
||||
| 0024 | [A CLI front end on the same Core](0024-cli-on-shared-core.md) | Distribution | Accepted |
|
||||
| 0025 | [Test the network layer against an in-process fake server](0025-in-process-fake-electrum-server-for-tests.md) | Testing | Accepted |
|
||||
| 0026 | [Fuzz every untrusted-input parser](0026-fuzzing-untrusted-input-parsers.md) | Testing | Accepted |
|
||||
| 0027 | [Server probe tool and opt-in live-server test](0027-server-probe-and-live-server-test.md) | Testing | Accepted |
|
||||
| 0028 | [Reproducible release builds inside pinned Docker images](0028-reproducible-docker-release-builds.md) | Distribution | Accepted |
|
||||
| 0029 | [Persistent Android release keystore kept out of git](0029-persistent-android-signing-keystore.md) | Distribution | Accepted |
|
||||
| 0030 | [Single-source versioning with a mandatory changelog entry](0030-single-source-versioning-and-changelog.md) | Distribution | Accepted |
|
||||
| 0031 | [Explicitly defer multisig, Lightning, Tor and hardware wallets](0031-defer-multisig-lightning-and-hardware-wallets.md) | Scope | Accepted |
|
||||
| 0032 | [Run tests, fuzz smoke and docs checks in GitHub Actions](0032-github-actions-ci.md) | Testing | Accepted |
|
||||
| 0033 | [Test the UI layer and CLI headlessly, gate coverage](0033-test-strategy-beyond-core.md) | Testing | Accepted |
|
||||
| 0034 | [Bound every network request with a timeout](0034-bound-every-network-request.md) | Network | Accepted |
|
||||
| 0035 | [Cap the length of a server line](0035-cap-server-line-length.md) | Network | Accepted |
|
||||
| 0036 | [Verify the chain identity of a server right after connecting](0036-verify-chain-identity-on-connect.md) | SPV | Accepted |
|
||||
| 0037 | [Derive an address private key only when it is revealed](0037-derive-address-private-keys-on-reveal.md) | Security | Accepted |
|
||||
| 0038 | [CLI password sources and explicit plaintext storage](0038-cli-password-sources-and-explicit-plaintext.md) | Distribution | Accepted |
|
||||
| 0039 | [Server fee estimates, relay floor and a high-fee warning](0039-server-fee-estimates-and-relay-floor.md) | Wallet | Accepted |
|
||||
| 0040 | [Anchor headers up to a checkpoint above them](0040-anchor-headers-up-to-a-checkpoint.md) | SPV | Accepted |
|
||||
| 0041 | [Flag transactions above the newest checkpoint as not anchored](0041-flag-transactions-above-the-newest-checkpoint.md) | SPV | Accepted |
|
||||
| 0042 | [Cross-check recent headers with a witness server; no bounded-PoW check](0042-cross-check-recent-headers-with-a-witness-server.md) | SPV | Accepted |
|
||||
|
||||
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
|
||||
If one misstates the original reasoning, fix it in a pull request.
|
||||
@@ -0,0 +1,25 @@
|
||||
# NNNN. Title in the imperative, naming the decision
|
||||
|
||||
- **Status:** Proposed | Accepted | Deprecated | Superseded by [NNNN](NNNN-slug.md)
|
||||
- **Area:** Architecture | Chain | Crypto | Net | SPV | Storage | Wallet | UI | Testing | Distribution | Scope | Process
|
||||
- **Recorded:** YYYY-MM-DD
|
||||
|
||||
## Context
|
||||
|
||||
The forces at play: the problem, the constraints, what was tried or rejected.
|
||||
Facts only; link to code, issues or specs instead of restating them.
|
||||
|
||||
## Decision
|
||||
|
||||
What we do, in the active voice ("We use…"). Specific enough that a reviewer
|
||||
can check the code against it.
|
||||
|
||||
## Consequences
|
||||
|
||||
What becomes easier, what becomes harder, new risks, follow-up work.
|
||||
Be honest about the downsides.
|
||||
|
||||
## References
|
||||
|
||||
- `path/to/code.cs`
|
||||
- Related ADRs, specs (BIPs), issues
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mechanical checks for docs/ (see docs/docs-as-code.md). Run from anywhere.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.." || exit 2
|
||||
fail=0
|
||||
err() { echo "FAIL: $*"; fail=1; }
|
||||
|
||||
adr_dir=docs/adr
|
||||
prev=0
|
||||
for f in $(ls $adr_dir/[0-9][0-9][0-9][0-9]-*.md 2>/dev/null | sort); do
|
||||
base=$(basename "$f"); num=${base:0:4}
|
||||
n=$((10#$num))
|
||||
[ "$n" -eq $((prev + 1)) ] || err "$base: numbering gap or duplicate (previous $prev)"
|
||||
prev=$n
|
||||
head -1 "$f" | grep -q "^# $num\. " || err "$base: first line must be '# $num. <title>'"
|
||||
grep -qE '^- \*\*Status:\*\* ' "$f" || err "$base: missing Status line"
|
||||
for s in Context Decision Consequences; do
|
||||
grep -q "^## $s\$" "$f" || err "$base: missing '## $s' section"
|
||||
done
|
||||
grep -q "($base)" $adr_dir/README.md || err "$base: not listed in $adr_dir/README.md"
|
||||
done
|
||||
[ "$prev" -gt 0 ] || err "no ADRs found"
|
||||
|
||||
# Index rows must point to existing files.
|
||||
grep -oE '\]\([0-9]{4}-[^)]+\.md\)' $adr_dir/README.md | tr -d '])(' | while read -r l; do
|
||||
[ -f "$adr_dir/$l" ] || { echo "FAIL: index links to missing $l"; exit 1; }
|
||||
done || fail=1
|
||||
|
||||
# Repo paths cited in backticks must exist.
|
||||
while IFS= read -r line; do
|
||||
file=${line%%:*}; p=${line#*:}
|
||||
[ -e "$p" ] || err "$file: cited path does not exist: $p"
|
||||
done < <(grep -oE '`(src|tests|docker|docs)/[A-Za-z0-9_./-]+`|`(README|USERGUIDE|SECURITY|CHANGELOG|CLAUDE|AGENTS)\.md`|`update-version\.sh`|`\.gitignore`' -r docs --include='*.md' \
|
||||
| grep -v 'template.md' | sed -E 's/`//g' | grep -v 'docs/adr/NNNN' | grep -v 'path/to')
|
||||
|
||||
# Relative markdown links in docs/ must resolve.
|
||||
for f in $(find docs -name '*.md'); do
|
||||
d=$(dirname "$f")
|
||||
for l in $(grep -oE '\]\([^)#:]+\.md\)' "$f" | sed -E "s/^\]\(|\)\$//g" | grep -v NNNN); do
|
||||
[ -e "$d/$l" ] || err "$f: broken link $l"
|
||||
done
|
||||
done
|
||||
|
||||
# CLAUDE.md and AGENTS.md must match apart from the header block (before '## Role').
|
||||
if ! diff <(sed -n '/^## Role/,$p' CLAUDE.md) <(sed -n '/^## Role/,$p' AGENTS.md) >/dev/null; then
|
||||
err "CLAUDE.md and AGENTS.md differ after the header block"
|
||||
fi
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "docs OK ($prev ADRs)"
|
||||
exit "$fail"
|
||||
@@ -0,0 +1,110 @@
|
||||
# Docs as code
|
||||
|
||||
Documentation in this repository is treated like source code: plain text in git,
|
||||
changed through pull requests, checked by a script, and kept next to the code it
|
||||
describes. This page defines how.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Docs live in the repo.** Markdown files, versioned with the code. No wiki,
|
||||
no separate document store that can drift from the release it describes.
|
||||
2. **One change, one pull request.** A change to behaviour, a file format,
|
||||
cryptography, SPV validation or key handling updates the affected docs in the
|
||||
same commit. `SECURITY.md` is the published threat model and must stay accurate
|
||||
(see `CLAUDE.md`, *Working rules*).
|
||||
3. **Review docs like code.** Reviewers check that the text matches the diff.
|
||||
4. **English only** for documentation, comments and commit messages.
|
||||
5. **Diagrams are text.** Use Mermaid code blocks, which render on GitHub and diff
|
||||
cleanly. No binary diagram files unless a tool cannot export text.
|
||||
6. **Prefer links to copies.** Point to the code path or the ADR instead of
|
||||
restating it, so there is one place to update.
|
||||
7. **Check mechanically.** `docs/check-docs.sh` runs the checks below; run it
|
||||
before committing documentation changes.
|
||||
|
||||
## Where things go
|
||||
|
||||
| What | Where | Audience |
|
||||
|------|-------|----------|
|
||||
| Project overview, build and run, test | `README.md` | Developers |
|
||||
| End-user manual | `USERGUIDE.md` | Users |
|
||||
| Threat model, crypto parameters, SPV trust | `SECURITY.md` | Users, reviewers |
|
||||
| What shipped in each release | `CHANGELOG.md` | Everyone |
|
||||
| Why the design is the way it is | `docs/adr/` | Maintainers, reviewers |
|
||||
| This process | `docs/docs-as-code.md` | Contributors |
|
||||
| Build, signing and release procedure | `docker/README.md`, `docker/keystore/README.md` | Release managers |
|
||||
| Rules for coding agents | `CLAUDE.md` and `AGENTS.md` (kept identical apart from their header) | Agents |
|
||||
|
||||
Rule of thumb: *what* and *how* belong to the README, user guide and code comments;
|
||||
*why this and not the alternative* belongs to an ADR.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
One ADR per feature or significant decision, in `docs/adr/NNNN-slug.md`, written
|
||||
from `docs/adr/template.md`. The index is `docs/adr/README.md`.
|
||||
|
||||
**When to write one.** Add or change an ADR when a change:
|
||||
|
||||
- introduces a feature or subsystem;
|
||||
- touches cryptography, SPV validation, key or seed handling, or the file format;
|
||||
- is hard to reverse, or someone is likely to ask "why not X?" later.
|
||||
|
||||
Bug fixes and refactors that keep the decision intact do not need an ADR.
|
||||
|
||||
**Lifecycle.**
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Proposed: opened in a pull request
|
||||
Proposed --> Accepted: merged
|
||||
Proposed --> [*]: rejected, PR closed
|
||||
Accepted --> Superseded: a newer ADR replaces it
|
||||
Accepted --> Deprecated: feature removed
|
||||
```
|
||||
|
||||
- Number ADRs sequentially; never reuse or renumber.
|
||||
- Do not rewrite the substance of an Accepted ADR. To change a decision, write a
|
||||
new ADR, set the old one's status to `Superseded by [NNNN](NNNN-slug.md)` and
|
||||
link back from the new one. Typos and broken links can be fixed in place.
|
||||
- Keep each ADR short: context, decision, consequences, references.
|
||||
- Reference code by repository-relative path in backticks. The check script
|
||||
verifies these paths still exist, so a rename shows up as a docs failure.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Change code] --> B{Decision hard to<br/>reverse or security-relevant?}
|
||||
B -- yes --> C[Add or supersede ADR]
|
||||
B -- no --> D[Update README / USERGUIDE / SECURITY if affected]
|
||||
C --> D
|
||||
D --> E[Run docs/check-docs.sh]
|
||||
E --> F[Pull request: code + docs together]
|
||||
F --> G[Review checks text against diff]
|
||||
```
|
||||
|
||||
1. Make the code change.
|
||||
2. Decide whether an ADR is needed (see above) and write or supersede it.
|
||||
3. Update `README.md`, `USERGUIDE.md`, `SECURITY.md` and `CHANGELOG.md` where the
|
||||
change affects them. If you edit `CLAUDE.md`, mirror the edit into `AGENTS.md`.
|
||||
4. Run `docs/check-docs.sh`.
|
||||
5. Commit code and docs together and open the pull request.
|
||||
|
||||
## What `docs/check-docs.sh` verifies
|
||||
|
||||
- Every ADR file is named `NNNN-slug.md`, numbers are unique and contiguous.
|
||||
- Every ADR has a title line matching its number, a `Status`, and the sections
|
||||
*Context*, *Decision*, *Consequences*.
|
||||
- Every ADR is listed in `docs/adr/README.md`, and every index row points to a file.
|
||||
- Repository paths cited in backticks (`src/…`, `tests/…`, `docker/…`, root files)
|
||||
exist.
|
||||
- Relative Markdown links in `docs/` resolve.
|
||||
- `CLAUDE.md` and `AGENTS.md` match apart from their header block.
|
||||
|
||||
It needs only `bash` and standard tools, so it runs on a developer machine and in CI
|
||||
(the `docs` job in `.github/workflows/ci.yml`, see ADR 0032).
|
||||
|
||||
## Possible next steps (not in place yet)
|
||||
|
||||
- Add a Markdown linter to the `docs` CI job.
|
||||
- Add a link checker for external URLs.
|
||||
- Publish `docs/` as a static site if the audience outgrows GitHub's renderer.
|
||||
@@ -8,8 +8,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<ApplicationVersion>4</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>5</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.2.0</ApplicationDisplayVersion>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
|
||||
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
|
||||
|
||||
@@ -436,6 +436,8 @@ public sealed class Loc
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar", "尚不可花费"],
|
||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung", "成熟中"],
|
||||
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft", "正在进行 SPV 验证"],
|
||||
["history.unanchored"] = ["non ancorata", "not anchored", "sin anclar", "non ancrée", "não ancorada", "nicht verankert", "未锚定"],
|
||||
["history.unanchored.tip"] = ["Blocco successivo all'ultimo checkpoint della versione: la sua intestazione e la prova Merkle arrivano dal server e non si collegano a un checkpoint. Aggiorna il wallet per ancorarla.", "Block after the newest checkpoint of this version: its header and Merkle proof come from the server and do not chain to a checkpoint. Update the wallet to anchor it.", "Bloque posterior al último punto de control de esta versión: su cabecera y la prueba Merkle vienen del servidor y no se enlazan a un punto de control. Actualiza la cartera para anclarlo.", "Bloc postérieur au dernier point de contrôle de cette version : son en-tête et la preuve Merkle viennent du serveur et ne se rattachent à aucun point de contrôle. Mettez le portefeuille à jour pour l'ancrer.", "Bloco posterior ao último checkpoint desta versão: o cabeçalho e a prova Merkle vêm do servidor e não se ligam a um checkpoint. Atualize a carteira para ancorá-lo.", "Block nach dem neuesten Checkpoint dieser Version: Header und Merkle-Beweis stammen vom Server und sind an keinen Checkpoint gekettet. Aktualisieren Sie die Wallet, um ihn zu verankern.", "此版本最新检查点之后的区块:其区块头和 Merkle 证明来自服务器,未链接到任何检查点。请更新钱包以完成锚定。"],
|
||||
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…", "验证中…"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert.", "设置已保存。"],
|
||||
["msg.certreset"] = [
|
||||
@@ -458,6 +460,12 @@ public sealed class Loc
|
||||
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen", "已广播"],
|
||||
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid", "谢谢!交易 ID"],
|
||||
["msg.unsigned.watchonly"] = [" · NON firmata (watch-only)", " · NOT signed (watch-only)", " · NO firmada (watch-only)", " · NON signée (watch-only)", " · NÃO assinada (watch-only)", " · NICHT signiert (watch-only)", " · 未签名(仅观察)"],
|
||||
["send.fee.fast"] = ["Veloce", "Fast", "Rápida", "Rapide", "Rápida", "Schnell", "快速"],
|
||||
["send.fee.normal"] = ["Normale", "Normal", "Normal", "Normale", "Normal", "Normal", "正常"],
|
||||
["send.fee.economy"] = ["Economica", "Economy", "Económica", "Économique", "Econômica", "Sparsam", "经济"],
|
||||
["send.fee.hint"] = ["Stima della rete (sat/vB): veloce {0} · normale {1} · economica {2} · minimo relay {3}", "Network estimate (sat/vB): fast {0} · normal {1} · economy {2} · relay minimum {3}", "Estimación de la red (sat/vB): rápida {0} · normal {1} · económica {2} · mínimo de relay {3}", "Estimation du réseau (sat/vB) : rapide {0} · normale {1} · économique {2} · minimum de relais {3}", "Estimativa da rede (sat/vB): rápida {0} · normal {1} · econômica {2} · mínimo de relay {3}", "Netzwerkschätzung (sat/vB): schnell {0} · normal {1} · sparsam {2} · Relay-Minimum {3}", "网络估算 (sat/vB):快速 {0} · 正常 {1} · 经济 {2} · 中继最低 {3}"],
|
||||
["msg.feerate.belowrelay"] = ["Fee rate sotto il minimo di relay del server ({0} sat/vB): la transazione verrebbe rifiutata.", "Fee rate below the server's minimum relay fee ({0} sat/vB): the transaction would be rejected.", "Tarifa por debajo del mínimo de relay del servidor ({0} sat/vB): la transacción sería rechazada.", "Taux de frais inférieur au minimum de relais du serveur ({0} sat/vB) : la transaction serait rejetée.", "Taxa abaixo do mínimo de relay do servidor ({0} sat/vB): a transação seria rejeitada.", "Gebührenrate unter dem Relay-Minimum des Servers ({0} sat/vB): Die Transaktion würde abgelehnt.", "手续费率低于服务器的最低中继费 ({0} sat/vB):交易将被拒绝。"],
|
||||
["msg.fee.high"] = ["Attenzione: la fee supera l'{0}% dell'importo inviato.", "Warning: the fee is more than {0}% of the amount sent.", "Atención: la comisión supera el {0}% del importe enviado.", "Attention : les frais dépassent {0} % du montant envoyé.", "Atenção: a taxa excede {0}% do valor enviado.", "Achtung: Die Gebühr beträgt mehr als {0} % des gesendeten Betrags.", "警告:手续费超过发送金额的 {0}%。"],
|
||||
["msg.cert.mismatch"] = [
|
||||
"Il certificato TLS di {0} è cambiato rispetto a quello salvato. Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.",
|
||||
"The TLS certificate of {0} has changed from the one saved. If the server renewed its certificate, reset the SSL certificates.",
|
||||
@@ -467,7 +475,32 @@ public sealed class Loc
|
||||
"Das TLS-Zertifikat von {0} hat sich gegenüber dem gespeicherten geändert. Wenn der Server das Zertifikat erneuert hat, setzen Sie die SSL-Zertifikate zurück.",
|
||||
"{0} 的 TLS 证书与已保存的证书不同。如果服务器更新了证书,请重置 SSL 证书。"],
|
||||
|
||||
["msg.chain.wrong"] = [
|
||||
"Il server {0} non è sulla catena Palladium (genesi o checkpoint diversi). Scegli un altro server.",
|
||||
"The server {0} is not on the Palladium chain (different genesis or checkpoint). Choose another server.",
|
||||
"El servidor {0} no está en la cadena Palladium (génesis o punto de control distintos). Elige otro servidor.",
|
||||
"Le serveur {0} n'est pas sur la chaîne Palladium (genèse ou point de contrôle différent). Choisissez un autre serveur.",
|
||||
"O servidor {0} não está na cadeia Palladium (gênese ou checkpoint diferente). Escolha outro servidor.",
|
||||
"Der Server {0} befindet sich nicht auf der Palladium-Chain (anderer Genesis-Block oder Checkpoint). Wählen Sie einen anderen Server.",
|
||||
"服务器 {0} 不在 Palladium 链上(创世块或检查点不同)。请选择其他服务器。"],
|
||||
|
||||
// Contacts
|
||||
["msg.contact.address.invalid"] = [
|
||||
"Indirizzo non valido per questa rete: il contatto non è stato salvato.",
|
||||
"Invalid address for this network: the contact was not saved.",
|
||||
"Dirección no válida para esta red: el contacto no se guardó.",
|
||||
"Adresse invalide pour ce réseau : le contact n'a pas été enregistré.",
|
||||
"Endereço inválido para esta rede: o contato não foi salvo.",
|
||||
"Ungültige Adresse für dieses Netzwerk: Der Kontakt wurde nicht gespeichert.",
|
||||
"该网络的地址无效:联系人未保存。"],
|
||||
["msg.contact.duplicate"] = [
|
||||
"Esiste già un contatto con questo indirizzo.",
|
||||
"A contact with this address already exists.",
|
||||
"Ya existe un contacto con esta dirección.",
|
||||
"Un contact avec cette adresse existe déjà.",
|
||||
"Já existe um contato com este endereço.",
|
||||
"Ein Kontakt mit dieser Adresse existiert bereits.",
|
||||
"已存在相同地址的联系人。"],
|
||||
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
|
||||
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
|
||||
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname", "联系人姓名"],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel
|
||||
titolo della finestra ed è incisa nei binari pubblicati. -->
|
||||
<Version>1.1.0</Version>
|
||||
<Version>1.2.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
@@ -27,4 +27,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,6 +2,9 @@ using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
@@ -33,8 +36,23 @@ public partial class MainWindowViewModel
|
||||
private void AddContact()
|
||||
{
|
||||
var name = NewContactName.Trim();
|
||||
var addr = NewContactAddress.Trim();
|
||||
// Pasted addresses often carry spaces or line breaks anywhere: drop all whitespace.
|
||||
var addr = string.Concat(NewContactAddress.Where(c => !char.IsWhiteSpace(c)));
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return;
|
||||
try
|
||||
{
|
||||
BitcoinAddress.Create(addr, PalladiumNetworks.For(Net));
|
||||
}
|
||||
catch (System.FormatException)
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.contact.address.invalid");
|
||||
return;
|
||||
}
|
||||
if (Contacts.Any(c => string.Equals(c.Address, addr, System.StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.contact.duplicate");
|
||||
return;
|
||||
}
|
||||
Contacts.Add(new ContactEntry(name, addr));
|
||||
NewContactName = NewContactAddress = "";
|
||||
PersistContacts();
|
||||
|
||||
@@ -44,9 +44,17 @@ public partial class MainWindowViewModel
|
||||
DonatePreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var feeRate) || feeRate <= 0)
|
||||
feeRate = 1m;
|
||||
if (!CoinAmount.TryParseFeeRate(SendFeeRate, out var feeRate))
|
||||
{
|
||||
DonatePreview = Loc.Tr("msg.feerate.invalid");
|
||||
return;
|
||||
}
|
||||
if (FeePolicy.IsBelowRelay(feeRate, _feeEstimates?.Relay))
|
||||
{
|
||||
DonatePreview = string.Format(Loc.Tr("msg.feerate.belowrelay"),
|
||||
FeePolicy.Format(_feeEstimates!.Relay!.Value));
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingDonate = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
@@ -55,7 +63,8 @@ public partial class MainWindowViewModel
|
||||
DonatePreview = $"txid {_pendingDonate.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingDonate.Fee.Satoshi)} " +
|
||||
$"({_pendingDonate.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingDonate.Signed ? "" : " · watch-only");
|
||||
(_pendingDonate.Signed ? "" : " · watch-only") +
|
||||
HighFeeWarning(_pendingDonate.Fee.Satoshi, amount);
|
||||
HasPendingDonate = _pendingDonate.Signed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -77,6 +77,11 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private bool isAddressPrivKeyRevealed;
|
||||
|
||||
// WIF of the address in the open sheet, derived only after the reveal gate and dropped as
|
||||
// soon as the key is hidden or the sheet closes. Never stored in rows or in AddressInfo.
|
||||
[ObservableProperty]
|
||||
private string revealedPrivKey = "";
|
||||
|
||||
// ---- private key password prompt ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -90,10 +95,23 @@ public partial class MainWindowViewModel
|
||||
|
||||
partial void OnAddressInfoChanged(AddressInfo? value)
|
||||
{
|
||||
IsAddressPrivKeyRevealed = false;
|
||||
HidePrivKeyInternal();
|
||||
ClosePrivKeyPromptInternal();
|
||||
}
|
||||
|
||||
private void HidePrivKeyInternal()
|
||||
{
|
||||
IsAddressPrivKeyRevealed = false;
|
||||
RevealedPrivKey = "";
|
||||
}
|
||||
|
||||
private void RevealPrivKeyInternal()
|
||||
{
|
||||
if (AddressInfo is not { HasPrivKey: true } info) return;
|
||||
RevealedPrivKey = KeyWif(info.IsChange, info.Index);
|
||||
IsAddressPrivKeyRevealed = RevealedPrivKey.Length > 0;
|
||||
}
|
||||
|
||||
private void ClosePrivKeyPromptInternal()
|
||||
{
|
||||
IsPrivKeyPromptOpen = false;
|
||||
@@ -102,7 +120,8 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
|
||||
public void ShowAddressInfo(AddressRow row) =>
|
||||
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
|
||||
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey,
|
||||
row.IsChange, row.Indice, row.HasPrivKey);
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseAddressInfo()
|
||||
@@ -118,7 +137,7 @@ public partial class MainWindowViewModel
|
||||
if (string.IsNullOrEmpty(_password))
|
||||
{
|
||||
// No encryption: reveal directly
|
||||
IsAddressPrivKeyRevealed = true;
|
||||
RevealPrivKeyInternal();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -137,7 +156,7 @@ public partial class MainWindowViewModel
|
||||
PrivKeyPromptError = Loc.Tr("msg.wrongpassword");
|
||||
return;
|
||||
}
|
||||
IsAddressPrivKeyRevealed = true;
|
||||
RevealPrivKeyInternal();
|
||||
ClosePrivKeyPromptInternal();
|
||||
}
|
||||
|
||||
@@ -145,10 +164,7 @@ public partial class MainWindowViewModel
|
||||
private void CancelPrivKeyPrompt() => ClosePrivKeyPromptInternal();
|
||||
|
||||
[RelayCommand]
|
||||
private void HideAddressPrivKey()
|
||||
{
|
||||
IsAddressPrivKeyRevealed = false;
|
||||
}
|
||||
private void HideAddressPrivKey() => HidePrivKeyInternal();
|
||||
|
||||
// ---- transaction detail overlay ----
|
||||
|
||||
@@ -224,6 +240,7 @@ public partial class MainWindowViewModel
|
||||
var transactions = _lastTransactions;
|
||||
var loc = _loc;
|
||||
var unit = _config.Unit;
|
||||
var unanchored = Profile.IsAboveNewestCheckpoint(height);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -231,7 +248,7 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
var details = await TransactionInspector.FetchAsync(
|
||||
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
|
||||
return new TransactionDetailsViewModel(details, loc, unit);
|
||||
return new TransactionDetailsViewModel(details, loc, unit, unanchored);
|
||||
}, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -265,8 +282,8 @@ public partial class MainWindowViewModel
|
||||
_account.GetReceiveAddress(i).ToString(), "—", "—",
|
||||
false,
|
||||
_account.GetPublicKey(false, i)?.ToHex() ?? "",
|
||||
KeyWif(false, i),
|
||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||
$"m/{_doc!.AccountPath}/0/{i}",
|
||||
!_account.IsWatchOnly));
|
||||
return;
|
||||
}
|
||||
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
|
||||
@@ -294,7 +311,8 @@ public partial class MainWindowViewModel
|
||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||
tx.Txid,
|
||||
tx.Verified));
|
||||
tx.Verified,
|
||||
Profile.IsAboveNewestCheckpoint(tx.Height)));
|
||||
|
||||
Addresses.Clear();
|
||||
foreach (var a in cache.Addresses)
|
||||
@@ -306,7 +324,7 @@ public partial class MainWindowViewModel
|
||||
a.TxCount > 0 ? a.TxCount.ToString() : "—",
|
||||
a.IsChange,
|
||||
_account.GetPublicKey(a.IsChange, a.Index)?.ToHex() ?? "",
|
||||
KeyWif(a.IsChange, a.Index),
|
||||
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
|
||||
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}",
|
||||
!_account.IsWatchOnly));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using NBitcoin;
|
||||
using Avalonia.Threading;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.App.Services;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
@@ -25,6 +26,73 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private bool sendAll;
|
||||
|
||||
// ---- fee presets from the server (ADR 0039) ----
|
||||
|
||||
private FeeEstimates? _feeEstimates;
|
||||
private bool _feeRateTouched;
|
||||
private bool _settingFeeRate;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasFeeEstimates;
|
||||
|
||||
[ObservableProperty]
|
||||
private string feeEstimateHint = "";
|
||||
|
||||
partial void OnSendFeeRateChanged(string value)
|
||||
{
|
||||
if (!_settingFeeRate)
|
||||
_feeRateTouched = true;
|
||||
}
|
||||
|
||||
private void SetFeeRate(string value)
|
||||
{
|
||||
_settingFeeRate = true;
|
||||
try { SendFeeRate = value; }
|
||||
finally { _settingFeeRate = false; }
|
||||
}
|
||||
|
||||
private async Task RefreshFeeEstimatesAsync(ElectrumClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
var estimates = await FeePolicy.FetchAsync(client);
|
||||
Dispatcher.UIThread.Post(() => ApplyFeeEstimates(estimates));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Optional information: a failure leaves the manual field and the 1 sat/vB default.
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFeeEstimates(FeeEstimates estimates)
|
||||
{
|
||||
_feeEstimates = estimates;
|
||||
HasFeeEstimates = estimates.HasAny;
|
||||
FeeEstimateHint = estimates.HasAny
|
||||
? string.Format(Loc.Tr("send.fee.hint"),
|
||||
estimates.Fast is { } f ? FeePolicy.Format(f) : "—",
|
||||
estimates.Normal is { } n ? FeePolicy.Format(n) : "—",
|
||||
estimates.Economy is { } e ? FeePolicy.Format(e) : "—",
|
||||
estimates.Relay is { } r ? FeePolicy.Format(r) : "—")
|
||||
: "";
|
||||
// Preselect the normal estimate unless the user already typed something.
|
||||
if (!_feeRateTouched && estimates.HasAny)
|
||||
SetFeeRate(FeePolicy.Format(FeePolicy.DefaultRate(estimates)));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UseFeePreset(string preset)
|
||||
{
|
||||
var rate = preset switch
|
||||
{
|
||||
"fast" => _feeEstimates?.Fast,
|
||||
"economy" => _feeEstimates?.Economy,
|
||||
_ => _feeEstimates?.Normal,
|
||||
};
|
||||
if (rate is { } r)
|
||||
SetFeeRate(FeePolicy.Format(r));
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string sendPreview = "";
|
||||
|
||||
@@ -74,11 +142,17 @@ public partial class MainWindowViewModel
|
||||
SendPreview = Loc.Tr("msg.amount.invalid");
|
||||
return;
|
||||
}
|
||||
if (!decimal.TryParse(SendFeeRate, out var feeRate) || feeRate <= 0)
|
||||
if (!CoinAmount.TryParseFeeRate(SendFeeRate, out var feeRate))
|
||||
{
|
||||
SendPreview = Loc.Tr("msg.feerate.invalid");
|
||||
return;
|
||||
}
|
||||
if (FeePolicy.IsBelowRelay(feeRate, _feeEstimates?.Relay))
|
||||
{
|
||||
SendPreview = string.Format(Loc.Tr("msg.feerate.belowrelay"),
|
||||
FeePolicy.Format(_feeEstimates!.Relay!.Value));
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingSend = new TransactionFactory(_account).Build(
|
||||
_doc.Cache.Utxos, _lastTransactions, destination, amount, feeRate,
|
||||
@@ -87,7 +161,9 @@ public partial class MainWindowViewModel
|
||||
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
|
||||
$"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
|
||||
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
|
||||
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly"));
|
||||
(_pendingSend.Signed ? "" : Loc.Tr("msg.unsigned.watchonly")) +
|
||||
HighFeeWarning(_pendingSend.Fee.Satoshi,
|
||||
SendAll ? _pendingSend.Transaction.TotalOut.Satoshi : amount);
|
||||
HasPendingSend = _pendingSend.Signed;
|
||||
PendingPsbtBase64 = _pendingSend.Signed ? "" : _pendingSend.Psbt.ToBase64();
|
||||
}
|
||||
@@ -101,6 +177,15 @@ public partial class MainWindowViewModel
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory text when the absolute fee is large against the amount: the estimate comes from
|
||||
/// an untrusted server, so the resulting fee is always shown and flagged, never just followed.
|
||||
/// </summary>
|
||||
private string HighFeeWarning(long feeSats, long amountSats) =>
|
||||
FeePolicy.IsHighFee(feeSats, amountSats)
|
||||
? "\n" + string.Format(Loc.Tr("msg.fee.high"), FeePolicy.HighFeePercent)
|
||||
: "";
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmSend()
|
||||
{
|
||||
|
||||
@@ -215,7 +215,14 @@ public partial class MainWindowViewModel
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||
var connected = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
|
||||
try { await connected.VerifyChainAsync(Profile, ct); }
|
||||
catch
|
||||
{
|
||||
await connected.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
_client = connected;
|
||||
_client.NotificationReceived += OnServerNotification;
|
||||
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
@@ -254,6 +261,8 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
if (lastError is not null)
|
||||
throw lastError;
|
||||
if (_client is { } fresh)
|
||||
_ = RefreshFeeEstimatesAsync(fresh);
|
||||
}
|
||||
|
||||
if (_account is null || _doc is null)
|
||||
@@ -262,6 +271,8 @@ public partial class MainWindowViewModel
|
||||
if (_synchronizer is null)
|
||||
{
|
||||
_synchronizer = new WalletSynchronizer(_account, _client!, _doc.GapLimit);
|
||||
var primary = _client!;
|
||||
_synchronizer.WitnessProvider = _ => ConnectWitnessesAsync(primary);
|
||||
// Reload from disk cache: avoids re-downloading already known transactions
|
||||
// (essential for wallets with thousands of historical txs — prevents -101).
|
||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||
@@ -272,7 +283,7 @@ public partial class MainWindowViewModel
|
||||
_doc.Cache?.NextReceiveIndex ?? 0,
|
||||
_doc.Cache?.NextChangeIndex ?? 0,
|
||||
net,
|
||||
_doc.Cache?.AnchoredUpTo);
|
||||
_doc.Cache?.AnchoredDownTo);
|
||||
_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
|
||||
@@ -281,7 +292,19 @@ public partial class MainWindowViewModel
|
||||
// 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));
|
||||
// A partial snapshot posted to the UI thread can run after the sync's final snapshot was
|
||||
// applied (UI thread busy, or a test that pumps jobs late): it would then overwrite the
|
||||
// verified rows and cache with the older unverified ones. Tag it with the run it
|
||||
// belongs to and drop it once that run has been finalised.
|
||||
_synchronizer.PartialResult += r =>
|
||||
{
|
||||
var run = Volatile.Read(ref _syncRun);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (run == Volatile.Read(ref _syncRun))
|
||||
ApplyPartialResult(r);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
do
|
||||
@@ -290,23 +313,26 @@ public partial class MainWindowViewModel
|
||||
// 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, anchoredUpTo) = await Task.Run(async () =>
|
||||
var (result, rawHex, verifiedAt, blockHeaders, anchoredDownTo) = 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, caches.AnchoredUpTo);
|
||||
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders, caches.AnchoredDownTo);
|
||||
}, ct);
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
var cache = new SyncCache
|
||||
{
|
||||
RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
|
||||
AnchoredUpTo = anchoredUpTo,
|
||||
AnchoredDownTo = anchoredDownTo,
|
||||
};
|
||||
FillDisplayFields(cache, result);
|
||||
// Finalise this run BEFORE the save's await: a stale partial snapshot still queued on the
|
||||
// UI thread would otherwise run during the save and replace _doc.Cache with unverified rows.
|
||||
Interlocked.Increment(ref _syncRun);
|
||||
_doc.Cache = cache;
|
||||
ApplyCache(cache);
|
||||
await WalletStore.SaveAsync(_doc, _walletPath!, _password);
|
||||
ApplyCache(_doc.Cache);
|
||||
_syncFailed = false;
|
||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||
@@ -428,7 +454,14 @@ public partial class MainWindowViewModel
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
temp = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||
var fresh = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
|
||||
try { await fresh.VerifyChainAsync(Profile); }
|
||||
catch
|
||||
{
|
||||
await fresh.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
temp = fresh;
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
@@ -480,6 +513,8 @@ public partial class MainWindowViewModel
|
||||
{
|
||||
CertificatePinMismatchException cert =>
|
||||
string.Format(Loc.Tr("msg.cert.mismatch"), $"{cert.Host}:{cert.Port}"),
|
||||
WrongChainException chain =>
|
||||
string.Format(Loc.Tr("msg.chain.wrong"), $"{chain.Host}:{chain.Port}"),
|
||||
_ => ex.Message,
|
||||
};
|
||||
|
||||
@@ -514,20 +549,50 @@ public partial class MainWindowViewModel
|
||||
try
|
||||
{
|
||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = _synchronizer.ExportCaches(net);
|
||||
var (rawHex, verifiedAt, blockHeaders, anchoredDownTo) = _synchronizer.ExportCaches(net);
|
||||
if (rawHex.Count == 0 && verifiedAt.Count == 0)
|
||||
return;
|
||||
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
|
||||
_doc.Cache.VerifiedAt = verifiedAt;
|
||||
_doc.Cache.BlockHeaders = blockHeaders;
|
||||
_doc.Cache.AnchoredUpTo = anchoredUpTo;
|
||||
_doc.Cache.AnchoredDownTo = anchoredDownTo;
|
||||
WalletStore.Save(_doc, _walletPath, _password);
|
||||
}
|
||||
catch { /* non-fatal: the next full save will recover */ }
|
||||
}
|
||||
|
||||
// Witness servers for cross-checking recent headers (ADR 0042): opened lazily by the
|
||||
// synchronizer, only from the hardcoded bootstrap list, and closed with the primary connection.
|
||||
private readonly List<ElectrumClient> _witnesses = [];
|
||||
private int _syncRun; // bumped each time a sync's final snapshot is applied (see PartialResult)
|
||||
|
||||
private async Task<IReadOnlyList<ElectrumClient>> ConnectWitnessesAsync(ElectrumClient primary)
|
||||
{
|
||||
if (Profile.BootstrapServers.Count == 0)
|
||||
return [];
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
|
||||
var list = await WitnessConnector.ConnectAsync(Profile, Profile.BootstrapServers,
|
||||
primary.Host, primary.Port, UseSsl, pins);
|
||||
lock (_witnesses)
|
||||
_witnesses.AddRange(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private void DisposeWitnesses()
|
||||
{
|
||||
ElectrumClient[] toClose;
|
||||
lock (_witnesses)
|
||||
{
|
||||
toClose = [.. _witnesses];
|
||||
_witnesses.Clear();
|
||||
}
|
||||
foreach (var w in toClose)
|
||||
_ = w.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
DisposeWitnesses();
|
||||
if (_client is { } client)
|
||||
{
|
||||
_client = null;
|
||||
|
||||
@@ -20,13 +20,17 @@ public partial class MainWindowViewModel
|
||||
[RelayCommand]
|
||||
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
||||
|
||||
/// <summary>Test seam: replaces the real GitHub check so tests never touch the network.</summary>
|
||||
internal static System.Func<string, Task<LatestRelease?>> UpdateCheck { get; set; } =
|
||||
version => UpdateChecker.CheckAsync(version);
|
||||
|
||||
/// <summary>
|
||||
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
||||
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
||||
/// </summary>
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
var latest = await UpdateChecker.CheckAsync(AppVersion);
|
||||
var latest = await UpdateCheck(AppVersion);
|
||||
if (latest is null) return;
|
||||
|
||||
UpdateReleaseUrl = latest.HtmlUrl;
|
||||
|
||||
@@ -17,23 +17,21 @@ using PalladiumWallet.Core.Wallet;
|
||||
namespace PalladiumWallet.App.ViewModels;
|
||||
|
||||
/// <summary>Transaction history row for the view.</summary>
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
|
||||
public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true,
|
||||
bool Unanchored = false);
|
||||
|
||||
/// <summary>Address view row with pre-computed keys and derivation path.</summary>
|
||||
/// <summary>
|
||||
/// Address view row: public data and derivation path only. The private key is never held here;
|
||||
/// it is derived on demand when the user reveals it (see <c>RevealedPrivKey</c>).
|
||||
/// </summary>
|
||||
public sealed record AddressRow(
|
||||
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
|
||||
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
|
||||
{
|
||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||
}
|
||||
bool IsChange = false, string PubKey = "", string DerivPath = "", bool HasPrivKey = false);
|
||||
|
||||
/// <summary>Full address data passed to the address detail overlay.</summary>
|
||||
/// <summary>Address data passed to the detail overlay: derivation coordinates, never the key.</summary>
|
||||
public sealed record AddressInfo(
|
||||
Loc Loc,
|
||||
string Address, string DerivPath, string PubKey, string PrivKey)
|
||||
{
|
||||
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
|
||||
}
|
||||
string Address, string DerivPath, string PubKey, bool IsChange, int Index, bool HasPrivKey);
|
||||
|
||||
/// <summary>Address book contact: name + blockchain address.</summary>
|
||||
public sealed record ContactEntry(string Name, string Address);
|
||||
@@ -130,7 +128,10 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
// ---- helpers ----
|
||||
|
||||
private NetKind Net => NetKind.Mainnet;
|
||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
||||
/// <summary>Test seam: lets tests drop the real bootstrap servers so nothing reaches the internet.</summary>
|
||||
internal static ChainProfile? ProfileOverride { get; set; }
|
||||
|
||||
private ChainProfile Profile => ProfileOverride ?? ChainProfiles.For(Net);
|
||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
||||
|
||||
private string Fmt(long sats, bool withLabel = true) =>
|
||||
@@ -266,6 +267,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_walletLock?.Dispose();
|
||||
_walletLock = null;
|
||||
_ = _client?.DisposeAsync().AsTask();
|
||||
DisposeWitnesses();
|
||||
_client = null;
|
||||
_synchronizer = null;
|
||||
_autoReconnect = false;
|
||||
@@ -283,6 +285,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
SelectedContactInList = null;
|
||||
SendToContact = null;
|
||||
SelectedAddressRow = null;
|
||||
AddressInfo = null; // also drops a revealed private key
|
||||
IsWalletOpen = false;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
|
||||
@@ -21,14 +21,14 @@ public sealed class TransactionDetailsViewModel
|
||||
|
||||
public Loc Loc { get; }
|
||||
|
||||
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
|
||||
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit, bool unanchored = false)
|
||||
{
|
||||
Loc = loc;
|
||||
_unit = unit;
|
||||
|
||||
Txid = d.Txid;
|
||||
IsCoinbase = d.IsCoinbase;
|
||||
StatusText = BuildStatus(d, loc);
|
||||
StatusText = BuildStatus(d, loc) + (unanchored ? $" — {loc["history.unanchored"]}" : "");
|
||||
DateText = d.BlockTime is { } t
|
||||
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
|
||||
: loc["tx.mempool"];
|
||||
|
||||
@@ -402,6 +402,10 @@
|
||||
<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}"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored]}"
|
||||
Foreground="#FB923C" FontSize="11" Margin="6,0,0,0"
|
||||
ToolTip.Tip="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored.tip]}"
|
||||
IsVisible="{Binding Unanchored}"/>
|
||||
</Grid>
|
||||
<!-- Mobile: vertical card -->
|
||||
<StackPanel Spacing="2"
|
||||
@@ -414,6 +418,9 @@
|
||||
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||
Foreground="#FCD34D" FontSize="11"
|
||||
IsVisible="{Binding !Verified}"/>
|
||||
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored]}"
|
||||
Foreground="#FB923C" FontSize="11"
|
||||
IsVisible="{Binding Unanchored}"/>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
@@ -504,6 +511,16 @@
|
||||
MaxWidth="120" HorizontalAlignment="Left"
|
||||
FontFamily="monospace"/>
|
||||
</Grid>
|
||||
<!-- Fee presets from the server estimate (shown once the server answered) -->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasFeeEstimates}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="{Binding Loc[send.fee.fast]}" Command="{Binding UseFeePresetCommand}" CommandParameter="fast"/>
|
||||
<Button Content="{Binding Loc[send.fee.normal]}" Command="{Binding UseFeePresetCommand}" CommandParameter="normal"/>
|
||||
<Button Content="{Binding Loc[send.fee.economy]}" Command="{Binding UseFeePresetCommand}" CommandParameter="economy"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding FeeEstimateHint}" FontSize="11" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -626,6 +643,16 @@
|
||||
<TextBox Text="{Binding SendFeeRate}"
|
||||
FontFamily="monospace" FontSize="15"
|
||||
MinHeight="48" HorizontalAlignment="Stretch"/>
|
||||
<!-- Fee presets from the server estimate (shown once the server answered) -->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasFeeEstimates}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="{Binding Loc[send.fee.fast]}" Command="{Binding UseFeePresetCommand}" CommandParameter="fast"/>
|
||||
<Button Content="{Binding Loc[send.fee.normal]}" Command="{Binding UseFeePresetCommand}" CommandParameter="normal"/>
|
||||
<Button Content="{Binding Loc[send.fee.economy]}" Command="{Binding UseFeePresetCommand}" CommandParameter="economy"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding FeeEstimateHint}" FontSize="11" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -1019,7 +1046,7 @@
|
||||
<!-- Revealed: key text + hide button -->
|
||||
<StackPanel IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsAddressPrivKeyRevealed}"
|
||||
Spacing="6">
|
||||
<SelectableTextBlock Text="{Binding PrivKey}"
|
||||
<SelectableTextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).RevealedPrivKey}"
|
||||
FontFamily="monospace" FontSize="12"
|
||||
Foreground="{DynamicResource DangerBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
@@ -149,7 +149,7 @@ public partial class MainView : UserControl
|
||||
|
||||
private async void OnOpenBugReportClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
const string issueUrl = "https://github.com/davide3011/PalladiumWallet/issues/new?template=bug_report.yml";
|
||||
const string issueUrl = "https://github.com/palladium-coin/palladium-wallet/issues/new?template=bug_report.yml";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(issueUrl));
|
||||
@@ -157,7 +157,7 @@ public partial class MainView : UserControl
|
||||
|
||||
private async void OnOpenUserGuideClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
const string userGuideUrl = "https://github.com/davide3011/PalladiumWallet/blob/main/USERGUIDE.md";
|
||||
const string userGuideUrl = "https://github.com/palladium-coin/palladium-wallet/blob/main/USERGUIDE.md";
|
||||
var launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||
if (launcher is not null)
|
||||
await launcher.LaunchUriAsync(new Uri(userGuideUrl));
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Wallet-password handling for the CLI (ADR 0038). A password never has to be given as a
|
||||
/// command-line argument (visible in the process list and shell history): it can come from an
|
||||
/// environment variable, from stdin, or from a no-echo terminal prompt. Plaintext storage is an
|
||||
/// explicit opt-in (<c>--no-encryption</c>), never the result of a forgotten flag.
|
||||
/// </summary>
|
||||
internal static class CliPassword
|
||||
{
|
||||
/// <summary>Password of the wallet opened by this process, reused when the wallet is saved back.</summary>
|
||||
internal static string? Current { get; private set; }
|
||||
|
||||
private static bool Interactive => !Console.IsInputRedirected;
|
||||
|
||||
/// <summary>Password to decrypt <paramref name="path"/>; null when the file is not encrypted.</summary>
|
||||
internal static string? ForOpening(string[] o, string path)
|
||||
{
|
||||
if (!EncryptedFile.IsEncrypted(File.ReadAllText(path)))
|
||||
return Current = null;
|
||||
return Current = Explicit(o) ?? Prompt("Wallet password: ")
|
||||
?? throw new WalletSpendException(
|
||||
"The wallet is encrypted: supply the password with --password-env NAME, " +
|
||||
"--password-stdin, or run in a terminal to be prompted.");
|
||||
}
|
||||
|
||||
/// <summary>Password to encrypt a new wallet; null only with <c>--no-encryption</c>.</summary>
|
||||
internal static string? ForNewWallet(string[] o)
|
||||
{
|
||||
var noEncryption = o.Contains("--no-encryption");
|
||||
if (noEncryption)
|
||||
{
|
||||
if (Opt(o, "--password") is not null || Opt(o, "--password-env") is not null
|
||||
|| o.Contains("--password-stdin"))
|
||||
throw new WalletSpendException("--no-encryption cannot be combined with a password option.");
|
||||
Console.Error.WriteLine("WARNING: --no-encryption: the wallet will be stored unencrypted on disk (§17).");
|
||||
return Current = null;
|
||||
}
|
||||
if (Explicit(o) is { } given)
|
||||
return Current = given;
|
||||
var first = Prompt("New wallet password: ")
|
||||
?? throw new WalletSpendException(
|
||||
"A password is required: use --password-env NAME, --password-stdin, run in a terminal " +
|
||||
"to be prompted, or pass --no-encryption to store the wallet unencrypted.");
|
||||
if (first.Length == 0)
|
||||
throw new WalletSpendException("The password cannot be empty (use --no-encryption for no encryption).");
|
||||
if (Prompt("Repeat password: ") != first)
|
||||
throw new WalletSpendException("The passwords do not match.");
|
||||
return Current = first;
|
||||
}
|
||||
|
||||
/// <summary>Password from --password, --password-env or --password-stdin; null if none given.</summary>
|
||||
private static string? Explicit(string[] o)
|
||||
{
|
||||
var flag = Opt(o, "--password");
|
||||
var env = Opt(o, "--password-env");
|
||||
var stdin = o.Contains("--password-stdin");
|
||||
if ((flag is not null ? 1 : 0) + (env is not null ? 1 : 0) + (stdin ? 1 : 0) > 1)
|
||||
throw new WalletSpendException("Use only one of --password, --password-env, --password-stdin.");
|
||||
|
||||
if (flag is not null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"WARNING: --password is visible in the process list and the shell history; " +
|
||||
"prefer --password-env or --password-stdin.");
|
||||
return flag;
|
||||
}
|
||||
if (env is not null)
|
||||
return Environment.GetEnvironmentVariable(env) is { Length: > 0 } v
|
||||
? v
|
||||
: throw new WalletSpendException($"Environment variable {env} is not set or empty.");
|
||||
if (stdin)
|
||||
return Console.In.ReadLine() is { Length: > 0 } line
|
||||
? line
|
||||
: throw new WalletSpendException("--password-stdin: no password received on stdin.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>No-echo prompt on the terminal; null when there is no terminal (never hangs).</summary>
|
||||
private static string? Prompt(string text)
|
||||
{
|
||||
if (!Interactive)
|
||||
return null;
|
||||
Console.Error.Write(text);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
while (true)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter) break;
|
||||
if (key.Key == ConsoleKey.Backspace) { if (sb.Length > 0) sb.Length--; }
|
||||
else if (!char.IsControl(key.KeyChar)) sb.Append(key.KeyChar);
|
||||
}
|
||||
Console.Error.WriteLine();
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? Opt(string[] options, string name)
|
||||
{
|
||||
var i = Array.IndexOf(options, name);
|
||||
return i >= 0 && i + 1 < options.Length ? options[i + 1] : null;
|
||||
}
|
||||
}
|
||||
+47
-20
@@ -28,7 +28,8 @@ try
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
|
||||
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
|
||||
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException
|
||||
or WrongChainException)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
@@ -91,7 +92,7 @@ static int RestoreXpub(string xpubText, string[] o)
|
||||
AccountXpub = account.ToSlip132(),
|
||||
};
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
WalletStore.Save(doc, path, CliPassword.ForNewWallet(o));
|
||||
Console.WriteLine($"Watch-only wallet saved to {path}");
|
||||
return 0;
|
||||
}
|
||||
@@ -116,7 +117,7 @@ static int RestoreAddress(string addrsText, string[] o)
|
||||
return 1;
|
||||
}
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
WalletStore.Save(doc, path, CliPassword.ForNewWallet(o));
|
||||
Console.WriteLine($"Watch-only wallet saved to {path} ({addresses.Length} address(es), cannot sign)");
|
||||
return 0;
|
||||
}
|
||||
@@ -158,6 +159,13 @@ static async Task<int> Sync(string[] o)
|
||||
await using var client = await Connect(o, account.Profile);
|
||||
|
||||
var sync = new WalletSynchronizer(account, client, doc.GapLimit);
|
||||
IReadOnlyList<ElectrumClient> witnesses = [];
|
||||
sync.WitnessProvider = async ct =>
|
||||
{
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(account.Profile.Kind));
|
||||
return witnesses = await WitnessConnector.ConnectAsync(account.Profile,
|
||||
account.Profile.BootstrapServers, client.Host, client.Port, client.UseSsl, pins, ct: ct);
|
||||
};
|
||||
sync.Progress += msg => Console.WriteLine($" {msg}");
|
||||
var net = PalladiumNetworks.For(account.Profile.Kind);
|
||||
sync.PreloadCaches(
|
||||
@@ -167,10 +175,16 @@ static async Task<int> Sync(string[] o)
|
||||
doc.Cache?.NextReceiveIndex ?? 0,
|
||||
doc.Cache?.NextChangeIndex ?? 0,
|
||||
net,
|
||||
doc.Cache?.AnchoredUpTo);
|
||||
var result = await sync.SyncOnceAsync();
|
||||
doc.Cache?.AnchoredDownTo);
|
||||
SyncResult result;
|
||||
try { result = await sync.SyncOnceAsync(); }
|
||||
finally
|
||||
{
|
||||
foreach (var w in witnesses)
|
||||
await w.DisposeAsync();
|
||||
}
|
||||
|
||||
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
|
||||
var (rawHex, verifiedAt, blockHeaders, anchoredDownTo) = sync.ExportCaches(net);
|
||||
doc.Cache = new SyncCache
|
||||
{
|
||||
TipHeight = result.TipHeight,
|
||||
@@ -187,9 +201,9 @@ static async Task<int> Sync(string[] o)
|
||||
RawTxHex = rawHex,
|
||||
VerifiedAt = verifiedAt,
|
||||
BlockHeaders = blockHeaders,
|
||||
AnchoredUpTo = anchoredUpTo,
|
||||
AnchoredDownTo = anchoredDownTo,
|
||||
};
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
WalletStore.Save(doc, path, CliPassword.Current);
|
||||
|
||||
Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
||||
@@ -217,7 +231,9 @@ static async Task<int> Send(string[] o)
|
||||
long amount = 0;
|
||||
if (!sendAll && !CoinAmount.TryParseCoins(Opt(o, "--amount") ?? "", out amount))
|
||||
throw new WalletSpendException("--amount missing or invalid (or use --all).");
|
||||
var feeRate = decimal.TryParse(Opt(o, "--feerate"), out var fr) ? fr : 1m;
|
||||
var feeRate = 1m;
|
||||
if (Opt(o, "--feerate") is { } feeText && !CoinAmount.TryParseFeeRate(feeText, out feeRate))
|
||||
throw new WalletSpendException($"--feerate invalid (expected sat/vB in (0, {CoinAmount.MaxFeeRate}]).");
|
||||
|
||||
// Source transactions of the UTXOs: needed for amounts and signing.
|
||||
await using var client = await Connect(o, account.Profile);
|
||||
@@ -319,11 +335,8 @@ static int SaveWallet(string words, string[] o)
|
||||
var (doc, account) = WalletLoader.NewFromMnemonic(
|
||||
words, Opt(o, "--passphrase"), Kind(o), profile,
|
||||
Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null);
|
||||
var password = Opt(o, "--password");
|
||||
if (string.IsNullOrEmpty(password))
|
||||
Console.WriteLine("WARNING: no --password, the seed will be stored in plaintext on disk (§17).");
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, password);
|
||||
WalletStore.Save(doc, path, CliPassword.ForNewWallet(o));
|
||||
Console.WriteLine($"Wallet saved to {path}");
|
||||
Console.WriteLine($"First address: {account.GetReceiveAddress(0)}");
|
||||
return 0;
|
||||
@@ -334,7 +347,7 @@ static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o)
|
||||
var path = WalletPath(o, Profile(o));
|
||||
if (!WalletStore.Exists(path))
|
||||
throw new WalletSpendException($"No wallet at {path}: use 'create' or 'restore'.");
|
||||
var doc = WalletStore.Load(path, Opt(o, "--password"));
|
||||
var doc = WalletStore.Load(path, CliPassword.ForOpening(o, path));
|
||||
return (doc, WalletLoader.ToAccount(doc), path);
|
||||
}
|
||||
|
||||
@@ -361,7 +374,14 @@ static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
|
||||
|
||||
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
|
||||
Console.WriteLine($"Connecting to {host}:{port}{(useSsl ? " (TLS)" : "")}…");
|
||||
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
|
||||
var client = await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
|
||||
try { await client.VerifyChainAsync(profile); }
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
static string? Opt(string[] options, string name)
|
||||
@@ -377,14 +397,21 @@ static int Usage()
|
||||
|
||||
Wallet:
|
||||
create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest]
|
||||
[--passphrase W] [--password P] [--file PATH]
|
||||
[--passphrase W] [--no-encryption] [--file PATH]
|
||||
restore "<mnemonic>" [same options as create] [--path m/...]
|
||||
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
|
||||
restore-address <addr1,addr2,...> [--net ...] [--password P] [--file PATH] (watch-only, no keys)
|
||||
info [--net ...] [--password P] [--file PATH]
|
||||
restore-xpub <slip132 xpub> [--net ...] [--file PATH] (watch-only)
|
||||
restore-address <addr1,addr2,...> [--net ...] [--file PATH] (watch-only, no keys)
|
||||
info [--net ...] [--file PATH]
|
||||
|
||||
Wallet password (create/restore ask for one, encrypted wallets need it to open):
|
||||
--password-env NAME read it from an environment variable
|
||||
--password-stdin read it from the first line of stdin
|
||||
(neither) prompt on the terminal, no echo; fails if there is no terminal
|
||||
--no-encryption create/restore only: store the wallet UNENCRYPTED (explicit opt-in)
|
||||
--password P still accepted but unsafe (shell history, process list): warns
|
||||
|
||||
Network (indexing server; without --server the first known server is used):
|
||||
sync [--server host[:port]] [--ssl] [--net ...] [--password P] [--file PATH]
|
||||
sync [--server host[:port]] [--ssl] [--net ...] [--file PATH]
|
||||
send --to ADDRESS (--amount X | --all) [--feerate sat/vB]
|
||||
[--server host[:port]] [--ssl] [--broadcast] [...]
|
||||
servers [--discover] [--server host[:port]] [--ssl] [--net ...]
|
||||
|
||||
@@ -140,4 +140,13 @@ public sealed record ChainProfile
|
||||
|
||||
/// <summary>Hardcoded checkpoints, updated at every release (§7.3).</summary>
|
||||
public required IReadOnlyList<Checkpoint> Checkpoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True for a block above the newest checkpoint of a profile that has checkpoints: its header
|
||||
/// cannot be anchored (ADR 0040), so only the server-supplied header and the Merkle proof back
|
||||
/// it. False for confirmed history the anchoring covers, and for profiles without any
|
||||
/// checkpoint (testnet/regtest), where nothing is anchored by design and the notion does not apply.
|
||||
/// </summary>
|
||||
public bool IsAboveNewestCheckpoint(int height) =>
|
||||
height > 0 && Checkpoints.Count > 0 && height > Checkpoints.Max(c => c.Height);
|
||||
}
|
||||
@@ -41,6 +41,27 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
// concurrent requests before throttling — no single constant is right for
|
||||
// every server, so callers may raise/lower it instead of recompiling.
|
||||
public const int DefaultMaxInFlight = 32;
|
||||
|
||||
// Every request is bounded: a server that stops answering without closing the socket
|
||||
// (half-open TCP, Android doze, a stalled or hostile server) would otherwise leave the
|
||||
// caller waiting forever. The timer starts once the request holds an in-flight slot, so
|
||||
// queueing behind the cap does not count. On expiry the request fails with
|
||||
// TimeoutException and the whole connection is marked dead (pending requests fail,
|
||||
// Disconnected is raised): after a missed answer the socket cannot be trusted, and a
|
||||
// late reply is dropped because the request is no longer pending.
|
||||
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan DefaultHandshakeTimeout = TimeSpan.FromSeconds(10);
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
|
||||
// Cap on a single newline-delimited reply. Without it a hostile or broken server could
|
||||
// stream an endless line and grow the read buffer until the process dies (fatal on
|
||||
// Android). The default is far above the largest legitimate replies (header batches of a
|
||||
// few hundred KB, a large transaction up to ~2 MB of hex, a very active address history
|
||||
// of a few MB). It is checked as the buffer grows, before a newline arrives; on excess the
|
||||
// read loop fails with InvalidDataException, which kills the connection like any
|
||||
// protocol error (pending requests fail, Disconnected is raised).
|
||||
public const int DefaultMaxLineBytes = 16 * 1024 * 1024;
|
||||
private readonly int _maxLineBytes;
|
||||
private readonly SemaphoreSlim _inFlight;
|
||||
|
||||
private long _nextId;
|
||||
@@ -54,8 +75,10 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
public event Action<Exception?>? Disconnected;
|
||||
|
||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl,
|
||||
int maxInFlight)
|
||||
int maxInFlight, TimeSpan requestTimeout, int maxLineBytes)
|
||||
{
|
||||
_requestTimeout = requestTimeout;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_tcp = tcp;
|
||||
_stream = stream;
|
||||
Host = host;
|
||||
@@ -68,7 +91,8 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
|
||||
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
||||
CertificatePinStore? pins = null, CancellationToken ct = default,
|
||||
int maxInFlight = DefaultMaxInFlight)
|
||||
int maxInFlight = DefaultMaxInFlight, TimeSpan? requestTimeout = null,
|
||||
TimeSpan? handshakeTimeout = null, int maxLineBytes = DefaultMaxLineBytes)
|
||||
{
|
||||
var tcp = new TcpClient { NoDelay = true };
|
||||
try
|
||||
@@ -97,8 +121,10 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
stream = ssl;
|
||||
}
|
||||
|
||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl, maxInFlight);
|
||||
await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion);
|
||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl, maxInFlight,
|
||||
requestTimeout ?? DefaultRequestTimeout, maxLineBytes);
|
||||
await client.RequestCoreAsync(handshakeTimeout ?? DefaultHandshakeTimeout,
|
||||
"server.version", ct, [ClientName, ProtocolVersion]);
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
@@ -108,8 +134,12 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
||||
params object?[] parameters)
|
||||
public Task<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
||||
params object?[] parameters) =>
|
||||
RequestCoreAsync(_requestTimeout, method, ct, parameters);
|
||||
|
||||
private async Task<JsonElement> RequestCoreAsync(TimeSpan timeout, string method,
|
||||
CancellationToken ct, object?[] parameters)
|
||||
{
|
||||
// Gate before going in-flight: beyond MaxInFlight pending requests
|
||||
// we wait for a response to free a slot, instead of flooding the server.
|
||||
@@ -120,6 +150,15 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = tcs;
|
||||
|
||||
// The read loop cancels _cts before failing the pending requests, so a request that
|
||||
// registers after that point lands here instead of waiting for a response that can
|
||||
// never arrive (the loop is gone).
|
||||
if (_cts.IsCancellationRequested)
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
throw new IOException("Connection to the server closed.");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
@@ -135,6 +174,15 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
_pending.TryRemove(id, out _);
|
||||
tcs.TrySetCanceled(ct);
|
||||
});
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(timeout);
|
||||
await using var timeoutRegistration = timeoutCts.Token.Register(() =>
|
||||
{
|
||||
if (!_pending.TryRemove(id, out _)) return;
|
||||
tcs.TrySetException(new TimeoutException(
|
||||
$"No response to '{method}' from {Host}:{Port} within {timeout.TotalSeconds:0.#}s."));
|
||||
try { _cts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
});
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
@@ -192,8 +240,15 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
while (TrySliceLine(ref buffer, out var line))
|
||||
{
|
||||
if (line.Length > _maxLineBytes)
|
||||
throw TooLong();
|
||||
if (!line.IsEmpty)
|
||||
DispatchLine(line);
|
||||
}
|
||||
// What is left has no newline yet: it is the start of a line still arriving.
|
||||
if (buffer.Length > _maxLineBytes)
|
||||
throw TooLong();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -212,6 +267,9 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
finally
|
||||
{
|
||||
await pipe.CompleteAsync();
|
||||
// Mark the connection dead first (IsConnected, RequestAsync, write loop) so nothing
|
||||
// can register a request after the pending ones are failed below.
|
||||
try { _cts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
foreach (var (_, tcs) in _pending)
|
||||
tcs.TrySetException(failure ?? new IOException("Connection to the server closed."));
|
||||
_pending.Clear();
|
||||
@@ -219,6 +277,9 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private InvalidDataException TooLong() =>
|
||||
new($"Server reply from {Host}:{Port} exceeds {_maxLineBytes} bytes without a newline.");
|
||||
|
||||
private static bool TrySliceLine(ref ReadOnlySequence<byte> buffer,
|
||||
out ReadOnlySequence<byte> line)
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed record LatestRelease(string Tag, string HtmlUrl);
|
||||
/// </summary>
|
||||
public static class UpdateChecker
|
||||
{
|
||||
private const string ReleasesApiUrl = "https://api.github.com/repos/davide3011/PalladiumWallet/releases/latest";
|
||||
private const string ReleasesApiUrl = "https://api.github.com/repos/palladium-coin/palladium-wallet/releases/latest";
|
||||
|
||||
private sealed class GitHubReleaseResponse
|
||||
{
|
||||
@@ -54,7 +54,7 @@ public static class UpdateChecker
|
||||
return null;
|
||||
if (remote <= local) return null;
|
||||
|
||||
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/davide3011/PalladiumWallet/releases/tag/{tag}");
|
||||
return new LatestRelease(tag, release.HtmlUrl ?? $"https://github.com/palladium-coin/palladium-wallet/releases/tag/{tag}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
/// <summary>The server is serving a different chain than the wallet's network profile.</summary>
|
||||
public sealed class WrongChainException(string host, int port, string reason) : Exception(
|
||||
$"The server {host}:{port} is not on the expected chain: {reason}")
|
||||
{
|
||||
public string Host { get; } = host;
|
||||
public int Port { get; } = port;
|
||||
public string Reason { get; } = reason;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain-identity check run right after the handshake (ADR 0036). Mainnet reuses Bitcoin's
|
||||
/// genesis hash, so the genesis alone cannot tell Palladium from Bitcoin: the check also pins
|
||||
/// the header at the highest hardcoded checkpoint not above the server's tip.
|
||||
/// </summary>
|
||||
public static class ChainVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Throws <see cref="WrongChainException"/> if the server reports another genesis, or serves
|
||||
/// at the highest checkpoint height (≤ its tip) a header other than the checkpoint. A tip
|
||||
/// below the first checkpoint (young testnet, server still syncing) has nothing to compare
|
||||
/// and is accepted on the genesis check alone.
|
||||
/// </summary>
|
||||
public static async Task VerifyChainAsync(this ElectrumClient client, ChainProfile profile,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
string? genesis = null;
|
||||
try
|
||||
{
|
||||
var features = await client.RequestAsync("server.features", ct);
|
||||
if (features.ValueKind == JsonValueKind.Object
|
||||
&& features.TryGetProperty("genesis_hash", out var g) && g.ValueKind == JsonValueKind.String)
|
||||
genesis = g.GetString();
|
||||
}
|
||||
catch (ElectrumServerException) { /* reported below as a missing genesis_hash */ }
|
||||
|
||||
if (genesis is null)
|
||||
throw new WrongChainException(client.Host, client.Port, "it does not report a genesis_hash");
|
||||
if (!string.Equals(genesis, profile.GenesisHash, StringComparison.OrdinalIgnoreCase))
|
||||
throw new WrongChainException(client.Host, client.Port,
|
||||
$"genesis {genesis} (expected {profile.GenesisHash})");
|
||||
|
||||
var tip = await client.SubscribeHeadersAsync(ct);
|
||||
Checkpoint? best = null;
|
||||
foreach (var cp in profile.Checkpoints)
|
||||
if (cp.Height <= tip.Height && (best is null || cp.Height > best.Value.Height))
|
||||
best = cp;
|
||||
if (best is not { } anchor)
|
||||
return;
|
||||
|
||||
BlockHeaderInfo header;
|
||||
try
|
||||
{
|
||||
header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(anchor.Height, ct));
|
||||
}
|
||||
catch (Exception ex) when (ex is ElectrumServerException or ArgumentException or FormatException
|
||||
or JsonException or InvalidOperationException)
|
||||
{
|
||||
throw new WrongChainException(client.Host, client.Port,
|
||||
$"no valid header at checkpoint height {anchor.Height} ({ex.Message})");
|
||||
}
|
||||
if (!header.MatchesCheckpoint(anchor))
|
||||
throw new WrongChainException(client.Host, client.Port,
|
||||
$"header at height {anchor.Height} is {header.Hash} (checkpoint {anchor.BlockHash})");
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,24 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
|
||||
public event Action<string>? Progress;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the witness servers used to cross-check headers of blocks above the newest
|
||||
/// checkpoint, which cannot be anchored (ADR 0042). Called at most once, lazily, the first
|
||||
/// time such a header is needed; null or an empty result means no cross-check.
|
||||
/// </summary>
|
||||
public Func<CancellationToken, Task<IReadOnlyList<ElectrumClient>>>? WitnessProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Below this depth (tip − height) a disagreement between servers is treated as a possible
|
||||
/// reorganisation in progress, not as a forgery: the proof is left unverified until the next sync.
|
||||
/// </summary>
|
||||
public const int CrossCheckMinDepth = 3;
|
||||
|
||||
private readonly object _witnessLock = new();
|
||||
private Task<IReadOnlyList<ElectrumClient>>? _witnesses;
|
||||
private readonly ConcurrentDictionary<int, Task> _crossChecks = new();
|
||||
private int _tipHeight;
|
||||
|
||||
/// <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"/>/
|
||||
@@ -88,11 +106,13 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||
|
||||
// checkpoint height -> highest height already proven to hash-chain back to it.
|
||||
// Persisted across sessions (see ExportCaches/PreloadCaches): without it, every restart
|
||||
// re-walks and re-verifies the whole header chain from the checkpoint even though the
|
||||
// header bytes themselves are cached, which dominates reconnect time on large wallets.
|
||||
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||
// checkpoint height -> lowest height already proven to hash-chain UP to that checkpoint.
|
||||
// Each anchored header must reach a hardcoded hash above it: SHA-256 preimage resistance
|
||||
// is what makes it unforgeable (the opposite direction, from a checkpoint up to the header,
|
||||
// proves nothing when PoW is skipped, since anyone can build such a chain). Persisted across
|
||||
// sessions (see ExportCaches/PreloadCaches): without it, every restart re-walks and
|
||||
// re-verifies the header chain even though the header bytes themselves are cached.
|
||||
private readonly ConcurrentDictionary<int, int> _anchoredDownTo = new();
|
||||
|
||||
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
|
||||
// being verified) would otherwise race on overlapping ranges and issue duplicate range
|
||||
@@ -121,7 +141,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
int knownReceiveIndex,
|
||||
int knownChangeIndex,
|
||||
Network network,
|
||||
Dictionary<int, int>? anchoredUpTo = null)
|
||||
Dictionary<int, int>? anchoredDownTo = null)
|
||||
{
|
||||
foreach (var (txid, hex) in rawTxHex)
|
||||
{
|
||||
@@ -130,21 +150,27 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
// filter), so every preloaded entry is safe to mark confirmed too.
|
||||
_confirmedTxids.TryAdd(txid, 0);
|
||||
}
|
||||
foreach (var (txid, height) in verifiedAt)
|
||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||
_verifiedAtHeight[txid] = height;
|
||||
// A cache written before the anchoring direction was fixed (ADR 0040) has no
|
||||
// anchoredDownTo: its "verified" claims rested on a forgeable check, so they are not
|
||||
// trusted and every proof is re-verified (the cached headers are reused, not refetched).
|
||||
if (anchoredDownTo is not null)
|
||||
foreach (var (txid, height) in verifiedAt)
|
||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||
_verifiedAtHeight[txid] = height;
|
||||
if (blockHeaders is not null)
|
||||
foreach (var (height, hex) in blockHeaders)
|
||||
_headerFetches.TryAdd(height, Task.FromResult(hex));
|
||||
// Only trust a preloaded anchor up to a height whose header is also cached: if the
|
||||
// Only trust a preloaded anchor down to a height whose header is also cached: if the
|
||||
// header cache was cleared/corrupted independently, re-deriving the chain-of-hashes
|
||||
// check on next use (AnchorToCheckpointAsync re-fetches what's missing) is safer than
|
||||
// trusting a stale "already validated" claim against headers that may no longer match.
|
||||
if (anchoredUpTo is not null)
|
||||
foreach (var (checkpointHeight, upToHeight) in anchoredUpTo)
|
||||
if (_headerFetches.ContainsKey(upToHeight))
|
||||
_anchoredUpTo.AddOrUpdate(checkpointHeight, upToHeight,
|
||||
(_, existing) => Math.Max(existing, upToHeight));
|
||||
if (anchoredDownTo is not null)
|
||||
foreach (var (checkpointHeight, downToHeight) in anchoredDownTo)
|
||||
if (downToHeight <= checkpointHeight
|
||||
&& _headerFetches.ContainsKey(downToHeight)
|
||||
&& _headerFetches.ContainsKey(checkpointHeight))
|
||||
_anchoredDownTo.AddOrUpdate(checkpointHeight, downToHeight,
|
||||
(_, existing) => Math.Min(existing, downToHeight));
|
||||
_knownReceiveIndex = knownReceiveIndex;
|
||||
_knownChangeIndex = knownChangeIndex;
|
||||
}
|
||||
@@ -157,7 +183,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
public (Dictionary<string, string> RawTxHex,
|
||||
Dictionary<string, int> VerifiedAt,
|
||||
Dictionary<int, string> BlockHeaders,
|
||||
Dictionary<int, int> AnchoredUpTo)
|
||||
Dictionary<int, int> AnchoredDownTo)
|
||||
ExportCaches(Network network)
|
||||
{
|
||||
var rawHex = _confirmedTxids.Keys
|
||||
@@ -172,12 +198,13 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
headers[height] = task.Result;
|
||||
|
||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers,
|
||||
new Dictionary<int, int>(_anchoredUpTo));
|
||||
new Dictionary<int, int>(_anchoredDownTo));
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tip = await client.SubscribeHeadersAsync(ct);
|
||||
_tipHeight = tip.Height;
|
||||
Progress?.Invoke($"chain tip: {tip.Height}");
|
||||
|
||||
// 1-2. Address scanning.
|
||||
@@ -287,7 +314,13 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
// 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);
|
||||
try { await AnchorToCheckpointAsync(height, ct); }
|
||||
catch (CrossCheckInconclusiveException)
|
||||
{
|
||||
// A recent block on which the servers disagree: maybe a reorganisation in progress.
|
||||
// Leave this proof unverified (not spendable) and decide again at the next sync.
|
||||
return;
|
||||
}
|
||||
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
|
||||
var header = BlockHeaderInfo.Parse(headerHex);
|
||||
var proof = await proofTask;
|
||||
@@ -444,32 +477,105 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
private async Task AnchorToCheckpointAsync(int height, CancellationToken ct)
|
||||
{
|
||||
var profile = account.Profile;
|
||||
Checkpoint? checkpoint = null;
|
||||
// The checkpoint ABOVE the height (lowest one at or above it): the header must hash-chain
|
||||
// up to it. Above the last checkpoint there is nothing to anchor to — a documented limit,
|
||||
// narrowed by adding a recent checkpoint at every release (SECURITY.md).
|
||||
Checkpoint? upper = null;
|
||||
foreach (var c in profile.Checkpoints)
|
||||
if (c.Height <= height && (checkpoint is not { } best || c.Height > best.Height))
|
||||
checkpoint = c;
|
||||
if (checkpoint is not { } cp)
|
||||
if (c.Height >= height && (upper is not { } best || c.Height < best.Height))
|
||||
upper = c;
|
||||
if (upper is not { } cp)
|
||||
{
|
||||
await CrossCheckAsync(height, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
_anchoredDownTo.TryGetValue(cp.Height, out var anchoredFrom);
|
||||
var alreadyAnchored = _anchoredDownTo.ContainsKey(cp.Height);
|
||||
if (alreadyAnchored && anchoredFrom <= height)
|
||||
return;
|
||||
|
||||
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
|
||||
return;
|
||||
// Headers [height .. top]: top is the checkpoint itself, or the lowest height already
|
||||
// proven to reach it (its header is trusted, so only the new segment is walked).
|
||||
var top = alreadyAnchored ? anchoredFrom : cp.Height;
|
||||
await FetchHeaderRangeAsync(height, top, ct);
|
||||
|
||||
await FetchHeaderRangeAsync(cp.Height, height, ct);
|
||||
|
||||
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||
var headers = Enumerable.Range(height, top - height + 1)
|
||||
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
|
||||
.ToArray();
|
||||
|
||||
if (!headers[0].MatchesCheckpoint(cp))
|
||||
if (!alreadyAnchored && !headers[^1].MatchesCheckpoint(cp))
|
||||
throw new SpvVerificationException(
|
||||
$"Header at checkpoint height {cp.Height} does not match the hardcoded hash: server is not trustworthy.");
|
||||
|
||||
for (var i = 1; i < headers.Length; i++)
|
||||
if (!headers[i].IsValidChild(headers[i - 1].Hash, profile))
|
||||
throw new SpvVerificationException(
|
||||
$"Broken header chain at height {cp.Height + i}: server is not trustworthy.");
|
||||
$"Broken header chain at height {height + i}: server is not trustworthy.");
|
||||
|
||||
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
|
||||
_anchoredDownTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Min(existing, height));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For a block above the newest checkpoint: ask the witness servers for the same header and
|
||||
/// require the same bytes (ADR 0042). A witness that is unreachable or does not know the
|
||||
/// block is skipped; no witness at all leaves only the Merkle proof, as before. A
|
||||
/// disagreement at a safe depth is a forgery or a fork: the sync aborts. At a shallow depth it
|
||||
/// may be a reorganisation, so it is only inconclusive.
|
||||
/// </summary>
|
||||
private async Task CrossCheckAsync(int height, CancellationToken ct)
|
||||
{
|
||||
if (WitnessProvider is null)
|
||||
return;
|
||||
var check = _crossChecks.GetOrAdd(height, h => CrossCheckCoreAsync(h, ct));
|
||||
try
|
||||
{
|
||||
await check;
|
||||
}
|
||||
catch (Exception ex) when (ex is CrossCheckInconclusiveException or OperationCanceledException)
|
||||
{
|
||||
_crossChecks.TryRemove(height, out _); // retry at the next sync / with another token
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CrossCheckCoreAsync(int height, CancellationToken ct)
|
||||
{
|
||||
Task<IReadOnlyList<ElectrumClient>> witnessesTask;
|
||||
lock (_witnessLock)
|
||||
witnessesTask = _witnesses ??= Task.Run(() => WitnessProvider!(CancellationToken.None));
|
||||
IReadOnlyList<ElectrumClient> witnesses;
|
||||
try
|
||||
{
|
||||
witnesses = await witnessesTask;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return; // best effort: a failure to open witnesses leaves only the Merkle proof, as before
|
||||
}
|
||||
if (witnesses.Count == 0)
|
||||
return;
|
||||
|
||||
var ours = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
|
||||
foreach (var witness in witnesses)
|
||||
{
|
||||
string theirs;
|
||||
try
|
||||
{
|
||||
theirs = await witness.GetBlockHeaderAsync(height, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is ElectrumServerException or IOException or TimeoutException
|
||||
or InvalidOperationException or ObjectDisposedException)
|
||||
{
|
||||
continue; // this witness cannot say: skip it
|
||||
}
|
||||
if (string.Equals(theirs, ours, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (_tipHeight - height < CrossCheckMinDepth)
|
||||
throw new CrossCheckInconclusiveException();
|
||||
throw new SpvVerificationException(
|
||||
$"Servers disagree about the header at height {height}: server is not trustworthy.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -633,3 +739,6 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
|
||||
/// <summary>SPV verification failed: server data contradicts the proofs (§17).</summary>
|
||||
public sealed class SpvVerificationException(string message) : Exception(message);
|
||||
|
||||
/// <summary>A recent header on which two servers disagree: retry later, do not mark the proof verified.</summary>
|
||||
internal sealed class CrossCheckInconclusiveException : Exception;
|
||||
@@ -0,0 +1,54 @@
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Core.Spv;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the "witness" connections used to cross-check recent headers (ADR 0042): servers
|
||||
/// other than the primary one, taken only from the hardcoded bootstrap list (never from the peers
|
||||
/// a server announces, which a hostile server could fill with its own), each verified to be on the
|
||||
/// profile's chain before use.
|
||||
/// </summary>
|
||||
public static class WitnessConnector
|
||||
{
|
||||
/// <summary>One witness is enough to turn a single lying server into a detectable disagreement.</summary>
|
||||
public const int DefaultMax = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Connects up to <paramref name="max"/> witnesses among <paramref name="candidates"/>,
|
||||
/// skipping the primary server. Unreachable, wrong-chain or otherwise failing candidates are
|
||||
/// skipped; an empty result means no cross-check is possible this time.
|
||||
/// </summary>
|
||||
public static async Task<IReadOnlyList<ElectrumClient>> ConnectAsync(ChainProfile profile,
|
||||
IEnumerable<ServerEndpoint> candidates, string primaryHost, int primaryPort, bool useSsl,
|
||||
CertificatePinStore? pins, int max = DefaultMax, CancellationToken ct = default)
|
||||
{
|
||||
var witnesses = new List<ElectrumClient>();
|
||||
foreach (var endpoint in candidates)
|
||||
{
|
||||
if (witnesses.Count >= max)
|
||||
break;
|
||||
var port = useSsl ? endpoint.SslPort : endpoint.TcpPort;
|
||||
if (string.Equals(endpoint.Host, primaryHost, StringComparison.OrdinalIgnoreCase) && port == primaryPort)
|
||||
continue;
|
||||
ElectrumClient? client = null;
|
||||
try
|
||||
{
|
||||
client = await ElectrumClient.ConnectAsync(endpoint.Host, port, useSsl, pins, ct);
|
||||
await client.VerifyChainAsync(profile, ct);
|
||||
witnesses.Add(client);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (client is not null) await client.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort: this candidate is unusable, try the next one.
|
||||
if (client is not null) await client.DisposeAsync();
|
||||
}
|
||||
}
|
||||
return witnesses;
|
||||
}
|
||||
}
|
||||
@@ -139,12 +139,12 @@ public sealed class SyncCache
|
||||
public Dictionary<int, string>? BlockHeaders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint height → highest height already proven to hash-chain back to it (§7.3).
|
||||
/// Avoids re-walking and re-verifying the whole header chain from the checkpoint on
|
||||
/// every launch: the chain-of-hashes check already done for a height doesn't need
|
||||
/// redoing once headers themselves are cached.
|
||||
/// Checkpoint height → lowest height already proven to hash-chain up to it (§7.3, ADR 0040).
|
||||
/// Avoids re-walking and re-verifying the header chain on every launch once the headers
|
||||
/// themselves are cached. A cache without this field predates the fix of the anchoring
|
||||
/// direction: its verified-proof claims are not trusted and get re-verified.
|
||||
/// </summary>
|
||||
public Dictionary<int, int>? AnchoredUpTo { get; set; }
|
||||
public Dictionary<int, int>? AnchoredDownTo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
||||
|
||||
@@ -80,4 +80,25 @@ public static class CoinAmount
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Upper bound accepted for a fee rate, in sat/vB (guards against typos).</summary>
|
||||
public const decimal MaxFeeRate = 1000m;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a fee rate in sat/vB, independent of the system culture. Accepts a decimal point or
|
||||
/// comma; rejects thousands separators, exponents, signs, zero and values above
|
||||
/// <see cref="MaxFeeRate"/>.
|
||||
/// </summary>
|
||||
public static bool TryParseFeeRate(string? text, out decimal satPerVbyte)
|
||||
{
|
||||
satPerVbyte = 0;
|
||||
if (text is null)
|
||||
return false;
|
||||
text = text.Trim().Replace(',', '.');
|
||||
if (!decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var rate)
|
||||
|| rate <= 0 || rate > MaxFeeRate)
|
||||
return false;
|
||||
satPerVbyte = rate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Core.Wallet;
|
||||
|
||||
/// <summary>
|
||||
/// Fee suggestions from the indexing server (untrusted input): the relay fee and estimates for
|
||||
/// three confirmation targets, in sat/vB. A null value means the server could not provide one
|
||||
/// or gave an implausible one (ADR 0039).
|
||||
/// </summary>
|
||||
public sealed record FeeEstimates(decimal? Relay, decimal? Fast, decimal? Normal, decimal? Economy)
|
||||
{
|
||||
public bool HasAny => Fast is not null || Normal is not null || Economy is not null;
|
||||
}
|
||||
|
||||
/// <summary>Conversions and checks for the fee rate, kept pure so they can be tested exhaustively.</summary>
|
||||
public static class FeePolicy
|
||||
{
|
||||
public const int FastTarget = 2;
|
||||
public const int NormalTarget = 6;
|
||||
public const int EconomyTarget = 24;
|
||||
|
||||
/// <summary>The fee warning threshold: a fee above this share of the amount sent.</summary>
|
||||
public const int HighFeePercent = 1;
|
||||
|
||||
private const decimal SatsPerCoin = 100_000_000m;
|
||||
private const decimal BytesPerKb = 1000m;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a server value in coin/kB to sat/vB (× 100,000,000 / 1,000). Null for "no
|
||||
/// estimate" (-1), zero, negative, or above <see cref="CoinAmount.MaxFeeRate"/>, which is
|
||||
/// treated as an implausible answer rather than followed.
|
||||
/// </summary>
|
||||
public static decimal? ToSatPerVbyte(decimal coinPerKb)
|
||||
{
|
||||
if (coinPerKb <= 0)
|
||||
return null;
|
||||
var rate = Math.Round(coinPerKb * SatsPerCoin / BytesPerKb, 2, MidpointRounding.AwayFromZero);
|
||||
return rate is > 0 and <= CoinAmount.MaxFeeRate ? rate : null;
|
||||
}
|
||||
|
||||
/// <summary>An estimate never goes below the relay fee: the node would reject it.</summary>
|
||||
public static decimal? AtLeastRelay(decimal? estimate, decimal? relay) =>
|
||||
estimate is { } e ? Math.Max(e, relay ?? 0m) : null;
|
||||
|
||||
/// <summary>The preselected rate: the normal estimate, else 1 sat/vB, never below the relay fee.</summary>
|
||||
public static decimal DefaultRate(FeeEstimates? estimates) =>
|
||||
Math.Max(estimates?.Normal ?? 1m, estimates?.Relay ?? 0m);
|
||||
|
||||
/// <summary>True when a manual rate is below the server's minimum relay fee.</summary>
|
||||
public static bool IsBelowRelay(decimal rate, decimal? relay) => relay is { } r && rate < r;
|
||||
|
||||
/// <summary>True when the fee is more than <see cref="HighFeePercent"/>% of the amount sent.</summary>
|
||||
public static bool IsHighFee(long feeSats, long amountSats) =>
|
||||
amountSats > 0 && feeSats * 100 > amountSats * HighFeePercent;
|
||||
|
||||
public static string Format(decimal rate) => rate.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Asks the server for the relay fee and the three estimates. Every call is optional: a
|
||||
/// server error or an unusable value just leaves that field null.
|
||||
/// </summary>
|
||||
public static async Task<FeeEstimates> FetchAsync(ElectrumClient client, CancellationToken ct = default)
|
||||
{
|
||||
var relay = await TryAsync(() => client.RelayFeeAsync(ct));
|
||||
var fast = await TryAsync(() => client.EstimateFeeAsync(FastTarget, ct));
|
||||
var normal = await TryAsync(() => client.EstimateFeeAsync(NormalTarget, ct));
|
||||
var economy = await TryAsync(() => client.EstimateFeeAsync(EconomyTarget, ct));
|
||||
return Build(relay, fast, normal, economy);
|
||||
}
|
||||
|
||||
/// <summary>Builds the estimates from raw server values (coin/kB), applying the sanity rules.</summary>
|
||||
public static FeeEstimates Build(decimal? relayCoinPerKb, decimal? fast, decimal? normal, decimal? economy)
|
||||
{
|
||||
var relay = relayCoinPerKb is { } r ? ToSatPerVbyte(r) : null;
|
||||
decimal? Convert(decimal? v) => AtLeastRelay(v is { } x ? ToSatPerVbyte(x) : null, relay);
|
||||
return new FeeEstimates(relay, Convert(fast), Convert(normal), Convert(economy));
|
||||
}
|
||||
|
||||
private static async Task<decimal?> TryAsync(Func<Task<decimal>> call)
|
||||
{
|
||||
try { return await call(); }
|
||||
catch (Exception ex) when (ex is ElectrumServerException or InvalidOperationException
|
||||
or FormatException or JsonException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,296 @@
|
||||
// Black-box conformance/health probe for Electrum-protocol servers (Fulcrum, ElectrumX, electrs).
|
||||
// Reuses the wallet's own ElectrumClient, so a server that passes here is one the wallet can use.
|
||||
// Read-only: never sends transactions and never touches wallet files.
|
||||
using System.Diagnostics;
|
||||
using System.Net.Security;
|
||||
using System.Text.Json;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
|
||||
if (args.Length == 0 || args.Contains("--help") || args.Contains("-h")) return Usage();
|
||||
|
||||
var profile = Opt("--net") switch
|
||||
{
|
||||
"testnet" => ChainProfiles.Testnet,
|
||||
"regtest" => ChainProfiles.Regtest,
|
||||
_ => ChainProfiles.Mainnet,
|
||||
};
|
||||
var useSsl = args.Contains("--ssl");
|
||||
var timeout = TimeSpan.FromSeconds(int.TryParse(Opt("--timeout"), out var t) ? t : 15);
|
||||
var pings = int.TryParse(Opt("--pings"), out var p) ? p : 5;
|
||||
var maxLag = int.TryParse(Opt("--max-lag"), out var l) ? l : 2;
|
||||
|
||||
var targets = new List<(string Host, int Port, bool Ssl)>();
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
if (args[i] == "--server") targets.Add(ParseTarget(args[i + 1]));
|
||||
if (Opt("--file") is { } file)
|
||||
foreach (var line in File.ReadAllLines(file))
|
||||
if (line.Trim() is { Length: > 0 } s && !s.StartsWith('#')) targets.Add(ParseTarget(s));
|
||||
if (args.Contains("--registry"))
|
||||
foreach (var s in new ServerRegistry(profile, Path.GetTempFileName()).All)
|
||||
targets.Add((s.Host, s.PortFor(useSsl), useSsl));
|
||||
if (targets.Count == 0) { Console.Error.WriteLine("No server given."); return Usage(); }
|
||||
|
||||
var failed = 0;
|
||||
var heights = new List<(string Name, int Height)>();
|
||||
foreach (var target in targets)
|
||||
{
|
||||
Console.WriteLine($"\n=== {target.Host}:{target.Port}{(target.Ssl ? " (TLS)" : "")} [{profile.NetName}] ===");
|
||||
var (ok, height) = await Probe(target);
|
||||
if (!ok) failed++;
|
||||
if (height is { } h) heights.Add(($"{target.Host}:{target.Port}", h));
|
||||
}
|
||||
|
||||
if (heights.Count > 1)
|
||||
{
|
||||
var best = heights.Max(x => x.Height);
|
||||
Console.WriteLine($"\n=== Cross-server tip (best {best}) ===");
|
||||
foreach (var (name, h) in heights)
|
||||
Console.WriteLine($" {(best - h > maxLag ? "WARN" : " ok ")} {name}: {h} (lag {best - h})");
|
||||
}
|
||||
Console.WriteLine($"\n{targets.Count - failed}/{targets.Count} server(s) passed.");
|
||||
return failed == 0 ? 0 : 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async Task<(bool Ok, int? Height)> Probe((string Host, int Port, bool Ssl) s)
|
||||
{
|
||||
var allOk = true;
|
||||
int? tip = null;
|
||||
|
||||
async Task<T?> Step<T>(string name, Func<CancellationToken, Task<(T Value, string Detail, bool Warn)>> body)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var (value, detail, warn) = await body(cts.Token);
|
||||
Console.WriteLine($" {(warn ? "WARN" : " ok ")} {name,-22} {sw.ElapsedMilliseconds,5} ms {detail}");
|
||||
return value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
allOk = false;
|
||||
var msg = ex is OperationCanceledException ? $"timeout after {timeout.TotalSeconds:0}s" : ex.Message;
|
||||
Console.WriteLine($" FAIL {name,-22} {sw.ElapsedMilliseconds,5} ms {msg}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
ElectrumClient? client = null;
|
||||
try
|
||||
{
|
||||
client = await Step("connect+handshake", async ct =>
|
||||
{
|
||||
var c = await ElectrumClient.ConnectAsync(s.Host, s.Port, s.Ssl, pins: null, ct);
|
||||
return (c, s.Ssl ? "TLS fingerprint " + await Fingerprint(s.Host, s.Port, ct) : "plaintext", !s.Ssl);
|
||||
});
|
||||
if (client is null) return (false, null);
|
||||
var c = client;
|
||||
await Step("server.banner", async ct =>
|
||||
{
|
||||
var r = await c.RequestAsync("server.banner", ct);
|
||||
var b = (r.GetString() ?? "").Replace('\n', ' ').Trim();
|
||||
return (0, b.Length > 60 ? b[..60] + "…" : b, false);
|
||||
});
|
||||
|
||||
await Step("server.features", async ct =>
|
||||
{
|
||||
var r = await c.RequestAsync("server.features", ct);
|
||||
var genesis = r.TryGetProperty("genesis_hash", out var g) ? g.GetString() : null;
|
||||
var hashFn = r.TryGetProperty("hash_function", out var hf) ? hf.GetString() : null;
|
||||
if (hashFn != "sha256") throw new Exception($"hash_function '{hashFn}' (expected sha256)");
|
||||
var pruning = r.TryGetProperty("pruning", out var pr) && pr.ValueKind == JsonValueKind.Number;
|
||||
return (0, $"genesis ok, pruning={(pruning ? pr.ToString() : "none")}", pruning);
|
||||
});
|
||||
|
||||
await Step("chain identity", async ct =>
|
||||
{
|
||||
await c.VerifyChainAsync(profile, ct);
|
||||
return (0, "genesis and checkpoint match the profile", false);
|
||||
});
|
||||
|
||||
var tipInfo = await Step("headers.subscribe", async ct =>
|
||||
{
|
||||
var r = await c.RequestAsync("blockchain.headers.subscribe", ct);
|
||||
var height = r.GetProperty("height").GetInt32();
|
||||
var header = BlockHeaderInfo.Parse(r.GetProperty("hex").GetString()!);
|
||||
var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
|
||||
var stale = age.TotalSeconds > profile.BlockTimeSeconds * 30;
|
||||
return ((height, header), $"height {height}, tip age {FormatAge(age)}", stale);
|
||||
});
|
||||
if (tipInfo.header is null) return (false, null);
|
||||
tip = tipInfo.height;
|
||||
var (tipHeight, tipHeader) = tipInfo;
|
||||
|
||||
await Step("genesis header", async ct =>
|
||||
{
|
||||
var hex = (await c.RequestAsync("blockchain.block.header", ct, 0)).GetString()!;
|
||||
var h = BlockHeaderInfo.Parse(hex);
|
||||
if (h.Hash.ToString() != profile.GenesisHash) throw new Exception($"header 0 hash {h.Hash}");
|
||||
return (0, "hash matches profile", false);
|
||||
});
|
||||
|
||||
await Step("header chain linkage", async ct =>
|
||||
{
|
||||
var count = Math.Min(20, tipHeight);
|
||||
var start = tipHeight - count + 1;
|
||||
var r = await c.RequestAsync("blockchain.block.headers", ct, start, count);
|
||||
var raw = Convert.FromHexString(r.GetProperty("hex").GetString()!);
|
||||
var n = raw.Length / BlockHeaderInfo.Size;
|
||||
if (n == 0) throw new Exception("empty header batch");
|
||||
BlockHeaderInfo? prev = null;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var h = BlockHeaderInfo.Parse(raw[(i * 80)..((i + 1) * 80)]);
|
||||
if (prev is not null && h.PrevHash != prev.Hash) throw new Exception($"broken link at height {start + i}");
|
||||
prev = h;
|
||||
}
|
||||
// The batch may be a block or two ahead of the earlier subscribe; only compare when equal.
|
||||
var note = start + n - 1 == tipHeight && prev!.Hash != tipHeader.Hash
|
||||
? throw new Exception("batch tip differs from headers.subscribe tip")
|
||||
: "";
|
||||
return (0, $"{n} headers linked {note}".Trim(), false);
|
||||
});
|
||||
|
||||
await Step("merkle proof (SPV)", async ct =>
|
||||
{
|
||||
// A block a few confirmations deep: its coinbase must verify against the header root.
|
||||
var height = Math.Max(1, tipHeight - 6);
|
||||
var hdr = BlockHeaderInfo.Parse((await c.RequestAsync("blockchain.block.header", ct, height)).GetString()!);
|
||||
var r = await c.RequestAsync("blockchain.transaction.id_from_pos", ct, height, 0, true);
|
||||
if (r.ValueKind != JsonValueKind.Object || !(r.TryGetProperty("tx_hash", out _) || r.TryGetProperty("tx_id", out _)) || !r.TryGetProperty("merkle", out _)) throw new Exception("unexpected id_from_pos reply: " + r.ToString()[..Math.Min(120, r.ToString().Length)]);
|
||||
var txid = uint256.Parse((r.TryGetProperty("tx_hash", out var th) ? th : r.GetProperty("tx_id")).GetString()!);
|
||||
var branch = r.GetProperty("merkle").EnumerateArray().Select(e => uint256.Parse(e.GetString()!)).ToList();
|
||||
if (!MerkleProof.Verify(txid, 0, branch, hdr.MerkleRoot))
|
||||
throw new Exception($"merkle proof for coinbase at height {height} does NOT match header root");
|
||||
return (0, $"coinbase {txid.ToString()[..12]}… @ {height} verified", false);
|
||||
});
|
||||
|
||||
await Step("estimatefee/relayfee", async ct =>
|
||||
{
|
||||
var est = (await c.RequestAsync("blockchain.estimatefee", ct, 2)).GetDouble();
|
||||
var relay = (await c.RequestAsync("blockchain.relayfee", ct)).GetDouble();
|
||||
return (0, $"est(2 blk)={(est < 0 ? "n/a" : est.ToString("0.########"))} PLM/kB, relay={relay:0.########}", relay <= 0);
|
||||
});
|
||||
|
||||
await Step("fee histogram", async ct =>
|
||||
{
|
||||
var r = await c.RequestAsync("mempool.get_fee_histogram", ct);
|
||||
return (0, $"{r.GetArrayLength()} buckets", false);
|
||||
});
|
||||
|
||||
await Step("peers.subscribe", async ct =>
|
||||
{
|
||||
var r = await c.RequestAsync("server.peers.subscribe", ct);
|
||||
return (0, $"{r.GetArrayLength()} peers", false);
|
||||
});
|
||||
|
||||
await Step("unknown scripthash", async ct =>
|
||||
{
|
||||
// Well-formed but unused scripthash must return an empty, not an error.
|
||||
var sh = new string('0', 64);
|
||||
var r = await c.RequestAsync("blockchain.scripthash.get_balance", ct, sh);
|
||||
var conf = r.GetProperty("confirmed").GetInt64();
|
||||
if (conf != 0) throw new Exception($"confirmed={conf} for an all-zero scripthash");
|
||||
return (0, "balance 0/0", false);
|
||||
});
|
||||
|
||||
await Step("error handling", async ct =>
|
||||
{
|
||||
try { await c.RequestAsync("blockchain.block.header", ct, int.MaxValue); }
|
||||
catch (ElectrumServerException) { return (0, "bad height → JSON-RPC error", false); }
|
||||
throw new Exception("server answered a nonexistent height instead of an error");
|
||||
});
|
||||
|
||||
await Step($"latency x{pings}", async ct =>
|
||||
{
|
||||
var times = new List<double>();
|
||||
for (var i = 0; i < pings; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
await c.RequestAsync("server.ping", ct);
|
||||
times.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
return (0, $"min {times.Min():0} / avg {times.Average():0} / max {times.Max():0} ms", times.Average() > 1500);
|
||||
});
|
||||
|
||||
await Step("concurrent burst x50", async ct =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var rs = await Task.WhenAll(Enumerable.Range(0, 50)
|
||||
.Select(i => c.RequestAsync("blockchain.block.header", ct, Math.Max(0, tipHeight - i))));
|
||||
if (rs.Any(r => r.ValueKind != JsonValueKind.String)) throw new Exception("malformed reply in burst");
|
||||
return (0, $"50 pipelined requests in {sw.ElapsedMilliseconds} ms", false);
|
||||
});
|
||||
|
||||
await Step("tip still advancing", async ct =>
|
||||
{
|
||||
var r = await c.RequestAsync("blockchain.headers.subscribe", ct);
|
||||
var now = r.GetProperty("height").GetInt32();
|
||||
tip = now;
|
||||
return (0, $"height {now} (was {tipHeight})", now < tipHeight);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client is not null) await client.DisposeAsync();
|
||||
}
|
||||
Console.WriteLine(allOk ? " => PASS" : " => FAIL");
|
||||
return (allOk, tip);
|
||||
}
|
||||
|
||||
static async Task<string> Fingerprint(string host, int port, CancellationToken ct)
|
||||
{
|
||||
using var tcp = new System.Net.Sockets.TcpClient();
|
||||
await tcp.ConnectAsync(host, port, ct);
|
||||
string fp = "?";
|
||||
await using var ssl = new SslStream(tcp.GetStream(), false, (_, cert, _, _) =>
|
||||
{
|
||||
if (cert is not null) fp = CertificatePinStore.Fingerprint(cert);
|
||||
return true;
|
||||
});
|
||||
await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, ct);
|
||||
return fp.Length > 23 ? fp[..23] + "…" : fp;
|
||||
}
|
||||
|
||||
static string FormatAge(TimeSpan a) =>
|
||||
a.TotalMinutes < 120 ? $"{a.TotalMinutes:0} min" : a.TotalHours < 48 ? $"{a.TotalHours:0.#} h" : $"{a.TotalDays:0.#} d";
|
||||
|
||||
(string Host, int Port, bool Ssl) ParseTarget(string spec)
|
||||
{
|
||||
var parts = spec.Trim().Split(':');
|
||||
var port = parts.Length > 1 ? int.Parse(parts[1]) : useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort;
|
||||
return (parts[0], port, useSsl);
|
||||
}
|
||||
|
||||
string? Opt(string name)
|
||||
{
|
||||
var i = Array.IndexOf(args, name);
|
||||
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
||||
}
|
||||
|
||||
static int Usage()
|
||||
{
|
||||
Console.WriteLine("""
|
||||
PalladiumWallet server probe — conformance/health checks for Electrum-protocol servers
|
||||
|
||||
usage: dotnet run --project tests/PalladiumWallet.ServerProbe -- [options]
|
||||
|
||||
--server host[:port] server to test (repeatable)
|
||||
--file PATH text file, one host[:port] per line ('#' comments)
|
||||
--registry test every server known for the network
|
||||
--ssl use TLS (default port 50002; plain 50001)
|
||||
--net mainnet|testnet|regtest (default mainnet)
|
||||
--timeout SEC per-check timeout (default 15)
|
||||
--pings N latency samples (default 5)
|
||||
--max-lag N blocks behind the best server before WARN (default 2)
|
||||
|
||||
Checks: handshake/TLS, version, banner, features (genesis + sha256), tip freshness,
|
||||
genesis header, header linkage, Merkle proof vs header root, fees, peers, error
|
||||
handling, latency, pipelined burst. Exit code 1 if any server has a FAIL.
|
||||
""");
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Reflection;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.App;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests of the CLI: the real entry point is invoked in-process with a captured
|
||||
/// console. State is sandboxed: wallet files live in a temp folder (--file) and the data
|
||||
/// root (server list, certificate pins) is redirected to it.
|
||||
/// </summary>
|
||||
[Collection("AppPaths")] // Console.SetOut and AppPaths are process-wide
|
||||
public sealed class CliTests : IDisposable
|
||||
{
|
||||
private const string Mnemonic =
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"plm-cli-{Guid.NewGuid()}");
|
||||
private readonly string? _savedOverride = AppPaths.OverrideDataRoot;
|
||||
|
||||
public CliTests()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
AppPaths.OverrideDataRoot = _dir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppPaths.OverrideDataRoot = _savedOverride;
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string WalletFile(string name = "w.json") => Path.Combine(_dir, name);
|
||||
|
||||
private static Task<(int Exit, string Out, string Err)> Cli(params string[] args) => CliWithStdin(null, args);
|
||||
|
||||
private static async Task<(int Exit, string Out, string Err)> CliWithStdin(string? stdin, params string[] args)
|
||||
{
|
||||
var entry = Assembly.Load("PalladiumWallet.Cli").EntryPoint
|
||||
?? throw new InvalidOperationException("CLI has no entry point.");
|
||||
var (oldOut, oldErr, oldIn) = (Console.Out, Console.Error, Console.In);
|
||||
var (o, e) = (new StringWriter(), new StringWriter());
|
||||
Console.SetOut(o); Console.SetError(e); Console.SetIn(new StringReader(stdin ?? ""));
|
||||
try
|
||||
{
|
||||
// The top-level-statements entry point is a synchronous Main(string[]) → int wrapper.
|
||||
var exit = await Task.Run(() => (int)entry.Invoke(null, [args])!);
|
||||
return (exit, o.ToString(), e.ToString());
|
||||
}
|
||||
finally { Console.SetOut(oldOut); Console.SetError(oldErr); Console.SetIn(oldIn); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoArguments_PrintsUsage_AndFails()
|
||||
{
|
||||
var (exit, output, _) = await Cli();
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("PalladiumWallet CLI", output);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("12", 12)]
|
||||
[InlineData("24", 24)]
|
||||
public async Task NewSeed_GeneratesAValidMnemonicOfTheRequestedLength(string words, int count)
|
||||
{
|
||||
var (exit, output, _) = await Cli("newseed", "--words", words);
|
||||
Assert.Equal(0, exit);
|
||||
Assert.True(Bip39.TryParse(output.Trim(), out var m));
|
||||
Assert.Equal(count, m!.Words.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Addresses_MatchesTheCoreDerivation()
|
||||
{
|
||||
var (exit, output, _) = await Cli("addresses", Mnemonic, "--net", "regtest", "--count", "3");
|
||||
Assert.Equal(0, exit);
|
||||
Assert.True(Bip39.TryParse(Mnemonic, out var m));
|
||||
var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||
for (var i = 0; i < 3; i++)
|
||||
Assert.Contains(account.GetReceiveAddress(i).ToString(), output);
|
||||
Assert.Contains(account.ToSlip132(), output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_WithAnInvalidMnemonic_FailsWithoutWritingAFile()
|
||||
{
|
||||
var (exit, _, err) = await Cli("restore", "abandon abandon abandon", "--no-encryption", "--file", WalletFile());
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("Invalid mnemonic", err);
|
||||
Assert.False(File.Exists(WalletFile()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Encrypts_AndInfoNeedsThePassword()
|
||||
{
|
||||
var (exit, _, _) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "pw", "--file", WalletFile());
|
||||
Assert.Equal(0, exit);
|
||||
Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile())));
|
||||
Assert.DoesNotContain("abandon", File.ReadAllText(WalletFile()));
|
||||
|
||||
var (okExit, info, _) = await Cli("info", "--net", "regtest", "--password", "pw", "--file", WalletFile());
|
||||
Assert.Equal(0, okExit);
|
||||
Assert.Contains("watch-only: False", info);
|
||||
|
||||
var (badExit, _, err) = await Cli("info", "--net", "regtest", "--password", "wrong", "--file", WalletFile());
|
||||
Assert.Equal(1, badExit);
|
||||
Assert.Contains("Error:", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ShowsTheMnemonicOnce_AndWritesTheWallet()
|
||||
{
|
||||
var (exit, output, _) = await Cli("create", "--net", "regtest", "--no-encryption", "--file", WalletFile());
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("New mnemonic", output);
|
||||
Assert.True(File.Exists(WalletFile()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreXpub_AndRestoreAddress_ProduceWatchOnlyWallets()
|
||||
{
|
||||
Assert.True(Bip39.TryParse(Mnemonic, out var m));
|
||||
var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||
|
||||
var (e1, _, _) = await Cli("restore-xpub", account.ToSlip132(), "--net", "regtest", "--no-encryption", "--file", WalletFile("x.json"));
|
||||
Assert.Equal(0, e1);
|
||||
Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("x.json"))).Out);
|
||||
|
||||
var (e2, _, _) = await Cli("restore-address", account.GetReceiveAddress(0).ToString(), "--net", "regtest", "--no-encryption", "--file", WalletFile("a.json"));
|
||||
Assert.Equal(0, e2);
|
||||
Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("a.json"))).Out);
|
||||
|
||||
var (e3, _, err3) = await Cli("restore-xpub", "garbage", "--net", "regtest", "--no-encryption", "--file", WalletFile("bad.json"));
|
||||
Assert.Equal(1, e3);
|
||||
Assert.Contains("not recognised", err3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_BeforeAnySync_AsksToSyncFirst()
|
||||
{
|
||||
await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile());
|
||||
var (exit, _, err) = await Cli("send", "--to", "x", "--all", "--net", "regtest", "--file", WalletFile());
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("run 'sync' first", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_ThenSend_AgainstAFakeServer_ProducesASignedTransaction()
|
||||
{
|
||||
var net = PalladiumNetworks.Regtest;
|
||||
Assert.True(Bip39.TryParse(Mnemonic, out var mnemonic));
|
||||
var account = HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||
var funding = net.CreateTransaction();
|
||||
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
funding.Outputs.Add(Money.Satoshis(10_000_000), account.GetReceiveAddress(0));
|
||||
var txid = funding.GetHash();
|
||||
var scripthash = Scripthash.FromAddress(account.GetReceiveAddress(0));
|
||||
|
||||
string Header(uint256 root, int h)
|
||||
{
|
||||
var header = net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||
header.HashPrevBlock = uint256.Zero;
|
||||
header.HashMerkleRoot = root;
|
||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + h);
|
||||
header.Bits = new Target(0x1d00ffffu);
|
||||
header.Nonce = (uint)h;
|
||||
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Regtest.GenesisHash });
|
||||
server.Handle("blockchain.headers.subscribe", _ => new { height = 300, hex = Header(uint256.One, 300) });
|
||||
server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||
server.Handle("blockchain.scripthash.get_history", p =>
|
||||
p[0].GetString() == scripthash ? new[] { new { tx_hash = txid.ToString(), height = 100 } } : Array.Empty<object>());
|
||||
server.Handle("blockchain.transaction.get", _ => funding.ToHex());
|
||||
server.Handle("blockchain.transaction.get_merkle", _ => new { block_height = 100, pos = 0, merkle = Array.Empty<string>() });
|
||||
server.Handle("blockchain.block.header", _ => Header(txid, 100));
|
||||
|
||||
await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile());
|
||||
var serverArg = $"{server.Host}:{server.Port}";
|
||||
|
||||
var (syncExit, syncOut, _) = await Cli("sync", "--server", serverArg, "--net", "regtest", "--file", WalletFile());
|
||||
Assert.Equal(0, syncExit);
|
||||
Assert.Contains("0.1", syncOut); // 10,000,000 sat
|
||||
Assert.Contains(txid.ToString(), syncOut);
|
||||
Assert.DoesNotContain("(unverified)", syncOut);
|
||||
|
||||
var dest = account.GetReceiveAddress(9).ToString();
|
||||
var (sendExit, sendOut, _) = await Cli("send", "--to", dest, "--amount", "0.05", "--feerate", "2",
|
||||
"--server", serverArg, "--net", "regtest", "--file", WalletFile());
|
||||
Assert.Equal(0, sendExit);
|
||||
Assert.Contains("NOT broadcast", sendOut);
|
||||
var hex = sendOut.Split('\n', StringSplitOptions.TrimEntries).Last(l => l.Length > 100 && l.All(Uri.IsHexDigit));
|
||||
var tx = Transaction.Parse(hex, net);
|
||||
Assert.Contains(tx.Outputs, o => o.Value == Money.Satoshis(5_000_000) && o.ScriptPubKey == account.GetReceiveAddress(9).ScriptPubKey);
|
||||
Assert.Equal(txid, tx.Inputs[0].PrevOut.Hash);
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.broadcast")); // nothing broadcast without --broadcast
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_refuses_a_server_on_another_chain()
|
||||
{
|
||||
await using var server = new FakeElectrumServer(); // default features: mainnet genesis
|
||||
await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile());
|
||||
|
||||
var (exit, _, err) = await Cli("sync", "--server", $"{server.Host}:{server.Port}",
|
||||
"--net", "regtest", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("not on the expected chain", err);
|
||||
Assert.Equal(0, server.CallCount("blockchain.headers.subscribe")); // rejected before syncing
|
||||
}
|
||||
|
||||
// ---- wallet password handling (ADR 0038) ----
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutAPasswordOrNoEncryption_FailsInsteadOfStoringPlaintext()
|
||||
{
|
||||
var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("A password is required", err);
|
||||
Assert.False(File.Exists(WalletFile()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoEncryption_StoresPlaintextAndWarns()
|
||||
{
|
||||
var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("unencrypted", err);
|
||||
Assert.False(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoEncryption_CannotBeCombinedWithAPassword()
|
||||
{
|
||||
var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption",
|
||||
"--password-stdin", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("cannot be combined", err);
|
||||
Assert.False(File.Exists(WalletFile()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordStdin_EncryptsAndThenOpensTheWallet()
|
||||
{
|
||||
var (e1, _, _) = await CliWithStdin("s3cret\n", "restore", Mnemonic, "--net", "regtest",
|
||||
"--password-stdin", "--file", WalletFile());
|
||||
Assert.Equal(0, e1);
|
||||
var text = File.ReadAllText(WalletFile());
|
||||
Assert.True(EncryptedFile.IsEncrypted(text));
|
||||
Assert.DoesNotContain("abandon", text);
|
||||
|
||||
var (e2, info, _) = await CliWithStdin("s3cret\n", "info", "--net", "regtest",
|
||||
"--password-stdin", "--file", WalletFile());
|
||||
Assert.Equal(0, e2);
|
||||
Assert.Contains("network: regtest", info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordEnv_EncryptsAndThenOpensTheWallet()
|
||||
{
|
||||
const string name = "PLM_TEST_WALLET_PW";
|
||||
Environment.SetEnvironmentVariable(name, "from-env");
|
||||
try
|
||||
{
|
||||
var (e1, _, err1) = await Cli("restore", Mnemonic, "--net", "regtest", "--password-env", name,
|
||||
"--file", WalletFile());
|
||||
Assert.Equal(0, e1);
|
||||
Assert.DoesNotContain("--password is visible", err1); // no warning for the safe route
|
||||
Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile())));
|
||||
|
||||
var (e2, _, _) = await Cli("info", "--net", "regtest", "--password-env", name, "--file", WalletFile());
|
||||
Assert.Equal(0, e2);
|
||||
}
|
||||
finally { Environment.SetEnvironmentVariable(name, null); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordEnv_UnsetOrEmpty_IsAnError()
|
||||
{
|
||||
var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest",
|
||||
"--password-env", "PLM_TEST_SURELY_UNSET", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("PLM_TEST_SURELY_UNSET", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EncryptedWallet_WithNoPasswordAndNoTerminal_FailsWithAClearError()
|
||||
{
|
||||
await CliWithStdin("pw\n", "restore", Mnemonic, "--net", "regtest", "--password-stdin", "--file", WalletFile());
|
||||
|
||||
var (exit, _, err) = await Cli("info", "--net", "regtest", "--file", WalletFile()); // no TTY: never prompts or hangs
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("encrypted", err);
|
||||
Assert.Contains("--password-env", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EncryptedWallet_WithTheWrongPasswordFails()
|
||||
{
|
||||
await CliWithStdin("pw\n", "restore", Mnemonic, "--net", "regtest", "--password-stdin", "--file", WalletFile());
|
||||
|
||||
var (exit, _, err) = await CliWithStdin("nope\n", "info", "--net", "regtest", "--password-stdin", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.NotEmpty(err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PasswordFlag_StillWorksButWarnsAndNeverEchoesThePassword()
|
||||
{
|
||||
var (exit, output, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "hunter2",
|
||||
"--file", WalletFile());
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("--password is visible", err);
|
||||
Assert.DoesNotContain("hunter2", output + err);
|
||||
Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoreThanOnePasswordSource_IsRejected()
|
||||
{
|
||||
var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "a",
|
||||
"--password-stdin", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
Assert.Contains("only one", err);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_OfAnEncryptedWallet_SavesItBackEncrypted()
|
||||
{
|
||||
await CliWithStdin("pw\n", "restore", Mnemonic, "--net", "regtest", "--password-stdin", "--file", WalletFile());
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Regtest.GenesisHash });
|
||||
server.Handle("blockchain.headers.subscribe", _ => new { height = 5, hex = new string('0', 160) });
|
||||
server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||
server.Handle("blockchain.scripthash.get_history", _ => Array.Empty<object>());
|
||||
|
||||
var (exit, _, _) = await CliWithStdin("pw\n", "sync", "--server", $"{server.Host}:{server.Port}",
|
||||
"--net", "regtest", "--password-stdin", "--file", WalletFile());
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Headless;
|
||||
using PalladiumWallet.Tests.App;
|
||||
|
||||
// Boots the real Avalonia Application headlessly (no window, no GPU) so ViewModels that use
|
||||
// the dispatcher (DispatcherTimer, UIThread.Post) can be tested. The xunit-v3-only helper
|
||||
// package (Avalonia.Headless.XUnit) is deliberately not used: this suite is on xunit 2.
|
||||
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
|
||||
|
||||
namespace PalladiumWallet.Tests.App;
|
||||
|
||||
public class TestAppBuilder
|
||||
{
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<global::PalladiumWallet.App.App>()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions());
|
||||
}
|
||||
|
||||
/// <summary>One shared headless Avalonia session; tests run their body on its UI thread.</summary>
|
||||
internal static class UiSession
|
||||
{
|
||||
private static readonly Lazy<HeadlessUnitTestSession> Session =
|
||||
new(() => HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder)));
|
||||
|
||||
// Avalonia's Dispatch(Func<Task>) returns as soon as the body reaches its first real await:
|
||||
// everything after it (including the assertions) would silently never run and the test would
|
||||
// pass vacuously. So the async body is driven to completion here, pumping the dispatcher on
|
||||
// the UI thread (the continuations of its awaits are posted there), with a hard time limit.
|
||||
public static Task Run(Func<Task> body) => Session.Value.Dispatch(() =>
|
||||
{
|
||||
var task = body();
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromMinutes(2);
|
||||
while (!task.IsCompleted)
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
throw new TimeoutException("Async UI test body did not complete within 2 minutes.");
|
||||
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
task.GetAwaiter().GetResult();
|
||||
}, CancellationToken.None);
|
||||
|
||||
public static Task Run(Action body) =>
|
||||
Session.Value.Dispatch(() => { body(); return Task.CompletedTask; }, CancellationToken.None);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using PalladiumWallet.App.Localization;
|
||||
|
||||
namespace PalladiumWallet.Tests.App;
|
||||
|
||||
/// <summary>
|
||||
/// Consistency of the in-code translation table: every key must have a non-empty value for
|
||||
/// each of the 7 languages, format placeholders must match across languages, and every key
|
||||
/// referenced from XAML or C# must exist (a missing key silently renders as the raw key).
|
||||
/// </summary>
|
||||
[Collection("AppPaths")] // shares the serialized collection for process-wide statics (Loc.Instance, AppPaths)
|
||||
public partial class LocalizationTests
|
||||
{
|
||||
private static Dictionary<string, string[]> Table() =>
|
||||
(Dictionary<string, string[]>)typeof(Loc)
|
||||
.GetField("Strings", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)!;
|
||||
|
||||
[Fact]
|
||||
public void EveryKey_HasOneNonEmptyValuePerLanguage()
|
||||
{
|
||||
var bad = Table()
|
||||
.Where(kv => kv.Value.Length != Loc.Languages.Length || kv.Value.Any(string.IsNullOrWhiteSpace))
|
||||
.Select(kv => kv.Key).ToList();
|
||||
Assert.True(bad.Count == 0, "Incomplete translations: " + string.Join(", ", bad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LanguageNames_MatchLanguages() =>
|
||||
Assert.Equal(Loc.Languages.Length, Loc.LanguageNames.Length);
|
||||
|
||||
[Fact]
|
||||
public void FormatPlaceholders_AreIdenticalAcrossLanguages()
|
||||
{
|
||||
var bad = new List<string>();
|
||||
foreach (var (key, values) in Table())
|
||||
{
|
||||
var sets = values.Select(v => string.Join(",", Placeholder().Matches(v).Select(m => m.Value).Distinct().Order())).ToList();
|
||||
if (sets.Distinct().Count() > 1) bad.Add($"{key}: {string.Join(" | ", sets)}");
|
||||
}
|
||||
Assert.True(bad.Count == 0, "Placeholder mismatch: " + string.Join("; ", bad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexer_ReturnsTranslationPerLanguage_AndFallsBackToKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
var table = Table();
|
||||
var key = table.Keys.First();
|
||||
for (var i = 0; i < Loc.Languages.Length; i++)
|
||||
Assert.Equal(table[key][i], Loc.SwitchTo(Loc.Languages[i])[key]);
|
||||
Assert.Equal("no.such.key", Loc.Tr("no.such.key"));
|
||||
Assert.Equal("en", Loc.SwitchTo("xx").Language); // unknown language → English
|
||||
}
|
||||
finally { Loc.SwitchTo("en"); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeysUsedInSources_ExistInTable()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
if (root is null) return; // running from a packaged copy without sources
|
||||
var keys = Table().Keys.ToHashSet();
|
||||
var missing = new SortedSet<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(root, "src", "App"), "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => (f.EndsWith(".axaml") || f.EndsWith(".cs")) && !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
&& !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
|
||||
&& Path.GetFileName(f) != "Loc.cs")) // Loc.cs only mentions the syntax in comments
|
||||
{
|
||||
var text = File.ReadAllText(file);
|
||||
foreach (Match m in XamlKey().Matches(text)) if (!keys.Contains(m.Groups[1].Value)) missing.Add(m.Groups[1].Value);
|
||||
foreach (Match m in CodeKey().Matches(text)) if (!keys.Contains(m.Groups[1].Value)) missing.Add(m.Groups[1].Value);
|
||||
}
|
||||
Assert.True(missing.Count == 0, "Keys used but not defined: " + string.Join(", ", missing));
|
||||
}
|
||||
|
||||
private static string? RepoRoot()
|
||||
{
|
||||
for (var d = new DirectoryInfo(AppContext.BaseDirectory); d is not null; d = d.Parent)
|
||||
if (File.Exists(Path.Combine(d.FullName, "PalladiumWallet.sln"))) return d.FullName;
|
||||
return null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\d+(?::[^}]*)?\}")] private static partial Regex Placeholder();
|
||||
[GeneratedRegex(@"Loc\[([A-Za-z0-9_.]+)\]")] private static partial Regex XamlKey();
|
||||
[GeneratedRegex(@"Loc\.Tr\(""([A-Za-z0-9_.]+)""\)")] private static partial Regex CodeKey();
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
|
||||
namespace PalladiumWallet.Tests.App;
|
||||
|
||||
/// <summary>
|
||||
/// Secret-handling gates in the UI layer: the seed and private keys must stay hidden until the
|
||||
/// wallet password is supplied, and must be hidden again on close. Also settings, wallet info
|
||||
/// and the remaining wizard flows.
|
||||
/// </summary>
|
||||
[Collection("AppPaths")]
|
||||
public sealed class MainWindowViewModelSecurityTests : VmTestBase
|
||||
{
|
||||
[Fact]
|
||||
public Task Seed_StaysHidden_UntilTheRightPasswordIsGiven() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "pw", "s1");
|
||||
vm.OpenWalletInfoCommand.Execute(null);
|
||||
Assert.True(vm.WalletInfoHasSeed);
|
||||
Assert.True(vm.WalletInfoSeedNeedsPassword);
|
||||
Assert.Equal("", vm.WalletInfoSeedText);
|
||||
|
||||
vm.WalletInfoSeedPasswordInput = "nope";
|
||||
vm.RevealSeedCommand.Execute(null);
|
||||
Assert.False(vm.IsWalletInfoSeedRevealed);
|
||||
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.WalletInfoSeedError);
|
||||
Assert.Equal("", vm.WalletInfoSeedText);
|
||||
|
||||
vm.WalletInfoSeedPasswordInput = "pw";
|
||||
vm.RevealSeedCommand.Execute(null);
|
||||
Assert.True(vm.IsWalletInfoSeedRevealed);
|
||||
Assert.Equal(Mnemonic, vm.WalletInfoSeedText);
|
||||
|
||||
vm.CloseWalletInfoCommand.Execute(null); // closing wipes the revealed state
|
||||
Assert.False(vm.IsWalletInfoSeedRevealed);
|
||||
Assert.Equal("", vm.WalletInfoSeedText);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task WatchOnlyWallet_HasNoSeedToReveal() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
|
||||
.GetReceiveAddress(0).ToString();
|
||||
vm.WizardStartImportAddressCommand.Execute(null);
|
||||
vm.ImportAddressInput = address;
|
||||
vm.WizardNextFromImportAddressCommand.Execute(null);
|
||||
vm.EncryptWallet = false;
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
vm.OpenWalletInfoCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.WalletInfoHasSeed);
|
||||
vm.RevealSeedCommand.Execute(null);
|
||||
Assert.False(vm.IsWalletInfoSeedRevealed);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task PrivateKey_IsRevealedOnlyAfterThePassword_AndOnlyWhenTheWalletHasKeys() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 1_000_000);
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "pw", "pk");
|
||||
await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0);
|
||||
|
||||
var row = vm.Addresses.First(a => !a.IsChange && a.Indice == 0);
|
||||
Assert.True(row.HasPrivKey);
|
||||
var wif = account.GetPrivateKey(false, 0)!.GetWif(PalladiumNetworks.For(NetKind.Mainnet)).ToString();
|
||||
|
||||
// No row holds a private key before the reveal (reflection over every string property).
|
||||
Assert.All(vm.Addresses, r => Assert.DoesNotContain(wif, StringValues(r)));
|
||||
Assert.DoesNotContain(typeof(AddressRow).GetProperties(), p => p.Name.Contains("PrivKey") && p.PropertyType == typeof(string));
|
||||
|
||||
vm.ShowAddressInfo(row);
|
||||
Assert.DoesNotContain(wif, StringValues(vm.AddressInfo!));
|
||||
Assert.Equal("", vm.RevealedPrivKey);
|
||||
|
||||
vm.RequestPrivKeyRevealCommand.Execute(null);
|
||||
Assert.True(vm.IsPrivKeyPromptOpen);
|
||||
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||
Assert.Equal("", vm.RevealedPrivKey);
|
||||
|
||||
vm.PrivKeyPromptPassword = "wrong";
|
||||
vm.ConfirmPrivKeyPasswordCommand.Execute(null);
|
||||
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.PrivKeyPromptError);
|
||||
|
||||
vm.PrivKeyPromptPassword = "pw";
|
||||
vm.ConfirmPrivKeyPasswordCommand.Execute(null);
|
||||
Assert.True(vm.IsAddressPrivKeyRevealed);
|
||||
Assert.False(vm.IsPrivKeyPromptOpen);
|
||||
Assert.Equal(wif, vm.RevealedPrivKey);
|
||||
Assert.DoesNotContain(wif, StringValues(vm.AddressInfo!)); // still not stored in the info
|
||||
Assert.All(vm.Addresses, r => Assert.DoesNotContain(wif, StringValues(r)));
|
||||
|
||||
vm.HideAddressPrivKeyCommand.Execute(null); // hiding drops the key
|
||||
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||
Assert.Equal("", vm.RevealedPrivKey);
|
||||
|
||||
vm.RequestPrivKeyRevealCommand.Execute(null); // reveal again, then close the sheet
|
||||
vm.PrivKeyPromptPassword = "pw";
|
||||
vm.ConfirmPrivKeyPasswordCommand.Execute(null);
|
||||
Assert.Equal(wif, vm.RevealedPrivKey);
|
||||
vm.CloseAddressInfoCommand.Execute(null); // closing the sheet re-hides everything
|
||||
Assert.Equal("", vm.RevealedPrivKey);
|
||||
vm.ShowAddressInfo(row);
|
||||
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task ClosingTheWallet_DropsARevealedPrivateKey() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 1_000_000);
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "pk2");
|
||||
await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0);
|
||||
|
||||
vm.ShowAddressInfo(vm.Addresses.First(a => !a.IsChange && a.Indice == 0));
|
||||
vm.RequestPrivKeyRevealCommand.Execute(null); // no password: revealed directly
|
||||
Assert.NotEqual("", vm.RevealedPrivKey);
|
||||
|
||||
vm.CloseWalletCommand.Execute(null);
|
||||
Assert.Equal("", vm.RevealedPrivKey);
|
||||
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||
Assert.Null(vm.AddressInfo);
|
||||
});
|
||||
|
||||
private static IEnumerable<string> StringValues(object o) =>
|
||||
o.GetType().GetProperties()
|
||||
.Where(p => p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => (string?)p.GetValue(o) ?? "");
|
||||
|
||||
[Fact]
|
||||
public Task WatchOnlyAddressRows_NeverCarryAPrivateKey() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 1_000_000);
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartImportXkeyCommand.Execute(null);
|
||||
vm.ImportXkeyInput = account.ToSlip132();
|
||||
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||
vm.EncryptWallet = false;
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0);
|
||||
|
||||
Assert.All(vm.Addresses, a => Assert.False(a.HasPrivKey));
|
||||
vm.ShowAddressInfo(vm.Addresses[0]);
|
||||
vm.RequestPrivKeyRevealCommand.Execute(null);
|
||||
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||
Assert.False(vm.IsPrivKeyPromptOpen);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Units_AndLanguages_AreAppliedAndPersisted() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
vm.SetUnitCommand.Execute("sat");
|
||||
Assert.True(vm.IsUnitSat);
|
||||
Assert.Equal("sat", vm.UnitLabel);
|
||||
Assert.Equal("sat", AppConfig.Load(AppPaths.ConfigPath()).Unit);
|
||||
|
||||
foreach (var lang in Loc.Languages)
|
||||
{
|
||||
vm.SetLanguageCommand.Execute(lang);
|
||||
Assert.Equal(lang, vm.Loc.Language);
|
||||
}
|
||||
Assert.True(vm.IsLangZh);
|
||||
Assert.False(vm.IsLangIt);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task SettingsFromDisk_AreRestoredByANewViewModel() => UiSession.Run(() =>
|
||||
{
|
||||
new MainWindowViewModel().SetLanguageCommand.Execute("de");
|
||||
|
||||
var vm2 = new MainWindowViewModel();
|
||||
|
||||
Assert.Equal("de", vm2.Loc.Language);
|
||||
Assert.True(vm2.IsLangDe);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Wizard_BackNavigation_ReturnsToThePreviousStep() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartRestoreCommand.Execute(null);
|
||||
vm.MnemonicInput = Mnemonic;
|
||||
vm.WizardNextFromWordsCommand.Execute(null);
|
||||
Assert.True(vm.IsStepPassphrase);
|
||||
|
||||
vm.WizardBackCommand.Execute(null);
|
||||
Assert.True(vm.IsStepWords);
|
||||
vm.WizardBackCommand.Execute(null);
|
||||
Assert.True(vm.IsStepStart);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task ImportWif_CreatesAnImportedKeyWallet_AndRejectsGarbage() => UiSession.Run(() =>
|
||||
{
|
||||
var key = new NBitcoin.Key();
|
||||
var wif = key.GetWif(PalladiumNetworks.Mainnet).ToString();
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
vm.WizardStartImportWifCommand.Execute(null);
|
||||
vm.ImportWifInput = "definitely-not-a-wif";
|
||||
vm.WizardNextFromImportWifCommand.Execute(null);
|
||||
Assert.True(vm.IsStepImportWif);
|
||||
|
||||
vm.ImportWifInput = wif;
|
||||
vm.WizardNextFromImportWifCommand.Execute(null);
|
||||
vm.EncryptWallet = true;
|
||||
vm.PasswordInput = vm.ConfirmPasswordInput = "pw";
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
Assert.Contains("imported", vm.NetworkInfo);
|
||||
var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)), "pw");
|
||||
Assert.Equal(wif, Assert.Single(stored.WifKeys!));
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task ImportXprv_CreatesASpendableWallet() => UiSession.Run(() =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartImportXkeyCommand.Execute(null);
|
||||
vm.ImportXkeyInput = account.ToSlip132Private();
|
||||
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||
vm.EncryptWallet = false;
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
Assert.False(vm.IsWatchOnlyAccount);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task ClosingTheWallet_ReleasesTheLock_SoItCanBeReopened() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "pw", "relock");
|
||||
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||
Assert.Null(WalletLock.TryAcquire(path)); // held while open
|
||||
|
||||
vm.CloseWalletCommand.Execute(null);
|
||||
|
||||
using var l = WalletLock.TryAcquire(path); // released on close
|
||||
Assert.NotNull(l);
|
||||
Assert.False(vm.IsWalletOpen);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using Avalonia.Threading;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.App;
|
||||
|
||||
/// <summary>
|
||||
/// Headless tests of the single application ViewModel. Everything is sandboxed: the data
|
||||
/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the
|
||||
/// auto-connect never reaches a real server) and the GitHub update check is stubbed.
|
||||
/// </summary>
|
||||
[Collection("AppPaths")]
|
||||
public sealed class MainWindowViewModelTests : VmTestBase
|
||||
{
|
||||
// ---- startup, overlays, settings ----
|
||||
|
||||
[Fact]
|
||||
public Task Startup_ShowsTheStartStep_WithNoWalletOpen() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
Assert.True(vm.IsStepStart);
|
||||
Assert.False(vm.IsWalletOpen);
|
||||
Assert.True(vm.IsSetupVisible);
|
||||
Assert.StartsWith("Palladium Wallet", vm.WindowTitle);
|
||||
Assert.True(vm.IsDesktop);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task SelectTab_ChangesTheSelectedIndex() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.SelectTabCommand.Execute("3");
|
||||
Assert.Equal(3, vm.SelectedTabIndex);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task SettingsAndHelpOverlays_OpenAndClose() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.OpenSettingsCommand.Execute(null);
|
||||
Assert.True(vm.IsSettingsOpen);
|
||||
vm.CloseSettingsCommand.Execute(null);
|
||||
Assert.False(vm.IsSettingsOpen);
|
||||
vm.OpenHelpCommand.Execute(null);
|
||||
Assert.True(vm.IsHelpOpen);
|
||||
vm.CloseHelpCommand.Execute(null);
|
||||
Assert.False(vm.IsHelpOpen);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task SetLanguage_SwitchesTheLocInstance_AndPersistsIt() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
var before = vm.Loc;
|
||||
|
||||
vm.SetLanguageCommand.Execute("it");
|
||||
|
||||
Assert.NotSame(before, vm.Loc); // new instance → bindings re-evaluate
|
||||
Assert.Equal("it", vm.Loc.Language);
|
||||
Assert.Equal("it", AppConfig.Load(AppPaths.ConfigPath()).Language);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task AvailableUpdate_OpensTheUpdateOverlay() => UiSession.Run(async () =>
|
||||
{
|
||||
MainWindowViewModel.UpdateCheck = _ => Task.FromResult<LatestRelease?>(new LatestRelease("v9.9.9", "https://example.test/r"));
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
await Eventually(() => vm.IsUpdateAvailableOpen);
|
||||
|
||||
Assert.Equal("v9.9.9", vm.UpdateAvailableTag);
|
||||
Assert.Equal("https://example.test/r", vm.UpdateReleaseUrl);
|
||||
vm.CloseUpdateAvailableCommand.Execute(null);
|
||||
Assert.False(vm.IsUpdateAvailableOpen);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task NoUpdate_LeavesTheOverlayClosed() => UiSession.Run(async () =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
await Task.Delay(100);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
Assert.False(vm.IsUpdateAvailableOpen);
|
||||
});
|
||||
|
||||
// ---- wizard ----
|
||||
|
||||
[Fact]
|
||||
public Task Restore_WithInvalidMnemonic_StaysOnTheWordsStep_WithAnError() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartRestoreCommand.Execute(null);
|
||||
vm.MnemonicInput = "abandon abandon abandon";
|
||||
|
||||
vm.WizardNextFromWordsCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsStepWords);
|
||||
Assert.Equal(Loc.Tr("msg.words.invalid"), vm.StatusMessage);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task NewWallet_ConfirmingTheWrongSeed_IsRejected() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartNewCommand.Execute(null);
|
||||
Assert.True(vm.IsStepShowSeed);
|
||||
Assert.True(Bip39.TryParse(vm.MnemonicInput, out _));
|
||||
vm.WizardNextFromShowSeedCommand.Execute(null);
|
||||
vm.ConfirmMnemonicInput = Mnemonic; // not the displayed one
|
||||
|
||||
vm.WizardNextFromConfirmSeedCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsStepConfirmSeed);
|
||||
Assert.Equal(Loc.Tr("msg.seed.mismatch"), vm.StatusMessage);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task NewWallet_ConfirmingTheRightSeed_AdvancesToThePassphraseStep() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartNewCommand.Execute(null);
|
||||
vm.WizardNextFromShowSeedCommand.Execute(null);
|
||||
vm.ConfirmMnemonicInput = vm.MnemonicInput.ToUpperInvariant();
|
||||
|
||||
vm.WizardNextFromConfirmSeedCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsStepPassphrase);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Password_Mismatch_OrEmpty_DoesNotCreateAnyFile() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartRestoreCommand.Execute(null);
|
||||
vm.MnemonicInput = Mnemonic;
|
||||
vm.WizardNextFromWordsCommand.Execute(null);
|
||||
vm.WizardNextFromPassphraseCommand.Execute(null);
|
||||
vm.WizardNextFromScriptTypeCommand.Execute(null);
|
||||
|
||||
vm.PasswordInput = vm.ConfirmPasswordInput = "";
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
Assert.Equal(Loc.Tr("msg.password.required"), vm.StatusMessage);
|
||||
|
||||
vm.PasswordInput = "one"; vm.ConfirmPasswordInput = "two";
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
Assert.Equal(Loc.Tr("msg.password.mismatch"), vm.StatusMessage);
|
||||
|
||||
Assert.False(vm.IsWalletOpen);
|
||||
Assert.Empty(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Restore_WithPassword_CreatesAnEncryptedWallet_AndOpensIt() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
RestoreFlow(vm, Mnemonic, "s3cret", "main");
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
var file = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||
var content = File.ReadAllText(file);
|
||||
Assert.True(EncryptedFile.IsEncrypted(content));
|
||||
Assert.DoesNotContain("abandon", content); // seed never in plaintext on disk
|
||||
Assert.Contains("NativeSegwit", vm.NetworkInfo);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Restore_WithoutEncryption_StoresPlainJson() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, password: null, name: "plain");
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
Assert.False(EncryptedFile.IsEncrypted(File.ReadAllText(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)))));
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Restore_OverAnExistingName_DoesNotOverwriteTheWallet() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "pw", "dup");
|
||||
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||
var before = File.ReadAllBytes(path);
|
||||
vm.CloseWalletCommand.Execute(null);
|
||||
|
||||
RestoreFlow(vm, Mnemonic, "other", "dup");
|
||||
|
||||
Assert.Equal(Loc.Tr("msg.wallet.exists"), vm.StatusMessage);
|
||||
Assert.Equal(before, File.ReadAllBytes(path));
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task ImportAddress_CreatesAWatchOnlyWallet() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
|
||||
.GetReceiveAddress(0).ToString();
|
||||
|
||||
vm.WizardStartImportAddressCommand.Execute(null);
|
||||
vm.ImportAddressInput = address;
|
||||
vm.WizardNextFromImportAddressCommand.Execute(null);
|
||||
vm.EncryptWallet = false;
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
Assert.True(vm.IsWatchOnlyAccount);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task ImportXpub_CreatesAWatchOnlyWallet_ButANonsenseKeyIsRejected() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.WizardStartImportXkeyCommand.Execute(null);
|
||||
vm.ImportXkeyInput = "not-an-extended-key";
|
||||
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||
Assert.True(vm.IsStepImportXkey);
|
||||
Assert.Equal(Loc.Tr("msg.xkey.invalid"), vm.StatusMessage);
|
||||
|
||||
vm.ImportXkeyInput = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToSlip132();
|
||||
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||
vm.EncryptWallet = false;
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
Assert.True(vm.IsWatchOnlyAccount);
|
||||
});
|
||||
|
||||
|
||||
// ---- open existing ----
|
||||
|
||||
[Fact]
|
||||
public Task OpenExisting_WithWrongPassword_ShowsAnError_AndStaysClosed() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "right", "w1");
|
||||
vm.CloseWalletCommand.Execute(null);
|
||||
|
||||
vm.WizardStartOpenCommand.Execute(null);
|
||||
vm.PasswordInput = "wrong";
|
||||
vm.OpenExistingCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.IsWalletOpen);
|
||||
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.StatusMessage);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task OpenExisting_WithTheRightPassword_OpensTheWallet() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "right", "w1");
|
||||
vm.CloseWalletCommand.Execute(null);
|
||||
|
||||
vm.WizardStartOpenCommand.Execute(null);
|
||||
vm.PasswordInput = "right";
|
||||
vm.OpenExistingCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task OpenExisting_WhileAnotherInstanceHoldsTheLock_IsRefused() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, "pw", "locked");
|
||||
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||
// The first VM still holds the exclusive lock: a second instance must not open it.
|
||||
var second = new MainWindowViewModel();
|
||||
second.WizardStartOpenCommand.Execute(null);
|
||||
second.PasswordInput = "pw";
|
||||
second.OpenExistingCommand.Execute(null);
|
||||
|
||||
Assert.False(second.IsWalletOpen);
|
||||
Assert.Equal(Loc.Tr("msg.wallet.locked"), second.StatusMessage);
|
||||
Assert.True(File.Exists(path));
|
||||
});
|
||||
|
||||
// ---- contacts ----
|
||||
|
||||
[Fact]
|
||||
public Task Contacts_AreAddedPersistedAndRemoved() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "c");
|
||||
|
||||
var alice = Address(1);
|
||||
vm.NewContactName = "Alice"; vm.NewContactAddress = alice;
|
||||
vm.AddContactCommand.Execute(null);
|
||||
vm.NewContactName = ""; vm.NewContactAddress = "ignored";
|
||||
vm.AddContactCommand.Execute(null); // blank name → ignored
|
||||
|
||||
Assert.Single(vm.Contacts);
|
||||
var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)));
|
||||
Assert.Equal("Alice", Assert.Single(stored.Contacts).Name);
|
||||
|
||||
vm.SelectedContactInList = vm.Contacts[0];
|
||||
vm.RemoveSelectedContactCommand.Execute(null);
|
||||
Assert.Empty(vm.Contacts);
|
||||
Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts);
|
||||
});
|
||||
|
||||
private static string Address(int index) =>
|
||||
HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
|
||||
.GetReceiveAddress(index).ToString();
|
||||
|
||||
[Theory]
|
||||
[InlineData("plm1qexample")] // garbage
|
||||
[InlineData("not an address")]
|
||||
[InlineData("tplm1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")] // wrong network / bad checksum
|
||||
public Task Contacts_RejectInvalidAddresses(string bad) => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "c1");
|
||||
|
||||
vm.NewContactName = "Bob"; vm.NewContactAddress = bad;
|
||||
vm.AddContactCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.Contacts);
|
||||
Assert.Equal(Loc.Tr("msg.contact.address.invalid"), vm.StatusMessage);
|
||||
Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Contacts_RejectAnAddressFromAnotherNetwork() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "c2");
|
||||
var regtest = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Regtest)
|
||||
.GetReceiveAddress(0).ToString();
|
||||
|
||||
vm.NewContactName = "Eve"; vm.NewContactAddress = regtest;
|
||||
vm.AddContactCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.Contacts);
|
||||
Assert.Equal(Loc.Tr("msg.contact.address.invalid"), vm.StatusMessage);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Contacts_RejectDuplicatesAndCleanPastedWhitespace() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "c3");
|
||||
var a = Address(2);
|
||||
|
||||
vm.NewContactName = " Carol "; vm.NewContactAddress = $" {a[..10]}\n{a[10..]}\r\n";
|
||||
vm.AddContactCommand.Execute(null);
|
||||
Assert.Equal(new ContactEntry("Carol", a), Assert.Single(vm.Contacts));
|
||||
|
||||
vm.NewContactName = "Carol again"; vm.NewContactAddress = a.ToUpperInvariant();
|
||||
vm.AddContactCommand.Execute(null);
|
||||
Assert.Single(vm.Contacts);
|
||||
Assert.Equal(Loc.Tr("msg.contact.duplicate"), vm.StatusMessage);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Contacts_AlreadyStoredWithAnInvalidAddress_StillLoadAndDisplay() => UiSession.Run(() =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "c4");
|
||||
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||
var doc = WalletStore.Load(path);
|
||||
doc.Contacts = [new StoredContact { Name = "Legacy", Address = "whatever" }];
|
||||
WalletStore.Save(doc, path, null);
|
||||
vm.CloseWalletCommand.Execute(null);
|
||||
|
||||
vm.WizardStartOpenCommand.Execute(null);
|
||||
vm.OpenExistingCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.IsWalletOpen);
|
||||
Assert.Equal(new ContactEntry("Legacy", "whatever"), Assert.Single(vm.Contacts));
|
||||
});
|
||||
|
||||
// ---- sync + send against the fake server ----
|
||||
|
||||
[Fact]
|
||||
public Task OpeningAFundedWallet_SyncsAgainstTheServer_AndShowsTheVerifiedHistory() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 2_500_000);
|
||||
var vm = new MainWindowViewModel();
|
||||
|
||||
RestoreFlow(vm, Mnemonic, null, "funded");
|
||||
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified,
|
||||
describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'");
|
||||
|
||||
Assert.True(vm.History[0].Verified);
|
||||
Assert.Contains("0.025", vm.BalanceText);
|
||||
Assert.NotEmpty(vm.Addresses);
|
||||
Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task HistoryRows_AboveTheNewestCheckpoint_AreFlaggedAsUnanchored() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 2_500_000); // confirmed at height 100
|
||||
// The fake serves the same header for every height: use its hash as a checkpoint at 50.
|
||||
string headerHash;
|
||||
await using (var c = await ElectrumClient.ConnectAsync(_server.Host, _server.Port, useSsl: false))
|
||||
headerHash = BlockHeaderInfo.Parse(await c.GetBlockHeaderAsync(50)).Hash.ToString();
|
||||
MainWindowViewModel.ProfileOverride = ChainProfiles.Mainnet with
|
||||
{
|
||||
BootstrapServers = [],
|
||||
Checkpoints = [new Checkpoint(50, headerHash, 0x1d00ffff)],
|
||||
};
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "unanch");
|
||||
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified,
|
||||
describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'");
|
||||
|
||||
Assert.True(vm.History[0].Verified); // the Merkle proof checks out
|
||||
Assert.True(vm.History[0].Unanchored); // but height 100 is above the checkpoint at 50
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task HistoryRows_CoveredByACheckpoint_AreNotFlagged() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 2_500_000);
|
||||
var vm = new MainWindowViewModel(); // default test profile: no checkpoints
|
||||
RestoreFlow(vm, Mnemonic, null, "anch");
|
||||
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1);
|
||||
|
||||
Assert.False(vm.History[0].Unanchored);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task FeePresets_ComeFromTheServer_AndTheNormalEstimateIsPreselected() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
|
||||
_server.Handle("blockchain.relayfee", _ => 0.00001m);
|
||||
_server.Handle("blockchain.estimatefee", p => p[0].GetInt32() switch
|
||||
{
|
||||
FeePolicy.FastTarget => 0.00005m,
|
||||
FeePolicy.NormalTarget => 0.00003m,
|
||||
_ => 0.00001m,
|
||||
});
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "fees");
|
||||
await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing);
|
||||
|
||||
Assert.Equal("3", vm.SendFeeRate); // normal, preselected
|
||||
Assert.Contains("5", vm.FeeEstimateHint);
|
||||
vm.UseFeePresetCommand.Execute("fast");
|
||||
Assert.Equal("5", vm.SendFeeRate);
|
||||
vm.UseFeePresetCommand.Execute("economy");
|
||||
Assert.Equal("1", vm.SendFeeRate);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task FeePresets_DoNotOverrideARateTheUserAlreadyTyped() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
|
||||
_server.Handle("blockchain.relayfee", _ => 0.00001m);
|
||||
_server.Handle("blockchain.estimatefee", _ => 0.00004m);
|
||||
var vm = new MainWindowViewModel();
|
||||
vm.SendFeeRate = "7"; // typed before the estimates arrive
|
||||
RestoreFlow(vm, Mnemonic, null, "fees2");
|
||||
await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing);
|
||||
|
||||
Assert.Equal("7", vm.SendFeeRate);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task WithoutServerEstimates_TheManualDefaultStays_AndNoPresetsAreShown() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 50_000_000); // the fake answers "unknown method"
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "fees3");
|
||||
await Eventually(() => !vm.IsSyncing && vm.History.Count == 1);
|
||||
await Task.Delay(300); // let the optional estimate request settle
|
||||
|
||||
Assert.False(vm.HasFeeEstimates);
|
||||
Assert.Equal("1", vm.SendFeeRate);
|
||||
vm.UseFeePresetCommand.Execute("fast"); // nothing to apply
|
||||
Assert.Equal("1", vm.SendFeeRate);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Send_RejectsARateBelowTheRelayFee_AndWarnsWhenTheFeeIsLarge() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
|
||||
_server.Handle("blockchain.relayfee", _ => 0.00002m); // 2 sat/vB
|
||||
_server.Handle("blockchain.estimatefee", _ => 0.00002m);
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "fees4");
|
||||
await Eventually(() => vm.HasFeeEstimates && !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified,
|
||||
describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'");
|
||||
|
||||
vm.SendTo = account.GetReceiveAddress(7).ToString();
|
||||
vm.SendAmount = "0.1";
|
||||
vm.SendFeeRate = "1"; // below the relay fee
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.False(vm.HasPendingSend);
|
||||
Assert.Equal(string.Format(Loc.Tr("msg.feerate.belowrelay"), "2"), vm.SendPreview);
|
||||
|
||||
vm.SendFeeRate = "2";
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.True(vm.HasPendingSend, vm.SendPreview);
|
||||
Assert.DoesNotContain(string.Format(Loc.Tr("msg.fee.high"), 1), vm.SendPreview);
|
||||
|
||||
vm.SendAmount = "0.00001"; // 1,000 sat: the fee is >1%
|
||||
vm.SendFeeRate = "50";
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.True(vm.HasPendingSend, vm.SendPreview);
|
||||
Assert.Contains(string.Format(Loc.Tr("msg.fee.high"), 1), vm.SendPreview);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Send_PreparesASignedTransaction_ForAFundedWallet_AndRejectsABadAmount() => UiSession.Run(async () =>
|
||||
{
|
||||
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
|
||||
var vm = new MainWindowViewModel();
|
||||
RestoreFlow(vm, Mnemonic, null, "sender");
|
||||
await Eventually(() => !vm.IsSyncing && vm.History.Count == 1 && vm.History[0].Verified,
|
||||
describe: () => $"connected={vm.IsConnected} syncing={vm.IsSyncing} rows={vm.History.Count} verified={vm.History.FirstOrDefault()?.Verified} status='{vm.StatusMessage}'");
|
||||
// Coins need MinConfirmations (tip 200, height 100 → 101 confirmations): spendable.
|
||||
|
||||
vm.SendTo = account.GetReceiveAddress(7).ToString();
|
||||
vm.SendAmount = "not-a-number";
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.Equal(Loc.Tr("msg.amount.invalid"), vm.SendPreview);
|
||||
Assert.False(vm.HasPendingSend);
|
||||
|
||||
vm.SendAmount = "0.1";
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.True(vm.HasPendingSend, vm.SendPreview);
|
||||
Assert.Contains("txid", vm.SendPreview);
|
||||
|
||||
vm.SendAmount = "999";
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.False(vm.HasPendingSend);
|
||||
Assert.Contains(Loc.Tr("msg.error"), vm.SendPreview);
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public Task Send_BeforeAnySync_AsksToSyncFirst() => UiSession.Run(async () =>
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||
Assert.Equal(Loc.Tr("msg.send.sync.first"), vm.SendPreview);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Avalonia.Threading;
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.App.Localization;
|
||||
using PalladiumWallet.App.ViewModels;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
using PalladiumWallet.Core.Wallet;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.App;
|
||||
|
||||
/// <summary>
|
||||
/// Headless tests of the single application ViewModel. Everything is sandboxed: the data
|
||||
/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the
|
||||
/// auto-connect never reaches a real server) and the GitHub update check is stubbed.
|
||||
/// </summary>
|
||||
[Collection("AppPaths")] // static AppPaths / Loc / update-check seam
|
||||
public abstract class VmTestBase : IDisposable
|
||||
{
|
||||
protected const string Mnemonic =
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"plm-vm-{Guid.NewGuid()}");
|
||||
private readonly string? _savedOverride = AppPaths.OverrideDataRoot;
|
||||
private readonly Func<string, Task<LatestRelease?>> _savedUpdateCheck = MainWindowViewModel.UpdateCheck;
|
||||
protected readonly FakeElectrumServer _server = new();
|
||||
|
||||
protected VmTestBase()
|
||||
{
|
||||
AppPaths.OverrideDataRoot = _root;
|
||||
MainWindowViewModel.UpdateCheck = _ => Task.FromResult<LatestRelease?>(null);
|
||||
// No bootstrap servers: the only reachable server is the in-process fake, so the
|
||||
// VM's connect/reconnect loops can never touch a real indexing server.
|
||||
// No checkpoints either: the fake chains use tiny heights, and with the real mainnet checkpoints
|
||||
// every proof would have to be anchored up to height 20,000 (ADR 0040), which the fake
|
||||
// server does not serve. Tests that care about anchoring set their own profile.
|
||||
MainWindowViewModel.ProfileOverride = ChainProfiles.Mainnet with { BootstrapServers = [], Checkpoints = [] };
|
||||
new AppConfig { LastServerHost = _server.Host, LastServerPort = _server.Port, LastServerUseSsl = false }
|
||||
.Save(AppPaths.ConfigPath());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
MainWindowViewModel.UpdateCheck = _savedUpdateCheck;
|
||||
// ProfileOverride is intentionally left in place: ViewModels created by a test keep their
|
||||
// keep-alive timers running, and restoring the real profile would let them reconnect to
|
||||
// the real bootstrap servers after the test is over. Nothing else in the suite uses it.
|
||||
AppPaths.OverrideDataRoot = _savedOverride;
|
||||
Loc.SwitchTo("en");
|
||||
_server.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
protected async Task Eventually(Func<bool> condition, int timeoutMs = 10_000, Func<string>? describe = null)
|
||||
{
|
||||
for (var waited = 0; !condition(); waited += 20)
|
||||
{
|
||||
Assert.True(waited < timeoutMs, "condition not reached in time" + (describe is null ? "" : $": {describe()}") + $" | calls: {Calls()}");
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private string Calls() => string.Join(", ", new[]
|
||||
{
|
||||
"blockchain.headers.subscribe", "blockchain.scripthash.get_history", "blockchain.transaction.get",
|
||||
"blockchain.transaction.get_merkle", "blockchain.block.header", "blockchain.block.headers",
|
||||
}.Select(m => $"{m.Split('.')[^1]}={_server.CallCount(m)}"));
|
||||
|
||||
protected static void RestoreFlow(MainWindowViewModel vm, string mnemonic, string? password, string? name = null)
|
||||
{
|
||||
vm.WizardStartRestoreCommand.Execute(null);
|
||||
vm.MnemonicInput = mnemonic;
|
||||
vm.WizardNextFromWordsCommand.Execute(null);
|
||||
vm.WizardNextFromPassphraseCommand.Execute(null);
|
||||
vm.WizardNextFromScriptTypeCommand.Execute(null);
|
||||
vm.EncryptWallet = password is not null;
|
||||
vm.PasswordInput = vm.ConfirmPasswordInput = password ?? "";
|
||||
vm.WalletNameInput = name ?? "";
|
||||
vm.CreateOrRestoreCommand.Execute(null);
|
||||
}
|
||||
|
||||
protected static Mnemonic ParseMnemonic()
|
||||
{
|
||||
Assert.True(Bip39.TryParse(Mnemonic, out var m));
|
||||
return m!;
|
||||
}
|
||||
|
||||
protected (Transaction Funding, string TxHex) WireFundedChain(BitcoinAddress to, long sats)
|
||||
{
|
||||
var net = PalladiumNetworks.Mainnet;
|
||||
var tx = net.CreateTransaction();
|
||||
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||
tx.Outputs.Add(Money.Satoshis(sats), to);
|
||||
var txid = tx.GetHash();
|
||||
var scripthash = Scripthash.FromAddress(to);
|
||||
|
||||
string Header(uint256 root, int h)
|
||||
{
|
||||
var header = net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||
header.HashPrevBlock = uint256.Zero;
|
||||
header.HashMerkleRoot = root;
|
||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + h);
|
||||
header.Bits = new Target(0x1d00ffffu);
|
||||
header.Nonce = (uint)h;
|
||||
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
_server.Handle("blockchain.headers.subscribe", _ => new { height = 200, hex = Header(uint256.One, 200) });
|
||||
_server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||
_server.Handle("blockchain.scripthash.get_history", p =>
|
||||
p[0].GetString() == scripthash ? new[] { new { tx_hash = txid.ToString(), height = 100 } } : Array.Empty<object>());
|
||||
_server.Handle("blockchain.transaction.get", _ => tx.ToHex());
|
||||
_server.Handle("blockchain.transaction.get_merkle",
|
||||
_ => new { block_height = 100, pos = 0, merkle = Array.Empty<string>() });
|
||||
_server.Handle("blockchain.block.header", _ => Header(txid, 100));
|
||||
_server.Handle("blockchain.block.headers", _ => new { count = 0, hex = "" });
|
||||
return (tx, tx.ToHex());
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,23 @@ public class ChainProfileTests
|
||||
data[3] = (byte)header;
|
||||
return Encoders.Base58Check.EncodeData(data);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)] // unconfirmed
|
||||
[InlineData(1, false)]
|
||||
[InlineData(100, false)] // at the newest checkpoint: still anchored
|
||||
[InlineData(101, true)]
|
||||
[InlineData(5_000_000, true)]
|
||||
public void IsAboveNewestCheckpoint_flags_only_heights_the_anchoring_cannot_cover(int height, bool expected)
|
||||
{
|
||||
var profile = ChainProfiles.Mainnet with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(50, "00", 0), new Checkpoint(100, "00", 0)],
|
||||
};
|
||||
Assert.Equal(expected, profile.IsAboveNewestCheckpoint(height));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_profile_without_checkpoints_never_reports_an_unanchored_height() =>
|
||||
Assert.False((ChainProfiles.Mainnet with { Checkpoints = [] }).IsAboveNewestCheckpoint(1_000_000));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using NBitcoin;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Crypto;
|
||||
|
||||
namespace PalladiumWallet.Tests.Crypto;
|
||||
|
||||
public class DerivationPathsEdgeCaseTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ScriptKind.WrappedSegwitMultisig)]
|
||||
[InlineData(ScriptKind.NativeSegwitMultisig)]
|
||||
public void Multisig_Kinds_FailLoudlyInsteadOfDerivingAWrongWallet(ScriptKind kind) =>
|
||||
Assert.Throws<NotSupportedException>(() => DerivationPaths.ScriptPubKeyTypeFor(kind));
|
||||
|
||||
[Fact]
|
||||
public void Multisig_UsesBip48Purpose_AndTaprootBip86()
|
||||
{
|
||||
Assert.Equal(48, DerivationPaths.PurposeFor(ScriptKind.NativeSegwitMultisig));
|
||||
Assert.Equal(48, DerivationPaths.PurposeFor(ScriptKind.WrappedSegwitMultisig));
|
||||
Assert.Equal(86, DerivationPaths.PurposeFor(ScriptKind.Taproot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownScriptKind_IsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DerivationPaths.PurposeFor((ScriptKind)999));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DerivationPaths.ScriptPubKeyTypeFor((ScriptKind)999));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeAccountOrIndex_IsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet, -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DerivationPaths.AddressSubPath(false, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountPath_UsesCoinTypeFromProfile()
|
||||
{
|
||||
Assert.Equal("84'/746'/0'", DerivationPaths.AccountPath(ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToString());
|
||||
Assert.Equal("49'/746'/2'", DerivationPaths.AccountPath(ScriptKind.WrappedSegwit, ChainProfiles.Mainnet, 2).ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KindFor_MapsAddressTypes()
|
||||
{
|
||||
var net = PalladiumNetworks.Regtest;
|
||||
var key = new Key();
|
||||
Assert.Equal(ScriptKind.NativeSegwit, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.Segwit, net)));
|
||||
Assert.Equal(ScriptKind.WrappedSegwit, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.SegwitP2SH, net)));
|
||||
Assert.Equal(ScriptKind.Legacy, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.Legacy, net)));
|
||||
Assert.Equal(ScriptKind.Taproot, DerivationPaths.KindFor(key.PubKey.GetAddress(ScriptPubKeyType.TaprootBIP86, net)));
|
||||
Assert.Equal(ScriptKind.NativeSegwitMultisig,
|
||||
DerivationPaths.KindFor(key.PubKey.WitHash.ScriptPubKey.WitHash.GetAddress(net)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Storage;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>Concurrency guarantees: the in-flight cap, disposal under load, exclusive wallet lock.</summary>
|
||||
public class ConcurrencyTests
|
||||
{
|
||||
// Generous: the rest of the suite (property tests) saturates the CPU while these run.
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private static async Task WaitFor(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + Timeout;
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(DateTime.UtcNow < deadline, "condition not reached in time");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InFlightRequests_AreCappedAtTheConfiguredLimit()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("hold", _ => FakeElectrumServer.NoResponse); // received, never answered
|
||||
await using var client = await ElectrumClient.ConnectAsync(
|
||||
server.Host, server.Port, useSsl: false, maxInFlight: 4);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var requests = Enumerable.Range(0, 20)
|
||||
.Select(_ => client.RequestAsync("hold", cts.Token)).ToList();
|
||||
|
||||
await WaitFor(() => server.CallCount("hold") == 4);
|
||||
await Task.Delay(250); // the other 16 must stay queued client-side, not reach the server
|
||||
Assert.Equal(4, server.CallCount("hold"));
|
||||
|
||||
cts.Cancel();
|
||||
foreach (var r in requests)
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => r.WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletedRequests_ReleaseTheirSlot_SoAllRequestsEventuallyFinish()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("echo", p => p[0].GetInt32());
|
||||
await using var client = await ElectrumClient.ConnectAsync(
|
||||
server.Host, server.Port, useSsl: false, maxInFlight: 2);
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 100)
|
||||
.Select(i => client.RequestAsync("echo", default, i))).WaitAsync(Timeout);
|
||||
|
||||
Assert.Equal(Enumerable.Range(0, 100), results.Select(r => r.GetInt32()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposingTheClient_WithRequestsInFlight_DoesNotHang()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("hold", _ => FakeElectrumServer.NoResponse);
|
||||
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
var pending = Enumerable.Range(0, 10).Select(_ => client.RequestAsync("hold")).ToList();
|
||||
await WaitFor(() => server.CallCount("hold") == 10);
|
||||
|
||||
await client.DisposeAsync().AsTask().WaitAsync(Timeout);
|
||||
|
||||
foreach (var p in pending)
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => p.WaitAsync(Timeout)); // failed, not stuck
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WalletLock_UnderContention_HasExactlyOneWinner()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"plm-lock-{Guid.NewGuid()}.wallet.json");
|
||||
try
|
||||
{
|
||||
using var start = new ManualResetEventSlim(false);
|
||||
var attempts = Enumerable.Range(0, 16)
|
||||
.Select(_ => Task.Run(() => { start.Wait(); return WalletLock.TryAcquire(path); }))
|
||||
.ToList();
|
||||
start.Set();
|
||||
var locks = await Task.WhenAll(attempts);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Single(locks, l => l is not null);
|
||||
Assert.Equal(15, locks.Count(l => l is null));
|
||||
}
|
||||
finally { foreach (var l in locks) l?.Dispose(); }
|
||||
|
||||
// Released → acquirable again (no stale lock left behind).
|
||||
using var again = WalletLock.TryAcquire(path);
|
||||
Assert.NotNull(again);
|
||||
}
|
||||
finally { try { File.Delete(path); File.Delete(path + ".lock"); } catch { } }
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,40 @@ public class ElectrumClientTests
|
||||
await disconnected.Task.WaitAsync(Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_line_from_the_server_fails_pending_requests_and_reports_the_error()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var disconnected = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += ex => disconnected.TrySetResult(ex);
|
||||
|
||||
var pending = client.BannerAsync();
|
||||
await server.SendRawAsync("this is not json");
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
|
||||
Assert.NotNull(await disconnected.Task.WaitAsync(Timeout)); // failure, not a clean close
|
||||
Assert.False(client.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Requests_after_the_connection_died_fail_fast_instead_of_hanging()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += _ => disconnected.TrySetResult();
|
||||
await server.SendRawAsync("garbage"); // kills the read loop
|
||||
await disconnected.Task.WaitAsync(Timeout);
|
||||
|
||||
// The read loop is gone: nobody would ever complete this request.
|
||||
await Assert.ThrowsAnyAsync<IOException>(() => client.RequestAsync("server.ping").WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Una_risposta_molto_grande_arriva_integra()
|
||||
{
|
||||
@@ -113,6 +147,134 @@ public class ElectrumClientTests
|
||||
Assert.Equal(big, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_just_below_the_line_limit_arrives_intact()
|
||||
{
|
||||
var big = new string('x', 60_000); // line = 60 KB + JSON envelope, below the 64 KB cap
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get", _ => big);
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, maxLineBytes: 64 * 1024);
|
||||
|
||||
Assert.Equal(big, await client.GetTransactionAsync("00").WaitAsync(Timeout));
|
||||
Assert.True(client.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_terminated_line_above_the_limit_kills_the_connection()
|
||||
{
|
||||
var big = new string('x', 100_000);
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get", _ => big);
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, maxLineBytes: 64 * 1024);
|
||||
var disconnected = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += ex => disconnected.TrySetResult(ex);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => client.GetTransactionAsync("00").WaitAsync(Timeout));
|
||||
Assert.IsType<InvalidDataException>(await disconnected.Task.WaitAsync(Timeout));
|
||||
Assert.False(client.IsConnected);
|
||||
await Assert.ThrowsAsync<IOException>(() => client.GetTransactionAsync("00").WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unterminated_line_above_the_limit_fails_before_any_newline_arrives()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, maxLineBytes: 64 * 1024);
|
||||
var disconnected = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += ex => disconnected.TrySetResult(ex);
|
||||
|
||||
var pending = client.BannerAsync();
|
||||
await server.SendUnterminatedAsync(new string('x', 100_000)); // never followed by '\n'
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => pending.WaitAsync(Timeout));
|
||||
Assert.IsType<InvalidDataException>(await disconnected.Task.WaitAsync(Timeout));
|
||||
Assert.False(client.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_without_an_answer_fails_with_TimeoutException()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, requestTimeout: TimeSpan.FromMilliseconds(300));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() => client.BannerAsync().WaitAsync(Timeout));
|
||||
Assert.Contains("server.banner", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_a_timeout_the_client_is_disconnected_and_later_requests_fail_fast()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, requestTimeout: TimeSpan.FromMilliseconds(300));
|
||||
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += _ => disconnected.TrySetResult();
|
||||
|
||||
await Assert.ThrowsAsync<TimeoutException>(() => client.BannerAsync().WaitAsync(Timeout));
|
||||
await disconnected.Task.WaitAsync(Timeout);
|
||||
|
||||
Assert.False(client.IsConnected);
|
||||
await Assert.ThrowsAsync<IOException>(() => client.BannerAsync().WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_timeout_fails_the_other_pending_requests_too()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, requestTimeout: TimeSpan.FromMilliseconds(300));
|
||||
|
||||
var a = client.BannerAsync();
|
||||
var b = client.BannerAsync();
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => a.WaitAsync(Timeout));
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => b.WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_slow_answer_within_the_timeout_still_succeeds()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => { Thread.Sleep(200); return "hello"; });
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, requestTimeout: TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal("hello", await client.BannerAsync().WaitAsync(Timeout));
|
||||
Assert.True(client.IsConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_handshake_that_never_completes_times_out()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.version", _ => FakeElectrumServer.NoResponse);
|
||||
|
||||
await Assert.ThrowsAsync<TimeoutException>(() => ElectrumClient.ConnectAsync(
|
||||
server.Host, server.Port, useSsl: false,
|
||||
handshakeTimeout: TimeSpan.FromMilliseconds(300)).WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waiting_for_an_in_flight_slot_does_not_count_towards_the_timeout()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => { Thread.Sleep(250); return "ok"; });
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port,
|
||||
useSsl: false, maxInFlight: 1, requestTimeout: TimeSpan.FromMilliseconds(600));
|
||||
|
||||
// Three serial requests of ~250 ms each: the last one waits ~500 ms for the slot.
|
||||
var all = await Task.WhenAll(client.BannerAsync(), client.BannerAsync(), client.BannerAsync())
|
||||
.WaitAsync(Timeout);
|
||||
Assert.All(all, b => Assert.Equal("ok", b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
||||
{
|
||||
|
||||
@@ -44,6 +44,12 @@ public sealed class FakeElectrumServer : IAsyncDisposable
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Start();
|
||||
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
|
||||
// Reports the mainnet genesis, as a real server does; override to simulate another chain.
|
||||
Handle("server.features", _ => new
|
||||
{
|
||||
genesis_hash = PalladiumWallet.Core.Chain.ChainProfiles.Mainnet.GenesisHash,
|
||||
hash_function = "sha256",
|
||||
});
|
||||
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
@@ -71,6 +77,27 @@ public sealed class FakeElectrumServer : IAsyncDisposable
|
||||
await WriteLineAsync(stream, writeLock, line);
|
||||
}
|
||||
|
||||
/// <summary>Sends an arbitrary (possibly malformed) line to every connected client.</summary>
|
||||
public async Task SendRawAsync(string line)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(line);
|
||||
foreach (var (stream, writeLock, _) in _clients)
|
||||
await WriteLineAsync(stream, writeLock, bytes);
|
||||
}
|
||||
|
||||
/// <summary>Sends raw bytes with no trailing newline (an unterminated, still-growing line).</summary>
|
||||
public async Task SendUnterminatedAsync(string text)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
foreach (var (stream, writeLock, _) in _clients)
|
||||
{
|
||||
await writeLock.WaitAsync();
|
||||
try { await stream.WriteAsync(bytes); await stream.FlushAsync(); }
|
||||
catch (Exception) { }
|
||||
finally { writeLock.Release(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
|
||||
public void DropClients()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in conformance run of the ServerProbe against real Electrum/Fulcrum servers.
|
||||
/// Does nothing (passes) unless <c>PALLADIUM_TEST_SERVERS</c> is set, so the default
|
||||
/// <c>dotnet test</c> stays offline and deterministic.
|
||||
///
|
||||
/// PALLADIUM_TEST_SERVERS=host1:50002,host2:50002 servers to probe (comma separated)
|
||||
/// PALLADIUM_TEST_SSL=1 use TLS
|
||||
/// PALLADIUM_TEST_NET=mainnet|testnet|regtest network (default mainnet)
|
||||
///
|
||||
/// Example: PALLADIUM_TEST_SERVERS=1.2.3.4:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer
|
||||
/// </summary>
|
||||
[Collection("AppPaths")] // serialized with other tests touching process-wide state (Console, AppPaths)
|
||||
public class LiveServerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LiveServer_PassesProbe()
|
||||
{
|
||||
var servers = Environment.GetEnvironmentVariable("PALLADIUM_TEST_SERVERS");
|
||||
if (string.IsNullOrWhiteSpace(servers)) return;
|
||||
|
||||
var args = new List<string>();
|
||||
foreach (var s in servers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
args.AddRange(["--server", s]);
|
||||
if (Environment.GetEnvironmentVariable("PALLADIUM_TEST_SSL") is "1" or "true") args.Add("--ssl");
|
||||
if (Environment.GetEnvironmentVariable("PALLADIUM_TEST_NET") is { Length: > 0 } net) args.AddRange(["--net", net]);
|
||||
|
||||
// The probe is a top-level-statements exe: call its entry point rather than shelling out.
|
||||
var entry = Assembly.Load("PalladiumWallet.ServerProbe").EntryPoint
|
||||
?? throw new InvalidOperationException("ServerProbe has no entry point.");
|
||||
|
||||
var original = Console.Out;
|
||||
var captured = new StringWriter();
|
||||
Console.SetOut(captured);
|
||||
int exit;
|
||||
// The synthesized entry point is a synchronous Main(string[]) → int wrapper.
|
||||
try { exit = await Task.Run(() => (int)entry.Invoke(null, [args.ToArray()])!); }
|
||||
finally { Console.SetOut(original); }
|
||||
|
||||
Assert.True(exit == 0, captured.ToString());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="CsCheck" Version="4.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
@@ -23,7 +24,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
||||
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
||||
<ProjectReference Include="..\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj" />
|
||||
<ProjectReference Include="..\..\src\App\PalladiumWallet.App.csproj" />
|
||||
<ProjectReference Include="..\..\src\Cli\PalladiumWallet.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using NBitcoin.Crypto;
|
||||
using PalladiumWallet.Core.Chain;
|
||||
using PalladiumWallet.Core.Net;
|
||||
using PalladiumWallet.Core.Spv;
|
||||
using PalladiumWallet.Tests.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Spv;
|
||||
|
||||
/// <summary>Chain-identity check after the handshake (genesis + highest checkpoint ≤ tip).</summary>
|
||||
public class ChainVerifierTests
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
// 80 arbitrary bytes are a valid "header" for hashing purposes; seed makes them distinct.
|
||||
private static string HeaderHex(int seed) =>
|
||||
Convert.ToHexString(Hashes.SHA256(BitConverter.GetBytes(seed))
|
||||
.Concat(Hashes.SHA256(BitConverter.GetBytes(seed + 1_000)))
|
||||
.Concat(new byte[16]).ToArray()).ToLowerInvariant();
|
||||
|
||||
private static Checkpoint CheckpointFor(int height, int seed) =>
|
||||
new(height, BlockHeaderInfo.Parse(HeaderHex(seed)).Hash.ToString(), 0x1d00ffff);
|
||||
|
||||
private static ChainProfile Profile(params Checkpoint[] checkpoints) =>
|
||||
ChainProfiles.Mainnet with { Checkpoints = checkpoints };
|
||||
|
||||
private static async Task<ElectrumClient> ConnectAsync(FakeElectrumServer s) =>
|
||||
await ElectrumClient.ConnectAsync(s.Host, s.Port, useSsl: false);
|
||||
|
||||
private static void Tip(FakeElectrumServer s, int height) =>
|
||||
s.Handle("blockchain.headers.subscribe", _ => new { height, hex = HeaderHex(0) });
|
||||
|
||||
[Fact]
|
||||
public async Task Accepts_a_server_with_the_expected_genesis_and_checkpoint_header()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
Tip(server, 150);
|
||||
server.Handle("blockchain.block.header", p => p[0].GetInt32() == 100 ? HeaderHex(7) : throw new FakeElectrumError(-1, "no"));
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uses_the_highest_checkpoint_not_above_the_tip()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
Tip(server, 150);
|
||||
var asked = new List<int>();
|
||||
server.Handle("blockchain.block.header", p => { asked.Add(p[0].GetInt32()); return HeaderHex(asked[^1]); });
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await client.VerifyChainAsync(
|
||||
Profile(CheckpointFor(50, 50), CheckpointFor(100, 100), CheckpointFor(200, 200)), default).WaitAsync(Timeout);
|
||||
|
||||
Assert.Equal([100], asked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_different_header_at_the_checkpoint_height()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
Tip(server, 150);
|
||||
server.Handle("blockchain.block.header", _ => HeaderHex(999)); // other chain
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WrongChainException>(
|
||||
() => client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout));
|
||||
Assert.Equal(server.Port, ex.Port);
|
||||
Assert.Contains("checkpoint", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_different_genesis_hash_before_asking_for_headers()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Testnet.GenesisHash });
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await Assert.ThrowsAsync<WrongChainException>(
|
||||
() => client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout));
|
||||
Assert.Equal(0, server.CallCount("blockchain.headers.subscribe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Genesis_comparison_is_case_insensitive()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Mainnet.GenesisHash.ToUpperInvariant() });
|
||||
Tip(server, 10);
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("error")]
|
||||
[InlineData("no-genesis")]
|
||||
[InlineData("not-an-object")]
|
||||
public async Task Rejects_a_server_that_does_not_report_a_genesis_hash(string mode)
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.features", _ => mode switch
|
||||
{
|
||||
"error" => throw new FakeElectrumError(-32601, "unknown method"),
|
||||
"no-genesis" => new { hash_function = "sha256" },
|
||||
_ => "nope",
|
||||
});
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await Assert.ThrowsAsync<WrongChainException>(
|
||||
() => client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tip_below_the_first_checkpoint_is_accepted_on_the_genesis_alone()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
Tip(server, 50);
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_profile_without_checkpoints_relies_on_the_genesis_alone()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
Tip(server, 5_000_000);
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("server-error")]
|
||||
[InlineData("short-header")]
|
||||
[InlineData("not-hex")]
|
||||
public async Task A_missing_or_malformed_checkpoint_header_is_a_wrong_chain(string mode)
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
Tip(server, 150);
|
||||
server.Handle("blockchain.block.header", _ => mode switch
|
||||
{
|
||||
"server-error" => throw new FakeElectrumError(-1, "no such block"),
|
||||
"short-header" => "00ff",
|
||||
_ => "zz",
|
||||
});
|
||||
await using var client = await ConnectAsync(server);
|
||||
|
||||
await Assert.ThrowsAsync<WrongChainException>(
|
||||
() => client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_shipped_mainnet_profile_has_checkpoints_the_check_can_use()
|
||||
{
|
||||
Assert.NotEmpty(ChainProfiles.Mainnet.Checkpoints);
|
||||
Assert.All(ChainProfiles.Mainnet.Checkpoints, c => Assert.Equal(64, c.BlockHash.Length));
|
||||
}
|
||||
}
|
||||
@@ -314,13 +314,14 @@ public class WalletSynchronizerTests
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
// The checkpoint sits ABOVE the tx (105): the header at 100 must hash-chain up to it.
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
@@ -331,7 +332,7 @@ public class WalletSynchronizerTests
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
|
||||
// covering 100..105 back to the checkpoint already includes the tx's own header —
|
||||
// covering 100..105 up 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"));
|
||||
@@ -342,21 +343,21 @@ public class WalletSynchronizerTests
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 103);
|
||||
|
||||
// Second tx (at height 103) known upfront so the chain can commit to it,
|
||||
// Second tx (at height 105) known upfront so the chain can commit to it,
|
||||
// but announced by the server only after the first sync.
|
||||
var tx2 = Net.CreateTransaction();
|
||||
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
|
||||
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(),
|
||||
roots: new() { [103] = tx2.GetHash() });
|
||||
roots: new() { [103] = funding.GetHash(), [105] = tx2.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
@@ -364,11 +365,11 @@ public class WalletSynchronizerTests
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var sync = new WalletSynchronizer(checkpointAccount, client);
|
||||
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
||||
await sync.SyncOnceAsync(); // walks 103..105 up to the checkpoint and memoizes it
|
||||
|
||||
// 103 <= the memoized 105: anchoring must early-return without re-walking,
|
||||
// so the header/range call counts stay at those of the first walk (103 is cached).
|
||||
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
||||
// 105 lies inside the memoized [103, 105]: anchoring must early-return without
|
||||
// re-walking, so the header/range call counts stay at those of the first walk.
|
||||
scenario.Register(tx2, 105, checkpointAccount.GetReceiveAddress(1));
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_500_000, result.ConfirmedSats);
|
||||
@@ -376,19 +377,144 @@ public class WalletSynchronizerTests
|
||||
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extending_the_anchor_downwards_only_walks_the_new_segment()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var high = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 104);
|
||||
var tx2 = Net.CreateTransaction();
|
||||
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
|
||||
var chain = ChainedHeaders(100, 105, high.GetHash(),
|
||||
roots: new() { [104] = high.GetHash(), [101] = tx2.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
var checkpointAccount = Account(Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
});
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var sync = new WalletSynchronizer(checkpointAccount, client);
|
||||
await sync.SyncOnceAsync(); // anchors [104, 105]
|
||||
scenario.Register(tx2, 101, checkpointAccount.GetReceiveAddress(1));
|
||||
var result = await sync.SyncOnceAsync(); // extends to [101, 105]
|
||||
|
||||
Assert.Equal(1_500_000, result.ConfirmedSats);
|
||||
Assert.Equal(2, server.CallCount("blockchain.block.headers")); // 104..105, then 101..103
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_that_forges_a_consistent_chain_to_the_checkpoint_is_detected()
|
||||
{
|
||||
// The attack the anchoring must stop: the server invents the header of the tx's block and
|
||||
// chains fake headers after it, all with valid prev-hash links. Linking "from a checkpoint
|
||||
// upward" would accept this; linking "up to a checkpoint" cannot, because the chain does
|
||||
// not end at the real hardcoded hash.
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
|
||||
var honest = ChainedHeaders(100, 105, uint256.One); // the real chain (tx not in it)
|
||||
var forged = ChainedHeaders(100, 105, funding.GetHash(),
|
||||
roots: new() { [100] = funding.GetHash() }); // fabricated, self-consistent
|
||||
foreach (var (h, hex) in forged) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointAccount = Account(Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(honest[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
});
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||
Assert.Contains("checkpoint height 105", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tx_above_the_last_checkpoint_has_nothing_to_anchor_to()
|
||||
{
|
||||
// Documented limit (SECURITY.md): only the Merkle proof is checked there.
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var checkpointAccount = Account(Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
|
||||
});
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.headers"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tx_below_the_first_checkpoint_is_anchored_to_it()
|
||||
{
|
||||
// L-01: heights under the first checkpoint used to have no anchor at all.
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 10);
|
||||
var chain = ChainedHeaders(10, 100, funding.GetHash(), roots: new() { [10] = funding.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
var checkpointAccount = Account(Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
});
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
Assert.True(server.CallCount("blockchain.block.headers") >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cache_without_the_anchor_state_is_not_trusted_for_verified_proofs()
|
||||
{
|
||||
// A cache written before the anchoring direction was fixed has no AnchoredDownTo: its
|
||||
// "verified" claims are dropped and every proof is checked again.
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
var first = new WalletSynchronizer(account, client);
|
||||
var result1 = await first.SyncOnceAsync();
|
||||
var (rawTx, verifiedAt, headers, _) = first.ExportCaches(Net);
|
||||
Assert.NotEmpty(verifiedAt);
|
||||
|
||||
server.ResetCallCounts();
|
||||
var second = new WalletSynchronizer(account, client);
|
||||
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredDownTo: null);
|
||||
var result2 = await second.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||
Assert.Equal(1, server.CallCount("blockchain.transaction.get_merkle")); // re-verified
|
||||
Assert.Equal(0, server.CallCount("blockchain.transaction.get")); // raw tx still reused
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
// Wrong hash at the checkpoint height: the real chain does not match it.
|
||||
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
|
||||
Checkpoints = [new Checkpoint(105, uint256.One.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
@@ -397,7 +523,7 @@ public class WalletSynchronizerTests
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||
Assert.Contains("checkpoint height 100", ex.Message);
|
||||
Assert.Contains("checkpoint height 105", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -405,15 +531,15 @@ public class WalletSynchronizerTests
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash());
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
|
||||
// Tamper with block 103: it no longer points at block 102's real hash.
|
||||
chain[103] = Scenario.SingleTxHeaderHex(uint256.One, 103, prevHash: uint256.One);
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
@@ -425,6 +551,30 @@ public class WalletSynchronizerTests
|
||||
Assert.Contains("Broken header chain at height 103", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_returning_no_headers_during_anchoring_fails_the_sync()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointAccount = Account(Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
});
|
||||
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
// A dishonest/pruned server answers the range request with zero headers.
|
||||
server.Handle("blockchain.block.headers", _ => new { count = 0, hex = "" });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
|
||||
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
|
||||
Assert.Contains("returned no headers", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op()
|
||||
{
|
||||
@@ -469,7 +619,7 @@ public class WalletSynchronizerTests
|
||||
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
|
||||
|
||||
var syncTask = sync.SyncOnceAsync();
|
||||
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.NotNull(partial);
|
||||
Assert.False(Assert.Single(partial!.History).Verified);
|
||||
@@ -513,7 +663,7 @@ public class WalletSynchronizerTests
|
||||
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
|
||||
|
||||
var syncTask = sync.SyncOnceAsync();
|
||||
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
|
||||
// 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.
|
||||
@@ -595,7 +745,7 @@ public class WalletSynchronizerTests
|
||||
|
||||
var first = new WalletSynchronizer(account, client);
|
||||
var result1 = await first.SyncOnceAsync();
|
||||
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
|
||||
var (rawTx, verifiedAt, headers, anchoredDownTo) = first.ExportCaches(Net);
|
||||
|
||||
Assert.Single(rawTx); // the confirmed tx is exported
|
||||
Assert.Single(verifiedAt); // with its verified height
|
||||
@@ -605,7 +755,7 @@ public class WalletSynchronizerTests
|
||||
server.ResetCallCounts();
|
||||
var second = new WalletSynchronizer(account, client);
|
||||
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
|
||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredDownTo);
|
||||
var result2 = await second.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
|
||||
@@ -619,23 +769,23 @@ public class WalletSynchronizerTests
|
||||
{
|
||||
// Same scenario as "Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo", but
|
||||
// across two separate WalletSynchronizer instances (simulating an app restart) with
|
||||
// AnchoredUpTo round-tripped through ExportCaches/PreloadCaches like a real save/reload —
|
||||
// AnchoredDownTo round-tripped through ExportCaches/PreloadCaches like a real save/reload —
|
||||
// the in-memory-only memoization that test covers wouldn't survive that on its own.
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 103);
|
||||
|
||||
var tx2 = Net.CreateTransaction();
|
||||
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
|
||||
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
|
||||
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(),
|
||||
roots: new() { [103] = tx2.GetHash() });
|
||||
roots: new() { [103] = funding.GetHash(), [105] = tx2.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
|
||||
var checkpointProfile = Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
var checkpointAccount = Account(checkpointProfile);
|
||||
|
||||
@@ -643,20 +793,20 @@ public class WalletSynchronizerTests
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var first = new WalletSynchronizer(checkpointAccount, client);
|
||||
var result1 = await first.SyncOnceAsync(); // walks and memoizes the anchor up to 105
|
||||
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
|
||||
Assert.NotEmpty(anchoredUpTo);
|
||||
var result1 = await first.SyncOnceAsync(); // walks 103..105 and memoizes the anchor
|
||||
var (rawTx, verifiedAt, headers, anchoredDownTo) = first.ExportCaches(Net);
|
||||
Assert.NotEmpty(anchoredDownTo);
|
||||
|
||||
// Fresh synchroniser (new launch) preloaded from the exported caches, then a new tx at
|
||||
// height 103 — within the already-anchored range — is announced.
|
||||
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
|
||||
// height 105 — within the already-anchored range — is announced.
|
||||
scenario.Register(tx2, 105, checkpointAccount.GetReceiveAddress(1));
|
||||
server.ResetCallCounts();
|
||||
var second = new WalletSynchronizer(checkpointAccount, client);
|
||||
second.PreloadCaches(rawTx, verifiedAt, headers,
|
||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
|
||||
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredDownTo);
|
||||
var result2 = await second.SyncOnceAsync();
|
||||
|
||||
// 103 <= the persisted anchor of 105: no header-range re-walk despite this being a
|
||||
// 105 lies in the persisted anchored range [103, 105]: no header-range re-walk despite this being a
|
||||
// brand-new synchroniser instance that never anchored anything itself.
|
||||
Assert.Equal(1_500_000, result2.ConfirmedSats);
|
||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
||||
@@ -702,4 +852,225 @@ public class WalletSynchronizerTests
|
||||
Assert.Equal(1, server.CallCount("blockchain.scripthash.get_history"));
|
||||
Assert.Equal(1, result.NextReceiveIndex);
|
||||
}
|
||||
|
||||
// ---- cross-check of recent headers against a witness server (ADR 0042) ----
|
||||
|
||||
private static ChainProfile ProfileWithCheckpointAt100() => Profile with
|
||||
{
|
||||
// Newest checkpoint at 100: a tx at height 150 has nothing to anchor to.
|
||||
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
|
||||
};
|
||||
|
||||
/// <summary>A witness server answering blockchain.block.header with <paramref name="headerAt150"/>.</summary>
|
||||
private static async Task<(FakeElectrumServer Server, ElectrumClient Client)> StartWitnessAsync(
|
||||
Func<int, string> headerAt)
|
||||
{
|
||||
var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.block.header", p => headerAt(p[0].GetInt32()));
|
||||
var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
return (server, client);
|
||||
}
|
||||
|
||||
private static Task<IReadOnlyList<ElectrumClient>> Witnesses(params ElectrumClient[] clients) =>
|
||||
Task.FromResult<IReadOnlyList<ElectrumClient>>(clients);
|
||||
|
||||
[Fact]
|
||||
public async Task A_witness_that_agrees_lets_a_recent_proof_verify()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
var (witness, witnessClient) = await StartWitnessAsync(_ => scenario.Headers[150]);
|
||||
await using var _ = server; await using var __ = client;
|
||||
await using var ___ = witness; await using var ____ = witnessClient;
|
||||
|
||||
var sync = new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) };
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.SpendableSats);
|
||||
Assert.Equal(0, result.PendingVerificationSats);
|
||||
Assert.Equal(1, witness.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_witness_that_disagrees_at_a_safe_depth_fails_the_sync()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario(); // tip 200, tx at 150: depth 50
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
var other = Scenario.SingleTxHeaderHex(uint256.One, 150); // a different header at 150
|
||||
var (witness, witnessClient) = await StartWitnessAsync(_ => other);
|
||||
await using var _ = server; await using var __ = client;
|
||||
await using var ___ = witness; await using var ____ = witnessClient;
|
||||
|
||||
var sync = new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) };
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SpvVerificationException>(() => sync.SyncOnceAsync());
|
||||
Assert.Contains("disagree about the header at height 150", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_disagreement_at_a_shallow_depth_leaves_the_proof_unverified_without_failing()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario { TipHeight = 151 }; // tx at 150: depth 1, could be a reorg
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
var (witness, witnessClient) = await StartWitnessAsync(_ => Scenario.SingleTxHeaderHex(uint256.One, 150));
|
||||
await using var _ = server; await using var __ = client;
|
||||
await using var ___ = witness; await using var ____ = witnessClient;
|
||||
|
||||
var sync = new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) };
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(0, result.SpendableSats); // not spendable
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats); // but still reported
|
||||
Assert.False(Assert.Single(result.History).Verified); // and not marked verified (no caching)
|
||||
Assert.Empty(sync.ExportCaches(Net).VerifiedAt);
|
||||
|
||||
// The witness now agrees (the servers converged): the next sync verifies it.
|
||||
witness.Handle("blockchain.block.header", _ => scenario.Headers[150]);
|
||||
var again = await sync.SyncOnceAsync();
|
||||
Assert.Equal(0, again.PendingVerificationSats);
|
||||
Assert.True(Assert.Single(again.History).Verified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_witness_provider_that_throws_leaves_only_the_merkle_proof()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var sync = new WalletSynchronizer(account, client)
|
||||
{
|
||||
WitnessProvider = _ => throw new IOException("cannot open witnesses"),
|
||||
};
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.SpendableSats);
|
||||
Assert.Equal(0, result.PendingVerificationSats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_witness_that_does_not_know_the_block_or_is_gone_is_skipped()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
var (witness, witnessClient) = await StartWitnessAsync(_ => throw new FakeElectrumError(-1, "no such block"));
|
||||
await using var _ = server; await using var __ = client;
|
||||
await using var ___ = witness;
|
||||
|
||||
var behind = await new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) }
|
||||
.SyncOnceAsync();
|
||||
Assert.Equal(1_000_000, behind.SpendableSats);
|
||||
|
||||
await witnessClient.DisposeAsync(); // a dead witness connection
|
||||
var gone = await new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses(witnessClient) }
|
||||
.SyncOnceAsync();
|
||||
Assert.Equal(1_000_000, gone.SpendableSats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_witnesses_the_behaviour_is_unchanged()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
await using var _ = server; await using var __ = client;
|
||||
|
||||
var none = await new WalletSynchronizer(account, client).SyncOnceAsync();
|
||||
var empty = await new WalletSynchronizer(account, client) { WitnessProvider = _ => Witnesses() }.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, none.SpendableSats);
|
||||
Assert.Equal(1_000_000, empty.SpendableSats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Heights_covered_by_a_checkpoint_are_not_cross_checked()
|
||||
{
|
||||
var account = Account();
|
||||
var scenario = new Scenario();
|
||||
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
|
||||
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
|
||||
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
|
||||
var checkpointAccount = Account(Profile with
|
||||
{
|
||||
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
|
||||
});
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
var (witness, witnessClient) = await StartWitnessAsync(_ => "00");
|
||||
await using var _ = server; await using var __ = client;
|
||||
await using var ___ = witness; await using var ____ = witnessClient;
|
||||
|
||||
var sync = new WalletSynchronizer(checkpointAccount, client) { WitnessProvider = _ => Witnesses(witnessClient) };
|
||||
var result = await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_000_000, result.ConfirmedSats);
|
||||
Assert.Equal(0, witness.CallCount("blockchain.block.header"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_witness_is_asked_once_per_height_and_the_provider_once_per_synchronizer()
|
||||
{
|
||||
var account = Account(ProfileWithCheckpointAt100());
|
||||
var scenario = new Scenario();
|
||||
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
|
||||
scenario.Pay(account.GetReceiveAddress(1), 500_000, height: 160);
|
||||
var (server, client) = await StartAsync(scenario);
|
||||
var (witness, witnessClient) = await StartWitnessAsync(h => scenario.Headers[h]);
|
||||
await using var _ = server; await using var __ = client;
|
||||
await using var ___ = witness; await using var ____ = witnessClient;
|
||||
var providerCalls = 0;
|
||||
|
||||
var sync = new WalletSynchronizer(account, client)
|
||||
{
|
||||
WitnessProvider = _ => { Interlocked.Increment(ref providerCalls); return Witnesses(witnessClient); },
|
||||
};
|
||||
var result = await sync.SyncOnceAsync();
|
||||
await sync.SyncOnceAsync();
|
||||
|
||||
Assert.Equal(1_500_000, result.SpendableSats);
|
||||
Assert.Equal(2, witness.CallCount("blockchain.block.header")); // heights 150 and 160, once each
|
||||
Assert.Equal(1, providerCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WitnessConnector_skips_the_primary_and_wrong_chain_servers_and_respects_the_maximum()
|
||||
{
|
||||
await using var primary = new FakeElectrumServer();
|
||||
await using var wrongChain = new FakeElectrumServer();
|
||||
wrongChain.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Testnet.GenesisHash });
|
||||
await using var good1 = new FakeElectrumServer();
|
||||
await using var good2 = new FakeElectrumServer();
|
||||
foreach (var f in new[] { primary, good1, good2 }) // the chain check reads the tip
|
||||
f.Handle("blockchain.headers.subscribe", _ => new { height = 10, hex = new string('0', 160) });
|
||||
var candidates = new[] { primary, wrongChain, good1, good2 }
|
||||
.Select(f => new ServerEndpoint(f.Host, f.Port, f.Port)).ToArray();
|
||||
// an unreachable candidate in front too
|
||||
candidates = [new ServerEndpoint("127.0.0.1", 1, 1), .. candidates];
|
||||
var profile = ChainProfiles.Mainnet with { Checkpoints = [] };
|
||||
|
||||
var witnesses = await WitnessConnector.ConnectAsync(profile, candidates,
|
||||
primary.Host, primary.Port, useSsl: false, pins: null, max: 1);
|
||||
try
|
||||
{
|
||||
var w = Assert.Single(witnesses);
|
||||
Assert.Equal(good1.Port, w.Port); // not the primary, not the wrong chain, not the dead port
|
||||
Assert.Equal(0, good2.CallCount("server.version")); // stopped at the maximum
|
||||
}
|
||||
finally { foreach (var w in witnesses) await w.DisposeAsync(); }
|
||||
|
||||
var two = await WitnessConnector.ConnectAsync(profile, candidates,
|
||||
primary.Host, primary.Port, useSsl: false, pins: null, max: 5);
|
||||
try { Assert.Equal([good1.Port, good2.Port], two.Select(c => c.Port).ToArray()); }
|
||||
finally { foreach (var w in two) await w.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user