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 | ||
|
|
0d541d0fe3 | ||
|
|
7057905d94 | ||
|
|
51f1af8786 | ||
|
|
4cd5fab736 | ||
|
|
94a474fe41 | ||
|
|
bbed21e820 | ||
|
|
06f512e2f7 | ||
|
|
5bb94c071f | ||
|
|
11b6a9a9ab | ||
|
|
9b00002e39 | ||
|
|
f0fb5bfcc6 | ||
|
|
9fa5440ae5 | ||
|
|
214abd2892 | ||
|
|
6d05a88073 | ||
|
|
d9dd05aa52 | ||
|
|
b6440484c1 | ||
|
|
1a4fefadc3 | ||
|
|
3460e53b4f | ||
|
|
feb765663c | ||
|
|
322ce8f305 |
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`
|
||||
@@ -92,7 +96,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **Localization:** `Localization/Loc.cs`, key→7 languages dictionary (it/en/es/fr/pt/de/zh); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced. The language picker in the Settings overlay (`MainView.axaml`) is a hand-written list of `RadioButton`s bound to `IsLangXx` properties in `MainWindowViewModel.Settings.cs` — it does **not** read `Loc.Languages` dynamically, so a new language needs a button + property added there too, not just a dictionary column.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
@@ -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
|
||||
+238
@@ -5,6 +5,244 @@ 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
|
||||
PSBT export), makes SPV sync render balance/history progressively instead
|
||||
of blocking on full Merkle verification, and fixes several Android
|
||||
sync-reconnect bugs found by testing the previous fix on a large wallet.
|
||||
|
||||
### Added
|
||||
|
||||
- Pure watch-only wallets from one or more plain addresses, no extended
|
||||
key or private key material at all (unlike existing xpub/WIF imports,
|
||||
which can still derive/hold key material): `WalletDocument.WatchAddresses`
|
||||
+ `WalletLoader.NewFromAddresses`, `ScriptKind` inferred from the address
|
||||
via `DerivationPaths.KindFor`, CLI `restore-address` command, and a
|
||||
matching setup-wizard step. `TransactionFactory` already refused to sign
|
||||
for any `IsWatchOnly` account, so the new address-only accounts inherit
|
||||
that guarantee for free.
|
||||
- Send flow: base64 PSBT export box + copy button and a visible warning
|
||||
banner for watch-only accounts, so the unsigned PSBT built from a
|
||||
watch-only wallet can actually be taken elsewhere to sign (previously
|
||||
only the CLI printed it).
|
||||
- Chinese (Simplified) as a 7th UI language (`Loc.Strings`/`Languages`);
|
||||
the Settings language picker is a hand-written `RadioButton` list, not
|
||||
generated from `Loc.Languages`, so `IsLangZh` was added to
|
||||
`MainWindowViewModel.Settings.cs` and `MainView.axaml` too.
|
||||
- New mainnet checkpoint at height 475124 (`ChainProfiles`).
|
||||
|
||||
### Changed
|
||||
|
||||
- SPV sync now renders balance/history as soon as transaction downloads
|
||||
finish instead of blocking on every historical Merkle proof — critical
|
||||
on mobile, where proof-checking can take much longer than the download.
|
||||
Proofs keep verifying in the background and each transaction's
|
||||
`Verified` flag catches up progressively; header ranges are fetched in
|
||||
batches (`blockchain.block.headers`) instead of one call per header.
|
||||
Coin selection (`UtxoSpendability.IsSpendable`) still refuses to spend a
|
||||
UTXO until its Merkle proof is actually checked, regardless of
|
||||
confirmation count — a server fabricating a confirmed balance can get it
|
||||
displayed early but never spent before the forgery is caught. The disk
|
||||
cache only ever persists the fully-verified end state. UI surfaces the
|
||||
new `PendingVerificationSats`/`SpendableSats` split with a
|
||||
"verifying..." badge.
|
||||
- `ElectrumClient`'s in-flight request cap (`MaxInFlight`, previously a
|
||||
hardcoded 32) is now an optional `ConnectAsync` parameter, since
|
||||
different indexing servers tolerate different concurrency before
|
||||
throttling.
|
||||
|
||||
### Performance
|
||||
|
||||
- Checkpoint-anchoring state (`_anchoredUpTo`) is now persisted across
|
||||
sync sessions via `SyncCache` instead of being re-walked and
|
||||
re-verified from scratch on every app restart, even when the header
|
||||
bytes were already cached on disk — this dominated reconnect time on
|
||||
large wallets.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `TransactionFactory.Build`: sending a large amount from a wallet with
|
||||
many small UTXOs could produce a transaction over the standard 100 KvB
|
||||
relay limit, previously surfaced only as a cryptic
|
||||
`Transaction's size is too high` error after everything else had
|
||||
already succeeded. UTXOs are now ordered largest-first with a binary
|
||||
search for the smallest spendable prefix, naturally preferring big
|
||||
coins over dust; NBitcoin's own oversized-selection case is now
|
||||
recognized and translated into a clear, actionable error instead of
|
||||
being read as "insufficient funds".
|
||||
- Android: a connection killed silently while the phone was locked (Doze,
|
||||
radio suspend, NAT timeout) still reported `IsConnected == true`
|
||||
because `TcpClient.Connected` only reflects the last known socket
|
||||
state, and the keep-alive ping's failure was swallowed by an empty
|
||||
catch — sync kept retrying on a dead socket instead of reconnecting.
|
||||
Fixed across two passes:
|
||||
- A failed keep-alive ping now tears down the client and reconnects;
|
||||
`OnPause`/`OnResume` on the Android activity force an immediate
|
||||
health check on resume instead of waiting for the 20s timer (itself
|
||||
liable to be suspended during Doze).
|
||||
- The keep-alive ping had no timeout, so a half-open TCP connection
|
||||
(the common outcome of a longer Doze suspend) left it awaiting a
|
||||
response that never arrives, so the teardown path was never reached
|
||||
— bounded to 8s, plus a re-entrancy guard against overlapping ticks.
|
||||
- Resume checking bailed out whenever a sync was already in progress,
|
||||
leaving a lock/unlock during an active sync hung indefinitely; it now
|
||||
cancels the stuck sync and tears down the dead client instead.
|
||||
- `WalletSynchronizer.ExportCaches` persisted raw transaction bytes only
|
||||
for already-verified transactions, discarding anything downloaded but
|
||||
not yet proof-verified when a sync was interrupted — forcing a full
|
||||
re-download on every resume of a large wallet. Confirmed txids are
|
||||
now tracked at download time, independent of verification status.
|
||||
- Wizard/overlay `TextBox`es (wizard steps, private-key prompt, wallet
|
||||
info overlay) shrank the whole panel when clicked into empty: their
|
||||
`HorizontalAlignment="Center"` + `MaxWidth` containers sized from
|
||||
content `DesiredSize`, and `PlaceholderText` only contributes to that
|
||||
measurement while empty and unfocused. Switched to
|
||||
`HorizontalAlignment="Stretch"`, which sizes from the available arrange
|
||||
rect (clamped by `MaxWidth`) regardless of focus/placeholder state.
|
||||
- Help overlay's Donate tab is now gated behind `IsWalletOpen`, like the
|
||||
rest of the wallet-only UI — it requires an open wallet to send from,
|
||||
so showing it earlier was a dead end.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `USERGUIDE.md`: documents why initial sync scales with transaction
|
||||
count rather than wallet age (one Merkle-proof round trip per confirmed
|
||||
transaction, no batching in the Electrum-style protocol), why later
|
||||
syncs are fast (cache persists proofs/headers/anchoring state), and
|
||||
warns against using this wallet as a mining payout address (many small
|
||||
transactions measurably slow sync).
|
||||
- `SECURITY.md`: documents address-only watch-only wallets, and adds
|
||||
multisig to the explicit "known limitations" list (unsupported —
|
||||
derivation for it throws, not just unimplemented UI).
|
||||
- `README.md` reconciled with current repo state: 7 UI languages (was
|
||||
6, missing Chinese Simplified), watch-only described as xpub *and*
|
||||
address-only (was xpub-only), multisig no longer overclaimed as a
|
||||
working PSBT flow, CLI quick-reference includes `restore-address` and
|
||||
`servers`, links the new `USERGUIDE.md`.
|
||||
|
||||
## [1.0.0] — 2026-07-09
|
||||
|
||||
First stable release. Closes the last open security gap from 0.9.x (header
|
||||
|
||||
@@ -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`
|
||||
@@ -92,7 +96,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles)
|
||||
- **In-app overlays, not OS windows:** details (address, transaction), settings, and help are full-screen `Border`s gated by an `IsXxxOpen` flag, not separate `Window`s — instant open/close, mobile-friendly, and popups/top-levels are slow on WSLg.
|
||||
- Pattern: bool property + Open/Close commands + backdrop handler and Esc key in `MainView`'s code-behind; overlay close buttons bind via `$parent[UserControl]` (not `$parent[Window]`, absent on mobile).
|
||||
- Heavy network work runs off the UI thread (`Task.Run`) so the overlay never freezes.
|
||||
- **Localization:** `Localization/Loc.cs`, key→6 languages dictionary (it/en/es/fr/pt/de); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced.
|
||||
- **Localization:** `Localization/Loc.cs`, key→7 languages dictionary (it/en/es/fr/pt/de/zh); in XAML `{Binding Loc[key]}`, in C# `Loc.Tr("key")`. On language change the `Loc` instance is replaced. The language picker in the Settings overlay (`MainView.axaml`) is a hand-written list of `RadioButton`s bound to `IsLangXx` properties in `MainWindowViewModel.Settings.cs` — it does **not** read `Loc.Languages` dynamically, so a new language needs a button + property added there too, not just a dictionary column.
|
||||
- **App version:** single source is `<Version>` in `src/App/PalladiumWallet.App.csproj`, read at runtime (`MainWindowViewModel.AppVersion`) and shown in the title. `Core/Net/UpdateChecker.cs` compares it against the latest GitHub release on startup; `MainWindowViewModel.Update.cs` prompts the user if newer.
|
||||
- **Storage paths:** `Core/Storage/AppPaths` resolves data locations; `AppPaths.OverrideDataRoot` (top priority) is the per-platform seam — the Android head sets it to the app sandbox (`Context.FilesDir`), desktop leaves it null.
|
||||
|
||||
@@ -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
|
||||
@@ -8,11 +8,11 @@ Unlike generic wallets adapted to many coins, Palladium Wallet is designed aroun
|
||||
|
||||
- **Lightweight SPV**: syncs against an indexing server (ElectrumX-like protocol) without downloading the full chain.
|
||||
- **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.
|
||||
- **PSBT-centric**: signing flows go through PSBT (offline / air-gapped / multisig).
|
||||
- **Multi-network**: mainnet, testnet, regtest.
|
||||
- **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 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.
|
||||
- **Multilingual**: Italian, English, Spanish, French, Portuguese, German, Chinese (Simplified).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -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**
|
||||
@@ -331,6 +340,9 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
||||
|
||||
## User guide (quick)
|
||||
|
||||
A condensed overview follows; for the complete, exhaustive walkthrough (every screen, every
|
||||
validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md).
|
||||
|
||||
### First launch
|
||||
1. On first launch (desktop), choose **where to store data** (wallet, configuration, certificates) — the default path or a folder of your choice. On Android this step is skipped: data lives in the app's private sandbox.
|
||||
2. Create a new wallet, restore from seed, or open one of the wallets already in your data folder.
|
||||
@@ -360,15 +372,24 @@ existing AVD, so create one first (step 3). Point it at the emulator binary:
|
||||
### 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 -- restore "<mnemonic>" [...]
|
||||
dotnet run --project src/Cli -- info [--net ...] [--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 ...] [--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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+29
-4
@@ -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,20 +35,42 @@ 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`,
|
||||
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
|
||||
each transaction's `Verified` flag catches up progressively. This means the UI can display a
|
||||
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
|
||||
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
|
||||
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
|
||||
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
|
||||
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
|
||||
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
|
||||
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
|
||||
a sync, never a partial one, so no unverified data survives a restart.
|
||||
|
||||
---
|
||||
|
||||
## Key and seed management
|
||||
|
||||
- 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`) hold no private keys and cannot sign transactions
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +92,7 @@ Connections to the indexing server use TOFU (Trust On First Use): the server's T
|
||||
|
||||
## Backup
|
||||
|
||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets restored from xpub, the private keys must be kept in a separate cold storage device.
|
||||
The wallet file is the only thing that needs to be backed up. For encrypted wallets, the password is also required. If both the file and the password are lost, funds are unrecoverable (no server-side backup). For watch-only wallets (restored from xpub or from a plain address), the private keys must be kept in a separate cold storage device.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,3 +116,6 @@ This is a complement to, not a substitute for, independent human or third-party
|
||||
- No coin control (automatic UTXO selection only)
|
||||
- No RBF/CPFP UI (RBF flag is set on all transactions, but fee bumping is not exposed)
|
||||
- No Lightning Network support
|
||||
- No multisig (M-of-N) wallets: the network profile defines multisig SLIP-132 header
|
||||
variants, but derivation for them is not implemented — attempting to use one throws
|
||||
rather than silently producing an insecure/incorrect wallet
|
||||
+75
-21
@@ -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.
|
||||
@@ -578,6 +594,31 @@ If the server is overloaded (busy responses), the wallet retries automatically u
|
||||
times with increasing back-off — a large wallet's first sync may take a little while, but it
|
||||
resumes from the cache instead of restarting.
|
||||
|
||||
**First sync can take noticeably longer than later ones — this is expected.** Each confirmed
|
||||
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 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
|
||||
desktop's wired/Wi-Fi connection, and every proof round trip pays that latency individually.
|
||||
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. 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
|
||||
proof cost described above — sync time grows with transaction count, not balance. A wallet
|
||||
whose history has accumulated **over 5,000 transactions** has been observed taking on the
|
||||
order of a couple of minutes to fully synchronize even on a stable connection, with slower
|
||||
networks (see the Android note above) pushing that further. If you mine, pay out to a wallet
|
||||
purpose-built for high transaction volume (or one that lets you consolidate UTXOs
|
||||
aggressively), and only move funds into Palladium Wallet in batches.
|
||||
|
||||
---
|
||||
|
||||
## 13. Settings
|
||||
@@ -715,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.
|
||||
@@ -729,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
|
||||
@@ -741,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.
|
||||
@@ -763,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
|
||||
@@ -772,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**.
|
||||
@@ -809,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. |
|
||||
@@ -818,7 +872,7 @@ Reset SSL certificates* — see
|
||||
| Payment sent to me doesn't appear | Not yet synced/connected, or sender hasn't broadcast. | Check the connection indicator; mempool entries appear within seconds of broadcast when connected. |
|
||||
| Update prompt at startup (*"Update available"*) | A newer GitHub release exists (checked once at startup, silently skipped offline). | *Download* opens the release page; *Dismiss* continues. Never enter your seed into anything but the wallet itself. |
|
||||
| Android: update apk refuses to install | Signature mismatch between builds. | Back up the seed **before** uninstalling; see [3.2](#32-android). |
|
||||
| First sync is slow / server busy errors | Server throttling; the wallet retries automatically (up to 8 attempts, growing back-off). | Wait; progress is cached, so restarting resumes rather than repeats. |
|
||||
| First sync is slow / server busy errors | Server throttling (automatic retry, up to 8 attempts) and/or a large transaction history — sync time scales with transaction count, not balance, worse on mobile. | Wait; progress is cached, so restarting resumes rather than repeats. See [12.3](#123-what-synchronization-actually-does). Do not use this wallet for mining payouts (many small transactions). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+33
-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
|
||||
|
||||
@@ -205,6 +223,7 @@ build_android() {
|
||||
-t:SignAndroidPackage \
|
||||
-p:AndroidSdkDirectory=\${ANDROID_HOME} \
|
||||
-p:ApplicationVersion=${VERSION_CODE} \
|
||||
-p:AndroidKeyStore=true \
|
||||
-p:AndroidSigningKeyStore=/keystore/release.keystore \
|
||||
-p:AndroidSigningKeyAlias=palladiumwallet \
|
||||
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
|
||||
@@ -220,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.
|
||||
@@ -4,6 +4,7 @@ using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Avalonia.Android;
|
||||
using AvaloniaApp = PalladiumWallet.App.App;
|
||||
|
||||
namespace PalladiumWallet.Mobile;
|
||||
|
||||
@@ -17,6 +18,7 @@ public class MainActivity : AvaloniaMainActivity
|
||||
internal const int ScanRequestCode = 9001;
|
||||
internal static TaskCompletionSource<string?>? ScanTcs;
|
||||
internal static MainActivity? Current;
|
||||
private bool _wasPaused;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
@@ -24,6 +26,26 @@ public class MainActivity : AvaloniaMainActivity
|
||||
Current = this;
|
||||
}
|
||||
|
||||
protected override void OnPause()
|
||||
{
|
||||
base.OnPause();
|
||||
_wasPaused = true;
|
||||
}
|
||||
|
||||
protected override void OnResume()
|
||||
{
|
||||
base.OnResume();
|
||||
if (!_wasPaused) return;
|
||||
_wasPaused = false;
|
||||
// The TCP socket can die silently while the screen was off/locked (Doze,
|
||||
// mobile radio suspend, NAT timeout) without the app ever observing the
|
||||
// failure. Force an immediate health check instead of waiting for the next
|
||||
// 20s keep-alive tick, which may itself have been suspended for longer than
|
||||
// the lock.
|
||||
if (AvaloniaApp.MainViewModel is { } vm)
|
||||
_ = vm.CheckConnectionOnResumeAsync();
|
||||
}
|
||||
|
||||
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
|
||||
{
|
||||
base.OnActivityResult(requestCode, resultCode, data);
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
|
||||
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
|
||||
<ApplicationVersion>3</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.0.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
|
||||
|
||||
@@ -8,6 +8,10 @@ namespace PalladiumWallet.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
/// <summary>Set once the single ViewModel is created; lets platform heads (e.g. the
|
||||
/// Android activity) reach it for lifecycle events without a second instance.</summary>
|
||||
public static MainWindowViewModel? MainViewModel { get; private set; }
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -16,6 +20,7 @@ public partial class App : Application
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var vm = new MainWindowViewModel();
|
||||
MainViewModel = vm;
|
||||
|
||||
// Desktop (Windows/Linux): classic window. Mobile (Android): single
|
||||
// view. Same shared UI (MainView) and same ViewModel.
|
||||
|
||||
+313
-236
@@ -11,8 +11,8 @@ public sealed class Loc
|
||||
{
|
||||
public static Loc Instance { get; private set; } = new();
|
||||
|
||||
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
|
||||
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
|
||||
public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de", "zh"];
|
||||
public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch", "中文"];
|
||||
|
||||
public string Language { get; private set; } = "en";
|
||||
|
||||
@@ -42,256 +42,283 @@ public sealed class Loc
|
||||
|
||||
private static readonly Dictionary<string, string[]> Strings = new()
|
||||
{
|
||||
// Menu it en es fr pt de
|
||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
|
||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden"],
|
||||
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
|
||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
|
||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
|
||||
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
|
||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe"],
|
||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über"],
|
||||
// Menu it en es fr pt de zh
|
||||
["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei", "_文件"],
|
||||
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…", "新建/恢复钱包…"],
|
||||
["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
|
||||
["menu.file.quit"] = ["Esci", "Quit", "Salir", "Quitter", "Sair", "Beenden", "退出"],
|
||||
["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk", "_网络"],
|
||||
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)", "发现服务器(节点)"],
|
||||
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen", "重置 SSL 证书"],
|
||||
["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen", "_设置"],
|
||||
["menu.help"] = ["_Help", "_Help", "_Ayuda", "_Aide", "_Ajuda", "_Hilfe", "_帮助"],
|
||||
["help.title"] = ["Informazioni", "About", "Información", "À propos", "Sobre", "Über", "关于"],
|
||||
["help.info"] = [
|
||||
"Wallet SPV leggero e non-custodiale per la rete Palladium (PLM). Sicurezza locale: seed e chiavi sempre cifrati, mai esposti in rete.",
|
||||
"Lightweight, non-custodial SPV wallet for the Palladium (PLM) network. Local security: seed and keys always encrypted, never exposed on the wire.",
|
||||
"Monedero SPV ligero y sin custodia para la red Palladium (PLM). Seguridad local: semilla y claves siempre cifradas, nunca expuestas en la red.",
|
||||
"Portefeuille SPV léger et non-custodial pour le réseau Palladium (PLM). Sécurité locale : graine et clés toujours chiffrées, jamais exposées sur le réseau.",
|
||||
"Carteira SPV leve e não custodial para a rede Palladium (PLM). Segurança local: semente e chaves sempre cifradas, nunca expostas na rede.",
|
||||
"Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert."],
|
||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info"],
|
||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden"],
|
||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden"],
|
||||
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch"],
|
||||
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar"],
|
||||
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:"],
|
||||
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen"],
|
||||
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen"],
|
||||
"Leichtes, nicht-verwahrendes SPV-Wallet für das Palladium (PLM)-Netzwerk. Lokale Sicherheit: Seed und Schlüssel stets verschlüsselt, nie im Netzwerk exponiert.",
|
||||
"轻量级、非托管的 Palladium(PLM)网络 SPV 钱包。本地安全:种子和密钥始终加密,绝不在网络上明文传输。"],
|
||||
["help.tab.info"] = ["Info", "Info", "Info", "Info", "Info", "Info", "信息"],
|
||||
["help.tab.donate"] = ["Dona", "Donate", "Donar", "Faire un don", "Doar", "Spenden", "捐赠"],
|
||||
["help.bug.report"] = ["Segnala un bug", "Report a bug", "Informar un error", "Signaler un bug", "Reportar um bug", "Fehler melden", "报告错误"],
|
||||
["help.user.guide"] = ["Guida utente", "User guide", "Guía del usuario", "Guide utilisateur", "Guia do usuário", "Benutzerhandbuch", "用户指南"],
|
||||
["update.title"] = ["Aggiornamento disponibile", "Update available", "Actualización disponible", "Mise à jour disponible", "Atualização disponível", "Update verfügbar", "有可用更新"],
|
||||
["update.message"] = ["È disponibile una nuova versione:", "A new version is available:", "Hay una nueva versión disponible:", "Une nouvelle version est disponible :", "Uma nova versão está disponível:", "Eine neue Version ist verfügbar:", "有新版本可用:"],
|
||||
["update.download"] = ["Scarica", "Download", "Descargar", "Télécharger", "Baixar", "Herunterladen", "下载"],
|
||||
["update.dismiss"] = ["Ignora", "Dismiss", "Ignorar", "Ignorer", "Ignorar", "Verwerfen", "忽略"],
|
||||
["donate.desc"] = [
|
||||
"Se questo wallet ti è utile, considera una piccola donazione allo sviluppatore.",
|
||||
"If you find this wallet useful, consider a small donation to the developer.",
|
||||
"Si esta wallet te resulta útil, considera una pequeña donación al desarrollador.",
|
||||
"Si ce portefeuille vous est utile, envisagez un petit don au développeur.",
|
||||
"Se esta carteira é útil para você, considere uma pequena doação ao desenvolvedor.",
|
||||
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler."],
|
||||
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse"],
|
||||
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag"],
|
||||
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten"],
|
||||
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden"],
|
||||
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
|
||||
"Wenn Ihnen dieses Wallet nützlich ist, erwägen Sie eine kleine Spende an den Entwickler.",
|
||||
"如果您觉得这个钱包有用,请考虑向开发者捐赠一点。"],
|
||||
["donate.dev.address"] = ["Indirizzo sviluppatore", "Developer address", "Dirección del desarrollador", "Adresse du développeur", "Endereço do desenvolvedor", "Entwickleradresse", "开发者地址"],
|
||||
["donate.amount"] = ["Importo donazione", "Donation amount", "Monto de donación", "Montant du don", "Valor da doação", "Spendenbetrag", "捐赠金额"],
|
||||
["donate.prepare"] = ["Prepara donazione", "Prepare donation", "Preparar donación", "Préparer le don", "Preparar doação", "Spende vorbereiten", "准备捐赠"],
|
||||
["donate.confirm"] = ["Conferma e invia", "Confirm and send", "Confirmar y enviar", "Confirmer et envoyer", "Confirmar e enviar", "Bestätigen und senden", "确认并发送"],
|
||||
["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit", "单位"],
|
||||
|
||||
// Wizard
|
||||
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
|
||||
["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden", "数据存储位置"],
|
||||
["wiz.data.info"] = [
|
||||
"Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
|
||||
"Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
|
||||
"Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
|
||||
"Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
|
||||
"Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
|
||||
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
|
||||
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
|
||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
|
||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
|
||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
|
||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
|
||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
|
||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
||||
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren"],
|
||||
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren"],
|
||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
|
||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"],
|
||||
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
|
||||
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)"],
|
||||
"Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen.",
|
||||
"选择用于存储钱包、配置和证书的文件夹。可以使用默认路径,也可以自行选择。"],
|
||||
["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:", "默认路径:"],
|
||||
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden", "使用默认路径"],
|
||||
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…", "选择文件夹…"],
|
||||
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen", "选择要打开的钱包"],
|
||||
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen", "打开现有钱包"],
|
||||
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen", "创建新钱包"],
|
||||
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
|
||||
["wiz.importxkey.btn"] = ["Importa xpub / xprv", "Import xpub / xprv", "Importar xpub / xprv", "Importer xpub / xprv", "Importar xpub / xprv", "xpub / xprv importieren", "导入 xpub / xprv"],
|
||||
["wiz.importwif.btn"] = ["Importa chiave WIF", "Import WIF key", "Importar clave WIF", "Importer clé WIF", "Importar chave WIF", "WIF-Schlüssel importieren", "导入 WIF 密钥"],
|
||||
["wiz.importaddress.btn"] = ["Importa indirizzo (sola lettura)", "Import address (watch-only)", "Importar dirección (solo lectura)", "Importer une adresse (lecture seule)", "Importar endereço (somente leitura)", "Adresse importieren (nur lesend)", "导入地址(仅观察)"],
|
||||
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen", "打开钱包"],
|
||||
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)", "文件密码(未设置则留空)"],
|
||||
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen", "打开"],
|
||||
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)", "您的种子(12 个单词)"],
|
||||
["wiz.seed.warning"] = [
|
||||
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
|
||||
"Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable.",
|
||||
"Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
|
||||
"Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
|
||||
"Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
|
||||
"Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren."],
|
||||
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter"],
|
||||
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"],
|
||||
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben"],
|
||||
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
|
||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
|
||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
|
||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
|
||||
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)"],
|
||||
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)"],
|
||||
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen."],
|
||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
|
||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
|
||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
|
||||
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"],
|
||||
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"],
|
||||
"Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren.",
|
||||
"请按顺序将这些单词写在纸上。持有这些单词的人即可控制资金;一旦丢失,资金将无法找回。"],
|
||||
["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter", "已记录 — 下一步"],
|
||||
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen", "确认种子"],
|
||||
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben", "重新输入以空格分隔的 12 个单词"],
|
||||
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen", "从种子恢复"],
|
||||
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)", "BIP39 助记词(12 或 24 个单词,以空格分隔)"],
|
||||
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase", "可选密码短语"],
|
||||
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen", "留空以跳过"],
|
||||
["wiz.name.label"] = ["Nome wallet (opzionale)", "Wallet name (optional)", "Nombre del wallet (opcional)", "Nom du wallet (optionnel)", "Nome da carteira (opcional)", "Wallet-Name (optional)", "钱包名称(可选)"],
|
||||
["wiz.name.placeholder"] = ["es. risparmio, trading… (lascia vuoto per nome automatico)", "e.g. savings, trading… (leave blank for auto name)", "p.ej. ahorro, trading… (deja en blanco para nombre automático)", "ex. épargne, trading… (laisser vide pour nom automatique)", "ex. poupança, trading… (deixe em branco para nome automático)", "z.B. Sparen, Trading… (leer lassen für automatischen Namen)", "例如:储蓄、交易…(留空则自动命名)"],
|
||||
["msg.wallet.exists"] = ["Esiste già un wallet con questo nome. Scegli un nome diverso.", "A wallet with this name already exists. Choose a different name.", "Ya existe un wallet con este nombre. Elige un nombre diferente.", "Un wallet avec ce nom existe déjà. Choisissez un nom différent.", "Já existe uma carteira com este nome. Escolha um nome diferente.", "Ein Wallet mit diesem Namen existiert bereits. Wähle einen anderen Namen.", "已存在同名钱包,请选择其他名称。"],
|
||||
["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort", "钱包文件密码"],
|
||||
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)", "建议设置(留空 = 文件在磁盘上以明文保存)"],
|
||||
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen", "创建钱包"],
|
||||
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen", "重复密码"],
|
||||
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln", "使用密码加密钱包文件"],
|
||||
["wiz.password.encrypt.hint"] = [
|
||||
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
|
||||
"Warning: without encryption the seed stays in plaintext on disk.",
|
||||
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
|
||||
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
|
||||
"Atenção: sem criptografia a semente fica em texto simples no disco.",
|
||||
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
|
||||
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
|
||||
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
|
||||
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen"],
|
||||
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte.",
|
||||
"警告:不加密的话,种子将以明文形式保存在磁盘上。"],
|
||||
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück", "返回"],
|
||||
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter", "下一步"],
|
||||
["wiz.scripttype.title"] = ["Tipo di script e indirizzi", "Script type and addresses", "Tipo de script y direcciones", "Type de script et adresses", "Tipo de script e endereços", "Skripttyp und Adressen", "脚本类型和地址"],
|
||||
["wiz.scripttype.hint"] = [
|
||||
"Determina il formato degli indirizzi. Se non sai cosa scegliere, usa Native SegWit.",
|
||||
"Determines the address format. If unsure, use Native SegWit.",
|
||||
"Determina el formato de los direcciones. Si no sabes, usa Native SegWit.",
|
||||
"Détermine le format des adresses. En cas de doute, utilisez Native SegWit.",
|
||||
"Determina o formato dos endereços. Em caso de dúvida, use Native SegWit.",
|
||||
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit."],
|
||||
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen"],
|
||||
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen"],
|
||||
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen"],
|
||||
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen"],
|
||||
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren"],
|
||||
"Bestimmt das Adressformat. Wenn Sie unsicher sind, verwenden Sie Native SegWit.",
|
||||
"决定地址格式。如果不确定,请使用原生隔离见证(Native SegWit)。"],
|
||||
["wiz.scripttype.legacy.desc"] = ["BIP44 · m/44'/… · indirizzi P", "BIP44 · m/44'/… · P addresses", "BIP44 · m/44'/… · direcciones P", "BIP44 · m/44'/… · adresses P", "BIP44 · m/44'/… · endereços P", "BIP44 · m/44'/… · P-Adressen", "BIP44 · m/44'/… · P 地址"],
|
||||
["wiz.scripttype.wrapped.desc"] = ["BIP49 · m/49'/… · indirizzi 3", "BIP49 · m/49'/… · 3 addresses", "BIP49 · m/49'/… · direcciones 3", "BIP49 · m/49'/… · adresses 3", "BIP49 · m/49'/… · endereços 3", "BIP49 · m/49'/… · 3-Adressen", "BIP49 · m/49'/… · 3 地址"],
|
||||
["wiz.scripttype.native.desc"] = ["BIP84 · m/84'/… · indirizzi plm1q — consigliato", "BIP84 · m/84'/… · plm1q addresses — recommended", "BIP84 · m/84'/… · direcciones plm1q — recomendado", "BIP84 · m/84'/… · adresses plm1q — recommandé", "BIP84 · m/84'/… · endereços plm1q — recomendado", "BIP84 · m/84'/… · plm1q-Adressen — empfohlen", "BIP84 · m/84'/… · plm1q 地址 — 推荐"],
|
||||
["wiz.scripttype.taproot.desc"] = ["BIP86 · m/86'/… · indirizzi plm1p", "BIP86 · m/86'/… · plm1p addresses", "BIP86 · m/86'/… · direcciones plm1p", "BIP86 · m/86'/… · adresses plm1p", "BIP86 · m/86'/… · endereços plm1p", "BIP86 · m/86'/… · plm1p-Adressen", "BIP86 · m/86'/… · plm1p 地址"],
|
||||
["wiz.importxkey.title"] = ["Importa chiave estesa", "Import extended key", "Importar clave extendida", "Importer la clé étendue", "Importar chave estendida", "Erweiterten Schlüssel importieren", "导入扩展密钥"],
|
||||
["wiz.importxkey.hint"] = [
|
||||
"Incolla una xpub/zpub/ypub (watch-only) o xprv/zprv/yprv (spendibile). Il tipo di script viene rilevato automaticamente.",
|
||||
"Paste an xpub/zpub/ypub (watch-only) or xprv/zprv/yprv (spendable). The script type is detected automatically.",
|
||||
"Pega un xpub/zpub/ypub (solo lectura) o xprv/zprv/yprv (gastable). El tipo de script se detecta automáticamente.",
|
||||
"Collez un xpub/zpub/ypub (lecture seule) ou xprv/zprv/yprv (dépensable). Le type de script est détecté automatiquement.",
|
||||
"Cole um xpub/zpub/ypub (somente leitura) ou xprv/zprv/yprv (gastável). O tipo de script é detectado automaticamente.",
|
||||
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt."],
|
||||
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
||||
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren"],
|
||||
"Fügen Sie einen xpub/zpub/ypub (nur lesend) oder xprv/zprv/yprv (ausgabefähig) ein. Der Skripttyp wird automatisch erkannt.",
|
||||
"粘贴 xpub/zpub/ypub(仅观察)或 xprv/zprv/yprv(可花费)。脚本类型将自动识别。"],
|
||||
["wiz.importxkey.placeholder"] = ["xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…", "xpub… / zpub… / ypub… / xprv… / zprv…"],
|
||||
["wiz.importwif.title"] = ["Importa chiave privata WIF", "Import WIF private key", "Importar clave privada WIF", "Importer la clé privée WIF", "Importar chave privada WIF", "WIF-Privatschlüssel importieren", "导入 WIF 私钥"],
|
||||
["wiz.importwif.hint"] = [
|
||||
"Incolla una o più chiavi WIF (una per riga). Puoi importare più chiavi per controllare più indirizzi con lo stesso wallet.",
|
||||
"Paste one or more WIF keys (one per line). You can import multiple keys to control multiple addresses with the same wallet.",
|
||||
"Pega una o más claves WIF (una por línea). Puedes importar múltiples claves para controlar múltiples direcciones con el mismo wallet.",
|
||||
"Collez une ou plusieurs clés WIF (une par ligne). Vous pouvez importer plusieurs clés pour contrôler plusieurs adresses avec le même wallet.",
|
||||
"Cole uma ou mais chaves WIF (uma por linha). Você pode importar várias chaves para controlar vários endereços com a mesma carteira.",
|
||||
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten."],
|
||||
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)"],
|
||||
"Fügen Sie einen oder mehrere WIF-Schlüssel ein (einer pro Zeile). Sie können mehrere Schlüssel importieren, um mehrere Adressen mit demselben Wallet zu verwalten.",
|
||||
"粘贴一个或多个 WIF 密钥(每行一个)。您可以导入多个密钥,用同一个钱包控制多个地址。"],
|
||||
["wiz.importwif.placeholder"] = ["K… / L… / 5… (una chiave per riga)", "K… / L… / 5… (one key per line)", "K… / L… / 5… (una clave por línea)", "K… / L… / 5… (une clé par ligne)", "K… / L… / 5… (uma chave por linha)", "K… / L… / 5… (ein Schlüssel pro Zeile)", "K… / L… / 5…(每行一个密钥)"],
|
||||
["wiz.importaddress.title"] = ["Importa indirizzi in sola lettura", "Import watch-only addresses", "Importar direcciones de solo lectura", "Importer des adresses en lecture seule", "Importar endereços somente leitura", "Nur-Lese-Adressen importieren", "导入仅观察地址"],
|
||||
["wiz.importaddress.hint"] = [
|
||||
"Incolla uno o più indirizzi (uno per riga). Nessuna chiave privata è coinvolta: potrai vedere saldo e cronologia ma non potrai mai firmare o inviare transazioni da questo wallet.",
|
||||
"Paste one or more addresses (one per line). No private key is involved: you'll be able to see the balance and history but you can never sign or send transactions from this wallet.",
|
||||
"Pega una o más direcciones (una por línea). No hay ninguna clave privada involucrada: podrás ver el saldo y el historial, pero nunca podrás firmar ni enviar transacciones desde este wallet.",
|
||||
"Collez une ou plusieurs adresses (une par ligne). Aucune clé privée n'est impliquée : vous pourrez voir le solde et l'historique, mais vous ne pourrez jamais signer ni envoyer de transactions depuis ce wallet.",
|
||||
"Cole um ou mais endereços (um por linha). Nenhuma chave privada está envolvida: você poderá ver o saldo e o histórico, mas nunca poderá assinar ou enviar transações a partir desta carteira.",
|
||||
"Fügen Sie eine oder mehrere Adressen ein (eine pro Zeile). Es ist kein privater Schlüssel beteiligt: Sie können den Kontostand und den Verlauf sehen, aber nie Transaktionen aus diesem Wallet signieren oder senden.",
|
||||
"粘贴一个或多个地址(每行一个)。不涉及任何私钥:您可以查看余额和历史记录,但永远无法从此钱包签名或发送交易。"],
|
||||
["wiz.importaddress.placeholder"] = ["Indirizzo… (uno per riga)", "Address… (one per line)", "Dirección… (una por línea)", "Adresse… (une par ligne)", "Endereço… (um por linha)", "Adresse… (eine pro Zeile)", "地址…(每行一个)"],
|
||||
|
||||
// Import error messages
|
||||
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante)."],
|
||||
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt."],
|
||||
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein."],
|
||||
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig."],
|
||||
["msg.xkey.required"] = ["Incolla una chiave estesa (xpub/xprv o variante).", "Paste an extended key (xpub/xprv or variant).", "Pega una clave extendida (xpub/xprv o variante).", "Collez une clé étendue (xpub/xprv ou variante).", "Cole uma chave estendida (xpub/xprv ou variante).", "Fügen Sie einen erweiterten Schlüssel ein (xpub/xprv oder Variante).", "请粘贴扩展密钥(xpub/xprv 或其变体)。"],
|
||||
["msg.xkey.invalid"] = ["Chiave estesa non riconosciuta per questa rete.", "Extended key not recognised for this network.", "Clave extendida no reconocida para esta red.", "Clé étendue non reconnue pour ce réseau.", "Chave estendida não reconhecida para esta rede.", "Erweiterter Schlüssel für dieses Netzwerk nicht erkannt.", "此网络无法识别该扩展密钥。"],
|
||||
["msg.wif.required"] = ["Incolla almeno una chiave WIF.", "Paste at least one WIF key.", "Pega al menos una clave WIF.", "Collez au moins une clé WIF.", "Cole pelo menos uma chave WIF.", "Fügen Sie mindestens einen WIF-Schlüssel ein.", "请至少粘贴一个 WIF 密钥。"],
|
||||
["msg.wif.invalid"] = ["Chiave WIF non valida per questa rete.", "WIF key not valid for this network.", "Clave WIF no válida para esta red.", "Clé WIF invalide pour ce réseau.", "Chave WIF inválida para esta rede.", "WIF-Schlüssel für dieses Netzwerk ungültig.", "该 WIF 密钥对此网络无效。"],
|
||||
["msg.address.required"] = ["Incolla almeno un indirizzo.", "Paste at least one address.", "Pega al menos una dirección.", "Collez au moins une adresse.", "Cole pelo menos um endereço.", "Fügen Sie mindestens eine Adresse ein.", "请至少粘贴一个地址。"],
|
||||
["msg.address.invalid"] = ["Indirizzo non valido per questa rete.", "Address not valid for this network.", "Dirección no válida para esta red.", "Adresse invalide pour ce réseau.", "Endereço inválido para esta rede.", "Adresse für dieses Netzwerk ungültig.", "该地址对此网络无效。"],
|
||||
|
||||
// Wallet panel
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
|
||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
|
||||
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
|
||||
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
|
||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
|
||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
|
||||
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
|
||||
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
|
||||
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"],
|
||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"],
|
||||
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren"],
|
||||
["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen", "关闭钱包"],
|
||||
["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:", "服务器:"],
|
||||
["wallet.connect"] = ["Connetti", "Connect", "Conectar", "Connecter", "Conectar", "Verbinden", "连接"],
|
||||
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port", "或手动输入 host:port"],
|
||||
["wallet.discover"] = ["Sincronizza", "Sync servers", "Sincronizar", "Synchroniser", "Sincronizar", "Synchronisieren", "同步服务器"],
|
||||
["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen", "重置证书"],
|
||||
["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen", "接收"],
|
||||
["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf", "历史"],
|
||||
["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen", "地址"],
|
||||
["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden", "发送"],
|
||||
["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte", "联系人"],
|
||||
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:", "下一个未使用地址:"],
|
||||
["receive.copy"] = ["Copia", "Copy", "Copiar", "Copier", "Copiar", "Kopieren", "复制"],
|
||||
["receive.hint"] = [
|
||||
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
|
||||
"Payments received here will appear in the history at the next synchronization.",
|
||||
"Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
|
||||
"Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
|
||||
"Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
|
||||
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."],
|
||||
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"],
|
||||
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
|
||||
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
|
||||
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
|
||||
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"],
|
||||
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
|
||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
|
||||
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
|
||||
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
|
||||
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen"],
|
||||
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen."],
|
||||
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
|
||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
|
||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
|
||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||
"Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf.",
|
||||
"在此收到的付款将在下次同步时出现在历史记录中。"],
|
||||
["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ", "类型"],
|
||||
["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index", "索引"],
|
||||
["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
|
||||
["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo", "余额"],
|
||||
["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert", "地址已复制到剪贴板"],
|
||||
["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:", "派生路径:"],
|
||||
["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:", "公钥:"],
|
||||
["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):", "私钥(WIF):"],
|
||||
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen", "显示"],
|
||||
["addr.privkey.prompt.title"] = ["Conferma identità", "Confirm identity", "Confirmar identidad", "Confirmer l'identité", "Confirmar identidade", "Identität bestätigen", "确认身份"],
|
||||
["addr.privkey.prompt.desc"] = ["Inserisci la password del wallet per visualizzare la chiave privata.", "Enter the wallet password to reveal the private key.", "Ingresa la contraseña del wallet para ver la clave privada.", "Entrez le mot de passe du wallet pour afficher la clé privée.", "Digite a senha da carteira para ver a chave privada.", "Geben Sie das Wallet-Passwort ein, um den privaten Schlüssel anzuzeigen.", "输入钱包密码以显示私钥。"],
|
||||
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
|
||||
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang", "接收"],
|
||||
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld", "找零"],
|
||||
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen", "地址信息"],
|
||||
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen", "关闭"],
|
||||
|
||||
// History → transaction detail
|
||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."],
|
||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"],
|
||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
|
||||
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"],
|
||||
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"],
|
||||
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"],
|
||||
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"],
|
||||
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"],
|
||||
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"],
|
||||
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"],
|
||||
["tx.to"] = ["A", "To", "Para", "À", "Para", "An"],
|
||||
["tx.from"] = ["Da", "From", "De", "De", "De", "Von"],
|
||||
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"],
|
||||
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"],
|
||||
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"],
|
||||
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"],
|
||||
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"],
|
||||
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"],
|
||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"],
|
||||
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"],
|
||||
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"],
|
||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"],
|
||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"],
|
||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"],
|
||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"],
|
||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."],
|
||||
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht"],
|
||||
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren"],
|
||||
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details"],
|
||||
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase"],
|
||||
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)"],
|
||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
|
||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
|
||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
|
||||
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
|
||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
|
||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
|
||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
|
||||
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen"],
|
||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
|
||||
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger"],
|
||||
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr"],
|
||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung"],
|
||||
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse"],
|
||||
["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details.", "双击某笔交易可查看详情。"],
|
||||
["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails", "交易详情"],
|
||||
["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen", "关闭"],
|
||||
["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…", "正在从服务器加载交易数据…"],
|
||||
["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status", "状态"],
|
||||
["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool", "0 次确认 · 在内存池中"],
|
||||
["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen", "次确认"],
|
||||
["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block", "区块"],
|
||||
["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool", "在内存池中"],
|
||||
["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum", "日期"],
|
||||
["tx.to"] = ["A", "To", "Para", "À", "Para", "An", "至"],
|
||||
["tx.from"] = ["Da", "From", "De", "De", "De", "Von", "来自"],
|
||||
["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll", "支出"],
|
||||
["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben", "收入"],
|
||||
["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr", "交易手续费"],
|
||||
["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte", "每虚拟字节手续费"],
|
||||
["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag", "净额"],
|
||||
["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID", "交易 ID"],
|
||||
["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße", "总大小"],
|
||||
["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe", "虚拟大小"],
|
||||
["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)", "可替换(RBF)"],
|
||||
["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge", "输入"],
|
||||
["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge", "输出"],
|
||||
["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja", "是"],
|
||||
["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein", "否"],
|
||||
["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen.", "连接服务器以查看交易详情。"],
|
||||
["tx.sect.overview"] = ["Panoramica", "Overview", "Resumen", "Aperçu", "Visão geral", "Übersicht", "概览"],
|
||||
["tx.sect.amounts"] = ["Importi e commissioni", "Amounts & fees", "Importes y comisiones", "Montants et frais", "Valores e taxas", "Beträge & Gebühren", "金额与手续费"],
|
||||
["tx.sect.tech"] = ["Dettagli tecnici", "Technical details", "Detalles técnicos", "Détails techniques", "Detalhes técnicos", "Technische Details", "技术详情"],
|
||||
["tx.coinbase"] = ["Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "Coinbase", "创币交易"],
|
||||
["tx.coinbase.newcoins"] = ["Nuova emissione (mining)", "Newly generated (mining)", "Nueva emisión (minería)", "Nouvelle émission (minage)", "Nova emissão (mineração)", "Neu erzeugt (Mining)", "新生成(挖矿)"],
|
||||
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:", "来自联系人:"],
|
||||
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen", "选择以填充地址"],
|
||||
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse", "收款地址"],
|
||||
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag", "金额"],
|
||||
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden", "全部发送"],
|
||||
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:", "手续费 sat/vB:"],
|
||||
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten", "准备交易"],
|
||||
["send.scan"] = ["Scansiona QR", "Scan QR", "Escanear QR", "Scanner QR", "Escanear QR", "QR scannen", "扫描二维码"],
|
||||
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN", "确认并广播"],
|
||||
["send.sect.recipient"] = ["Destinatario", "Recipient", "Destinatario", "Destinataire", "Destinatário", "Empfänger", "收款人"],
|
||||
["send.sect.amount"] = ["Importo e commissione", "Amount & fee", "Importe y comisión", "Montant et frais", "Valor e taxa", "Betrag & Gebühr", "金额与手续费"],
|
||||
["send.summary"] = ["Riepilogo", "Summary", "Resumen", "Résumé", "Resumo", "Zusammenfassung", "摘要"],
|
||||
["send.psbt.label"] = ["PSBT non firmata (base64) — da firmare altrove", "Unsigned PSBT (base64) — sign it elsewhere", "PSBT sin firmar (base64) — fírmala en otro lugar", "PSBT non signée (base64) — à signer ailleurs", "PSBT não assinada (base64) — assine em outro lugar", "Unsignierte PSBT (base64) — anderswo signieren", "未签名 PSBT(base64)— 请在别处签名"],
|
||||
["send.psbt.copy"] = ["Copia PSBT", "Copy PSBT", "Copiar PSBT", "Copier la PSBT", "Copiar PSBT", "PSBT kopieren", "复制 PSBT"],
|
||||
["send.watchonly.hint"] = ["Wallet in sola lettura: non può firmare. Esporta la PSBT e firmala con un wallet che possiede le chiavi.", "Watch-only wallet: it cannot sign. Export the PSBT and sign it with a wallet that holds the keys.", "Wallet de solo lectura: no puede firmar. Exporta la PSBT y fírmala con un wallet que tenga las claves.", "Wallet en lecture seule : il ne peut pas signer. Exportez la PSBT et signez-la avec un wallet possédant les clés.", "Carteira somente leitura: não pode assinar. Exporte a PSBT e assine-a com uma carteira que tenha as chaves.", "Nur-Lese-Wallet: kann nicht signieren. Exportieren Sie die PSBT und signieren Sie sie mit einem Wallet, das die Schlüssel besitzt.", "仅观察钱包:无法签名。请导出 PSBT 并用持有密钥的钱包签名。"],
|
||||
["psbt.copied"] = ["PSBT copiata negli appunti", "PSBT copied to clipboard", "PSBT copiada al portapapeles", "PSBT copiée dans le presse-papiers", "PSBT copiada para a área de transferência", "PSBT in die Zwischenablage kopiert", "PSBT 已复制到剪贴板"],
|
||||
["receive.your.address"] = ["Il tuo indirizzo", "Your address", "Tu dirección", "Votre adresse", "Seu endereço", "Deine Adresse", "您的地址"],
|
||||
|
||||
// Wallet info overlay
|
||||
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet"],
|
||||
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen"],
|
||||
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei"],
|
||||
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk"],
|
||||
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ"],
|
||||
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)"],
|
||||
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)"],
|
||||
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel"],
|
||||
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)"],
|
||||
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script"],
|
||||
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad"],
|
||||
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel"],
|
||||
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint"],
|
||||
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)"],
|
||||
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert)."],
|
||||
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:"],
|
||||
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen"],
|
||||
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
|
||||
["menu.wallet"] = ["_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_Wallet", "_钱包"],
|
||||
["walletinfo.title"] = ["Informazioni Wallet", "Wallet Information", "Información del Wallet", "Informations Wallet", "Informações da Carteira", "Wallet-Informationen", "钱包信息"],
|
||||
["walletinfo.file"] = ["File", "File", "Archivo", "Fichier", "Arquivo", "Datei", "文件"],
|
||||
["walletinfo.network"] = ["Rete", "Network", "Red", "Réseau", "Rede", "Netzwerk", "网络"],
|
||||
["walletinfo.type"] = ["Tipo wallet", "Wallet type", "Tipo de wallet", "Type de wallet", "Tipo de carteira", "Wallet-Typ", "钱包类型"],
|
||||
["walletinfo.type.seed"] = ["HD (seed BIP39)", "HD (BIP39 seed)", "HD (seed BIP39)", "HD (graine BIP39)", "HD (semente BIP39)", "HD (BIP39-Seed)", "HD(BIP39 种子)"],
|
||||
["walletinfo.type.xprv"] = ["HD (xprv importato)", "HD (imported xprv)", "HD (xprv importado)", "HD (xprv importé)", "HD (xprv importado)", "HD (importierter xprv)", "HD(已导入 xprv)"],
|
||||
["walletinfo.type.wif"] = ["Chiave WIF importata", "Imported WIF key", "Clave WIF importada", "Clé WIF importée", "Chave WIF importada", "Importierter WIF-Schlüssel", "已导入 WIF 密钥"],
|
||||
["walletinfo.type.watchonly"] = ["Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "Watch-only (xpub)", "仅观察(xpub)"],
|
||||
["walletinfo.script"] = ["Script", "Script", "Script", "Script", "Script", "Script", "脚本"],
|
||||
["walletinfo.derivpath"] = ["Percorso derivazione", "Derivation path", "Ruta de derivación", "Chemin de dérivation", "Caminho de derivação", "Ableitungspfad", "派生路径"],
|
||||
["walletinfo.xpub"] = ["Chiave pubblica estesa", "Extended public key", "Clave pública extendida", "Clé publique étendue", "Chave pública estendida", "Erweiterter öffentlicher Schlüssel", "扩展公钥"],
|
||||
["walletinfo.fingerprint"] = ["Master fingerprint", "Master fingerprint", "Huella maestra", "Empreinte maître", "Impressão digital mestre", "Master-Fingerprint", "主指纹"],
|
||||
["walletinfo.seed.section"] = ["Seed (mnemonica BIP39)", "Seed (BIP39 mnemonic)", "Semilla (mnemónico BIP39)", "Graine (mnémonique BIP39)", "Semente (mnemônico BIP39)", "Seed (BIP39-Mnemonic)", "种子(BIP39 助记词)"],
|
||||
["walletinfo.seed.noseed"] = ["Questo wallet non ha una seed (watch-only o importato).", "This wallet has no seed (watch-only or imported).", "Este wallet no tiene seed (watch-only o importado).", "Ce wallet n'a pas de graine (watch-only ou importé).", "Esta carteira não tem semente (watch-only ou importada).", "Dieses Wallet hat keinen Seed (watch-only oder importiert).", "此钱包没有种子(仅观察或已导入)。"],
|
||||
["walletinfo.seed.password"] = ["Password del file wallet per sbloccare il seed:", "Wallet file password to unlock the seed:", "Contraseña del archivo para desbloquear la seed:", "Mot de passe du fichier pour déverrouiller la graine :", "Senha do arquivo para desbloquear a semente:", "Dateipasswort zum Entsperren des Seeds:", "输入钱包文件密码以解锁种子:"],
|
||||
["walletinfo.seed.reveal"] = ["Mostra seed", "Show seed", "Mostrar seed", "Afficher la graine", "Mostrar semente", "Seed anzeigen", "显示种子"],
|
||||
["walletinfo.seed.hide"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden", "隐藏"],
|
||||
["walletinfo.seed.warning"] = [
|
||||
"Non condividere mai queste parole. Chi le possiede controlla i fondi.",
|
||||
"Never share these words. Whoever holds them controls the funds.",
|
||||
"Nunca compartas estas palabras. Quien las tenga controla los fondos.",
|
||||
"Ne partagez jamais ces mots. Celui qui les possède contrôle les fonds.",
|
||||
"Nunca compartilhe essas palavras. Quem as tiver controla os fundos.",
|
||||
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder."],
|
||||
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase"],
|
||||
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)"],
|
||||
"Teilen Sie diese Wörter niemals. Wer sie hat, kontrolliert die Gelder.",
|
||||
"切勿分享这些单词。持有者即可控制资金。"],
|
||||
["walletinfo.passphrase"] = ["Passphrase BIP39", "BIP39 passphrase", "Frase de contraseña BIP39", "Phrase de passe BIP39", "Frase-senha BIP39", "BIP39-Passphrase", "BIP39 密码短语"],
|
||||
["walletinfo.passphrase.set"] = ["(impostata)", "(set)", "(establecida)", "(définie)", "(definida)", "(gesetzt)", "(已设置)"],
|
||||
|
||||
// Connection status
|
||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
|
||||
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
|
||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
|
||||
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"],
|
||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
|
||||
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden"],
|
||||
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
|
||||
["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden", "未连接"],
|
||||
["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt", "已断开"],
|
||||
["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…", "正在重新连接…"],
|
||||
["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler", "连接错误"],
|
||||
["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert", "证书已更改"],
|
||||
["conn.connectedto"] = ["connesso", "connected", "conectado", "connecté", "conectado", "verbunden", "已连接"],
|
||||
["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu", "正在连接"],
|
||||
|
||||
// Main status messages
|
||||
["msg.welcome.existing"] = [
|
||||
@@ -300,151 +327,201 @@ public sealed class Loc
|
||||
"Se encontró un wallet existente en esta red: ábrelo o crea otro.",
|
||||
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
|
||||
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
|
||||
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues."],
|
||||
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues.",
|
||||
"在此网络上发现现有钱包:打开它,或创建新钱包。"],
|
||||
["msg.welcome.new"] = [
|
||||
"Benvenuto: crea un nuovo wallet o ripristina da seed.",
|
||||
"Welcome: create a new wallet or restore from seed.",
|
||||
"Bienvenido: crea un nuevo wallet o restaura desde semilla.",
|
||||
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
|
||||
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
|
||||
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."],
|
||||
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her.",
|
||||
"欢迎:创建新钱包或从种子恢复。"],
|
||||
["msg.open.password"] = [
|
||||
"Inserisci la password del file (lascia vuoto se non impostata).",
|
||||
"Enter the file password (leave empty if not set).",
|
||||
"Ingresa la contraseña del archivo (deja vacío si no establecida).",
|
||||
"Entrez le mot de passe du fichier (laisser vide si non défini).",
|
||||
"Digite a senha do arquivo (deixe vazio se não definida).",
|
||||
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."],
|
||||
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt).",
|
||||
"输入文件密码(未设置则留空)。"],
|
||||
["msg.seed.write"] = [
|
||||
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
|
||||
"Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
|
||||
"Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
|
||||
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
|
||||
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
|
||||
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets."],
|
||||
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets.",
|
||||
"请将这 12 个单词按顺序写在纸上。它们是钱包唯一的备份。"],
|
||||
["msg.seed.retype"] = [
|
||||
"Reinserisci le 12 parole per confermare di averle scritte.",
|
||||
"Re-enter the 12 words to confirm you wrote them down.",
|
||||
"Reingresa las 12 palabras para confirmar que las has anotado.",
|
||||
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
|
||||
"Reinsira as 12 palavras para confirmar que as anotou.",
|
||||
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."],
|
||||
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben.",
|
||||
"重新输入这 12 个单词,以确认您已记录。"],
|
||||
["msg.seed.mismatch"] = [
|
||||
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
|
||||
"The words do not match: check what you wrote on paper.",
|
||||
"Las palabras no coinciden: revisa lo que escribiste en papel.",
|
||||
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
|
||||
"As palavras não correspondem: verifique o que escreveu no papel.",
|
||||
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."],
|
||||
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben.",
|
||||
"单词不匹配:请检查您写在纸上的内容。"],
|
||||
["msg.words.enter"] = [
|
||||
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
|
||||
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
|
||||
"Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
|
||||
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
|
||||
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
|
||||
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."],
|
||||
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter).",
|
||||
"输入 BIP39 助记词(12 或 24 个单词,以空格分隔)。"],
|
||||
["msg.words.invalid"] = [
|
||||
"Mnemonica non valida (parole o checksum errati): ricontrolla.",
|
||||
"Invalid mnemonic (wrong words or checksum): check again.",
|
||||
"Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
|
||||
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
|
||||
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
|
||||
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."],
|
||||
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen.",
|
||||
"助记词无效(单词错误或校验和不正确):请重新检查。"],
|
||||
["msg.passphrase.info"] = [
|
||||
"Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
|
||||
"Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip.",
|
||||
"Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
|
||||
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
|
||||
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
|
||||
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen."],
|
||||
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen.",
|
||||
"可选的 BIP39 密码短语:它会派生出一个完全不同的钱包。如果使用,请与种子分开记录;一旦丢失,资金将无法找回。留空以跳过。"],
|
||||
["msg.password.info"] = [
|
||||
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
|
||||
"Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file.",
|
||||
"Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
|
||||
"Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
|
||||
"Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
|
||||
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
|
||||
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
|
||||
"Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei.",
|
||||
"用于加密磁盘上钱包文件的密码(建议设置)。它不能替代种子,仅用于保护文件。"],
|
||||
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines.", "有多个可用钱包:请选择一个。"],
|
||||
["msg.password.required"] = [
|
||||
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
|
||||
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
|
||||
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
|
||||
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
|
||||
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
|
||||
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
|
||||
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“).",
|
||||
"请输入密码以加密钱包(或取消勾选“加密钱包文件”)。"],
|
||||
["msg.password.mismatch"] = [
|
||||
"Le due password non coincidono.",
|
||||
"The two passwords do not match.",
|
||||
"Las dos contraseñas no coinciden.",
|
||||
"Les deux mots de passe ne correspondent pas.",
|
||||
"As duas senhas não coincidem.",
|
||||
"Die beiden Passwörter stimmen nicht überein."],
|
||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
|
||||
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet."],
|
||||
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen."],
|
||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
|
||||
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
|
||||
"Die beiden Passwörter stimmen nicht überein.",
|
||||
"两次输入的密码不一致。"],
|
||||
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort.", "密码错误。"],
|
||||
["msg.wallet.locked"] = ["Wallet già aperto in un'altra istanza dell'applicazione.", "Wallet already open in another instance of the application.", "El wallet ya está abierto en otra instancia de la aplicación.", "Le wallet est déjà ouvert dans une autre instance de l'application.", "A carteira já está aberta em outra instância do aplicativo.", "Wallet ist bereits in einer anderen Instanz der Anwendung geöffnet.", "钱包已在应用程序的另一个实例中打开。"],
|
||||
["msg.wallet.noaccess"] = ["Impossibile accedere al file del wallet: verificare i permessi.", "Cannot access the wallet file: check file permissions.", "No se puede acceder al archivo del wallet: verifique los permisos.", "Impossible d'accéder au fichier du wallet : vérifiez les autorisations.", "Não é possível acessar o arquivo da carteira: verifique as permissões.", "Zugriff auf die Wallet-Datei nicht möglich: Berechtigungen prüfen.", "无法访问钱包文件:请检查文件权限。"],
|
||||
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…", "钱包已打开:正在连接服务器…"],
|
||||
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert", "已同步"],
|
||||
["msg.synced.detail"] = [
|
||||
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
|
||||
"SPV-verified transactions. Real-time updates active.",
|
||||
"transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
|
||||
"transactions vérifiées SPV. Mises à jour en temps réel actives.",
|
||||
"transações verificadas SPV. Atualizações em tempo real ativas.",
|
||||
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
|
||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
|
||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
|
||||
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
|
||||
"SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv.",
|
||||
"已通过 SPV 验证的交易。实时更新已启用。"],
|
||||
["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe", "高度"],
|
||||
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung", "等待确认"],
|
||||
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar", "尚不可花费"],
|
||||
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung", "成熟中"],
|
||||
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft", "正在进行 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"] = [
|
||||
"Certificati SSL azzerati: riprova la connessione.",
|
||||
"SSL certificates cleared: retry the connection.",
|
||||
"Certificados SSL restablecidos: reintenta la conexión.",
|
||||
"Certificats SSL réinitialisés : réessayez la connexion.",
|
||||
"Certificados SSL redefinidos: tente novamente a conexão.",
|
||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
|
||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
|
||||
["msg.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler"],
|
||||
["msg.peer.discovery.error"] = ["Errore nella scoperta peer", "Peer discovery error", "Error al descubrir peers", "Erreur de découverte des pairs", "Erro na descoberta de peers", "Fehler bei der Peer-Suche"],
|
||||
["msg.peer.discovery.found"] = ["Trovati {0} nuovi server dai peer (totale {1}).", "Found {0} new servers from peers (total {1}).", "Se encontraron {0} nuevos servidores de peers (total {1}).", "{0} nouveaux serveurs trouvés via les pairs (total {1}).", "Encontrados {0} novos servidores dos peers (total {1}).", "{0} neue Server von Peers gefunden (insgesamt {1})."],
|
||||
["msg.peer.discovery.none"] = ["Nessun nuovo server annunciato (totale {0}).", "No new servers announced (total {0}).", "No se anunciaron nuevos servidores (total {0}).", "Aucun nouveau serveur annoncé (total {0}).", "Nenhum novo servidor anunciado (total {0}).", "Keine neuen Server angekündigt (insgesamt {0})."],
|
||||
["msg.send.sync.first"] = ["Sincronizza prima di inviare.", "Synchronize before sending.", "Sincroniza antes de enviar.", "Synchronisez avant d'envoyer.", "Sincronize antes de enviar.", "Vor dem Senden synchronisieren."],
|
||||
["msg.send.connect.first"] = ["Connettiti al server e sincronizza prima di inviare.", "Connect to the server and synchronize before sending.", "Conéctate al servidor y sincroniza antes de enviar.", "Connectez-vous au serveur et synchronisez avant d'envoyer.", "Conecte-se ao servidor e sincronize antes de enviar.", "Mit dem Server verbinden und vor dem Senden synchronisieren."],
|
||||
["msg.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag."],
|
||||
["msg.feerate.invalid"] = ["Fee rate non valido.", "Invalid fee rate.", "Tarifa no válida.", "Taux de frais invalide.", "Taxa inválida.", "Ungültige Gebührenrate."],
|
||||
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen"],
|
||||
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid"],
|
||||
["msg.unsigned.watchonly"] = [" · NON firmata (watch-only)", " · NOT signed (watch-only)", " · NO firmada (watch-only)", " · NON signée (watch-only)", " · NÃO assinada (watch-only)", " · NICHT signiert (watch-only)"],
|
||||
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen.",
|
||||
"SSL 证书已清除:请重试连接。"],
|
||||
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler", "错误"],
|
||||
["msg.broadcast.error"] = ["Errore broadcast", "Broadcast error", "Error de transmisión", "Erreur de diffusion", "Erro de transmissão", "Übertragungsfehler", "广播错误"],
|
||||
["msg.peer.discovery.error"] = ["Errore nella scoperta peer", "Peer discovery error", "Error al descubrir peers", "Erreur de découverte des pairs", "Erro na descoberta de peers", "Fehler bei der Peer-Suche", "节点发现错误"],
|
||||
["msg.peer.discovery.found"] = ["Trovati {0} nuovi server dai peer (totale {1}).", "Found {0} new servers from peers (total {1}).", "Se encontraron {0} nuevos servidores de peers (total {1}).", "{0} nouveaux serveurs trouvés via les pairs (total {1}).", "Encontrados {0} novos servidores dos peers (total {1}).", "{0} neue Server von Peers gefunden (insgesamt {1}).", "从节点发现了 {0} 个新服务器(共 {1} 个)。"],
|
||||
["msg.peer.discovery.none"] = ["Nessun nuovo server annunciato (totale {0}).", "No new servers announced (total {0}).", "No se anunciaron nuevos servidores (total {0}).", "Aucun nouveau serveur annoncé (total {0}).", "Nenhum novo servidor anunciado (total {0}).", "Keine neuen Server angekündigt (insgesamt {0}).", "没有新的服务器公告(共 {0} 个)。"],
|
||||
["msg.send.sync.first"] = ["Sincronizza prima di inviare.", "Synchronize before sending.", "Sincroniza antes de enviar.", "Synchronisez avant d'envoyer.", "Sincronize antes de enviar.", "Vor dem Senden synchronisieren.", "发送前请先同步。"],
|
||||
["msg.send.connect.first"] = ["Connettiti al server e sincronizza prima di inviare.", "Connect to the server and synchronize before sending.", "Conéctate al servidor y sincroniza antes de enviar.", "Connectez-vous au serveur et synchronisez avant d'envoyer.", "Conecte-se ao servidor e sincronize antes de enviar.", "Mit dem Server verbinden und vor dem Senden synchronisieren.", "发送前请先连接服务器并同步。"],
|
||||
["msg.amount.invalid"] = ["Importo non valido.", "Invalid amount.", "Importe no válido.", "Montant invalide.", "Valor inválido.", "Ungültiger Betrag.", "金额无效。"],
|
||||
["msg.feerate.invalid"] = ["Fee rate non valido.", "Invalid fee rate.", "Tarifa no válida.", "Taux de frais invalide.", "Taxa inválida.", "Ungültige Gebührenrate.", "手续费率无效。"],
|
||||
["msg.broadcasted"] = ["Trasmessa", "Broadcast", "Transmitida", "Diffusée", "Transmitida", "Übertragen", "已广播"],
|
||||
["msg.donate.thanks"] = ["Grazie! txid", "Thank you! txid", "¡Gracias! txid", "Merci ! txid", "Obrigado! txid", "Danke! txid", "谢谢!交易 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.",
|
||||
"El certificado TLS de {0} ha cambiado respecto al guardado. Si el servidor renovó su certificado, restablece los certificados SSL.",
|
||||
"Le certificat TLS de {0} a changé par rapport à celui enregistré. Si le serveur a renouvelé son certificat, réinitialisez les certificats SSL.",
|
||||
"O certificado TLS de {0} mudou em relação ao salvo. Se o servidor renovou o certificado, redefina os certificados SSL.",
|
||||
"Das TLS-Zertifikat von {0} hat sich gegenüber dem gespeicherten geändert. Wenn der Server das Zertifikat erneuert hat, setzen Sie die SSL-Zertifikate zurück."],
|
||||
"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
|
||||
["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"],
|
||||
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
|
||||
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
|
||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
|
||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
|
||||
["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", "联系人姓名"],
|
||||
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse", "区块链地址"],
|
||||
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen", "添加"],
|
||||
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen", "删除所选"],
|
||||
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte.", "没有已保存的联系人。"],
|
||||
|
||||
// Settings window
|
||||
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
|
||||
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
|
||||
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
|
||||
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
|
||||
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
|
||||
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
|
||||
["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen", "设置"],
|
||||
["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache", "语言"],
|
||||
["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit", "金额单位"],
|
||||
["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern", "保存"],
|
||||
["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen", "取消"],
|
||||
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…", "索引服务器…"],
|
||||
|
||||
// Server window
|
||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
|
||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
|
||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
|
||||
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
|
||||
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
|
||||
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver", "索引服务器"],
|
||||
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host", "主机"],
|
||||
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port", "端口"],
|
||||
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):", "已知服务器(点击使用):"],
|
||||
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden.", "没有已知服务器。连接后请使用“发现服务器”。"],
|
||||
};
|
||||
}
|
||||
@@ -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.0.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)
|
||||
|
||||
@@ -28,6 +28,9 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private string immatureText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string verifyingText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string networkInfo = "";
|
||||
|
||||
@@ -74,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]
|
||||
@@ -87,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;
|
||||
@@ -99,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()
|
||||
@@ -115,7 +137,7 @@ public partial class MainWindowViewModel
|
||||
if (string.IsNullOrEmpty(_password))
|
||||
{
|
||||
// No encryption: reveal directly
|
||||
IsAddressPrivKeyRevealed = true;
|
||||
RevealPrivKeyInternal();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -134,7 +156,7 @@ public partial class MainWindowViewModel
|
||||
PrivKeyPromptError = Loc.Tr("msg.wrongpassword");
|
||||
return;
|
||||
}
|
||||
IsAddressPrivKeyRevealed = true;
|
||||
RevealPrivKeyInternal();
|
||||
ClosePrivKeyPromptInternal();
|
||||
}
|
||||
|
||||
@@ -142,10 +164,7 @@ public partial class MainWindowViewModel
|
||||
private void CancelPrivKeyPrompt() => ClosePrivKeyPromptInternal();
|
||||
|
||||
[RelayCommand]
|
||||
private void HideAddressPrivKey()
|
||||
{
|
||||
IsAddressPrivKeyRevealed = false;
|
||||
}
|
||||
private void HideAddressPrivKey() => HidePrivKeyInternal();
|
||||
|
||||
// ---- transaction detail overlay ----
|
||||
|
||||
@@ -221,6 +240,7 @@ public partial class MainWindowViewModel
|
||||
var transactions = _lastTransactions;
|
||||
var loc = _loc;
|
||||
var unit = _config.Unit;
|
||||
var unanchored = Profile.IsAboveNewestCheckpoint(height);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -228,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)
|
||||
@@ -253,6 +273,7 @@ public partial class MainWindowViewModel
|
||||
BalanceText = $"0.00000000 {Profile.CoinUnit}";
|
||||
UnconfirmedText = "";
|
||||
ImmatureText = "";
|
||||
VerifyingText = "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(0).ToString();
|
||||
History.Clear();
|
||||
Addresses.Clear();
|
||||
@@ -261,11 +282,15 @@ 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;
|
||||
}
|
||||
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
|
||||
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
|
||||
// immature coinbase can also be unverified), which double-subtracts and can go
|
||||
// negative. SpendableSats is computed directly from the same IsSpendable gate
|
||||
// coin selection uses, so it's always correct.
|
||||
BalanceText = Fmt(cache.SpendableSats);
|
||||
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
|
||||
UnconfirmedText = pending != 0
|
||||
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
|
||||
@@ -273,13 +298,21 @@ public partial class MainWindowViewModel
|
||||
ImmatureText = cache.ImmatureSats != 0
|
||||
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
|
||||
// proof background verification hasn't reached yet — same "not spendable" treatment
|
||||
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
|
||||
VerifyingText = cache.PendingVerificationSats != 0
|
||||
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
|
||||
: "";
|
||||
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
|
||||
History.Clear();
|
||||
foreach (var tx in cache.History)
|
||||
History.Add(new HistoryRow(
|
||||
tx.Height > 0 ? tx.Height.ToString() : "mempool",
|
||||
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
|
||||
tx.Txid));
|
||||
tx.Txid,
|
||||
tx.Verified,
|
||||
Profile.IsAboveNewestCheckpoint(tx.Height)));
|
||||
|
||||
Addresses.Clear();
|
||||
foreach (var a in cache.Addresses)
|
||||
@@ -291,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,12 +26,87 @@ 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 = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasPendingSend;
|
||||
|
||||
[ObservableProperty]
|
||||
private string pendingPsbtBase64 = "";
|
||||
|
||||
/// <summary>True when the open account cannot sign — Send only prepares an unsigned PSBT to export.</summary>
|
||||
public bool IsWatchOnlyAccount => _account?.IsWatchOnly ?? false;
|
||||
|
||||
public void NotifyPsbtCopied() => StatusMessage = Loc.Tr("psbt.copied");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ScanQr()
|
||||
{
|
||||
@@ -66,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,
|
||||
@@ -79,18 +161,31 @@ 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();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
SendPreview = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
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()
|
||||
{
|
||||
@@ -103,6 +198,7 @@ public partial class MainWindowViewModel
|
||||
SendTo = SendAmount = "";
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
await ConnectAndSync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -12,6 +12,7 @@ public partial class MainWindowViewModel
|
||||
public bool IsLangFr => _config.Language == "fr";
|
||||
public bool IsLangPt => _config.Language == "pt";
|
||||
public bool IsLangDe => _config.Language == "de";
|
||||
public bool IsLangZh => _config.Language == "zh";
|
||||
public bool IsUnitPlm => _config.Unit == "PLM";
|
||||
public bool IsUnitMilli => _config.Unit == "mPLM";
|
||||
public bool IsUnitMicro => _config.Unit == "µPLM";
|
||||
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
|
||||
OnPropertyChanged(nameof(IsLangFr));
|
||||
OnPropertyChanged(nameof(IsLangPt));
|
||||
OnPropertyChanged(nameof(IsLangDe));
|
||||
OnPropertyChanged(nameof(IsLangZh));
|
||||
OnPropertyChanged(nameof(IsUnitPlm));
|
||||
OnPropertyChanged(nameof(IsUnitMilli));
|
||||
OnPropertyChanged(nameof(IsUnitMicro));
|
||||
|
||||
@@ -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);
|
||||
@@ -271,35 +282,57 @@ public partial class MainWindowViewModel
|
||||
_doc.Cache?.BlockHeaders,
|
||||
_doc.Cache?.NextReceiveIndex ?? 0,
|
||||
_doc.Cache?.NextChangeIndex ?? 0,
|
||||
net);
|
||||
net,
|
||||
_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
|
||||
// proof — critical on mobile, where verifying thousands of proofs can take far
|
||||
// longer than the download itself. Never persisted to disk on its own (only the
|
||||
// final, fully-verified snapshot below is saved); coin selection still refuses
|
||||
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
|
||||
// early can't be exploited to spend a server-fabricated balance.
|
||||
// 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
|
||||
{
|
||||
_resyncRequested = false;
|
||||
var result = await _synchronizer.SyncOnceAsync(ct);
|
||||
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
|
||||
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
|
||||
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
|
||||
var (result, rawHex, verifiedAt, blockHeaders, 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.AnchoredDownTo);
|
||||
}, ct);
|
||||
_lastTransactions = result.Transactions;
|
||||
|
||||
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||
PalladiumNetworks.For(_account.Profile.Kind));
|
||||
_doc.Cache = new SyncCache
|
||||
var cache = new SyncCache
|
||||
{
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
ImmatureSats = result.ImmatureSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
Utxos = [.. result.Utxos],
|
||||
Addresses = [.. result.AddressRows],
|
||||
RawTxHex = rawHex,
|
||||
VerifiedAt = verifiedAt,
|
||||
BlockHeaders = blockHeaders,
|
||||
RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
|
||||
AnchoredDownTo = anchoredDownTo,
|
||||
};
|
||||
WalletStore.Save(_doc, _walletPath!, _password);
|
||||
ApplyCache(_doc.Cache);
|
||||
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);
|
||||
_syncFailed = false;
|
||||
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
|
||||
$"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
|
||||
@@ -307,11 +340,13 @@ public partial class MainWindowViewModel
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Intentional cancellation due to server change request — not an error.
|
||||
// Intentional cancellation: a server change request, or CheckConnectionOnResumeAsync
|
||||
// recovering a sync stuck on a socket that died while the app was suspended
|
||||
// (_resumeRecovering) — neither is an error the user needs to see as one.
|
||||
cancelled = true;
|
||||
IsConnected = false;
|
||||
ConnectionStatus = Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = Loc.Tr("conn.none");
|
||||
ConnectionStatus = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = _resumeRecovering ? Loc.Tr("conn.reconnecting") : Loc.Tr("conn.none");
|
||||
StatusMessage = "";
|
||||
}
|
||||
catch (CertificatePinMismatchException ex)
|
||||
@@ -324,9 +359,22 @@ public partial class MainWindowViewModel
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsConnected = _client?.IsConnected == true;
|
||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
if (_resumeRecovering && !IsConnected)
|
||||
{
|
||||
// We tore the connection down ourselves (see CheckConnectionOnResumeAsync);
|
||||
// whether that surfaces here as a cancellation or as a transport exception
|
||||
// is a race, not a real failure — show "reconnecting" and restart right away
|
||||
// instead of a scary error message and a wait for the next keep-alive tick.
|
||||
cancelled = true;
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.none");
|
||||
ConnectionStatusShort = IsConnected ? Loc.Tr("conn.connectedto") : Loc.Tr("conn.none");
|
||||
StatusMessage = $"{Loc.Tr("msg.error")}: {DescribeError(ex)}";
|
||||
}
|
||||
if (_account is not null)
|
||||
{
|
||||
_syncFailed = true;
|
||||
@@ -338,6 +386,7 @@ public partial class MainWindowViewModel
|
||||
finally
|
||||
{
|
||||
IsSyncing = false;
|
||||
_resumeRecovering = false;
|
||||
}
|
||||
|
||||
// If cancelled due to a server change, restart immediately with the new server.
|
||||
@@ -345,6 +394,44 @@ public partial class MainWindowViewModel
|
||||
_ = ConnectAndSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
|
||||
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
|
||||
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
|
||||
/// resume without re-downloading) were never written.
|
||||
/// </summary>
|
||||
private void ApplyPartialResult(SyncResult result)
|
||||
{
|
||||
if (_doc is null)
|
||||
return;
|
||||
var previous = _doc.Cache;
|
||||
var cache = new SyncCache
|
||||
{
|
||||
RawTxHex = previous?.RawTxHex,
|
||||
VerifiedAt = previous?.VerifiedAt,
|
||||
BlockHeaders = previous?.BlockHeaders,
|
||||
};
|
||||
FillDisplayFields(cache, result);
|
||||
_doc.Cache = cache;
|
||||
_lastTransactions = result.Transactions;
|
||||
ApplyCache(cache);
|
||||
}
|
||||
|
||||
private static void FillDisplayFields(SyncCache cache, SyncResult result)
|
||||
{
|
||||
cache.TipHeight = result.TipHeight;
|
||||
cache.ConfirmedSats = result.ConfirmedSats;
|
||||
cache.UnconfirmedSats = result.UnconfirmedSats;
|
||||
cache.ImmatureSats = result.ImmatureSats;
|
||||
cache.PendingVerificationSats = result.PendingVerificationSats;
|
||||
cache.SpendableSats = result.SpendableSats;
|
||||
cache.NextReceiveIndex = result.NextReceiveIndex;
|
||||
cache.NextChangeIndex = result.NextChangeIndex;
|
||||
cache.History = [.. result.History];
|
||||
cache.Utxos = [.. result.Utxos];
|
||||
cache.Addresses = [.. result.AddressRows];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses
|
||||
/// that connection; otherwise it opens a short-lived connection to a candidate server
|
||||
@@ -367,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;
|
||||
}
|
||||
@@ -419,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,
|
||||
};
|
||||
|
||||
@@ -453,19 +549,50 @@ public partial class MainWindowViewModel
|
||||
try
|
||||
{
|
||||
var net = PalladiumNetworks.For(_account.Profile.Kind);
|
||||
var (rawHex, verifiedAt, blockHeaders) = _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.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;
|
||||
|
||||
@@ -27,9 +27,10 @@ public partial class MainWindowViewModel
|
||||
public const string StepScriptType = "script-type";
|
||||
public const string StepImportXkey = "import-xkey";
|
||||
public const string StepImportWif = "import-wif";
|
||||
public const string StepImportAddress = "import-address";
|
||||
public const string StepPassword = "password";
|
||||
|
||||
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif }
|
||||
private enum WizardFlowKind { New, Restore, ImportXkey, ImportWif, ImportAddress }
|
||||
private WizardFlowKind _wizardFlow;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -44,6 +45,7 @@ public partial class MainWindowViewModel
|
||||
[NotifyPropertyChangedFor(nameof(IsStepScriptType))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepImportXkey))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepImportWif))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepImportAddress))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepPassword))]
|
||||
private string setupStep = StepStart;
|
||||
|
||||
@@ -56,9 +58,10 @@ public partial class MainWindowViewModel
|
||||
public bool IsStepWords => SetupStep == StepWords;
|
||||
public bool IsStepPassphrase => SetupStep == StepPassphrase;
|
||||
public bool IsStepScriptType => SetupStep == StepScriptType;
|
||||
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
||||
public bool IsStepImportWif => SetupStep == StepImportWif;
|
||||
public bool IsStepPassword => SetupStep == StepPassword;
|
||||
public bool IsStepImportXkey => SetupStep == StepImportXkey;
|
||||
public bool IsStepImportWif => SetupStep == StepImportWif;
|
||||
public bool IsStepImportAddress => SetupStep == StepImportAddress;
|
||||
public bool IsStepPassword => SetupStep == StepPassword;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsLegacySelected))]
|
||||
@@ -85,6 +88,9 @@ public partial class MainWindowViewModel
|
||||
[ObservableProperty]
|
||||
private string importWifInput = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string importAddressInput = "";
|
||||
|
||||
// Script type detected during xkey decoding (to display to the user)
|
||||
[ObservableProperty]
|
||||
private string importXkeyDetectedKind = "";
|
||||
@@ -214,6 +220,15 @@ public partial class MainWindowViewModel
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardStartImportAddress()
|
||||
{
|
||||
_wizardFlow = WizardFlowKind.ImportAddress;
|
||||
ImportAddressInput = "";
|
||||
SetupStep = StepImportAddress;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromShowSeed()
|
||||
{
|
||||
@@ -312,6 +327,35 @@ public partial class MainWindowViewModel
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromImportAddress()
|
||||
{
|
||||
var addresses = ImportAddressInput.Split('\n',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.address.required");
|
||||
return;
|
||||
}
|
||||
var network = PalladiumNetworks.For(Net);
|
||||
foreach (var a in addresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = NBitcoin.BitcoinAddress.Create(a, network);
|
||||
}
|
||||
catch
|
||||
{
|
||||
StatusMessage = Loc.Tr("msg.address.invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
PasswordInput = ConfirmPasswordInput = "";
|
||||
EncryptWallet = true;
|
||||
SetupStep = StepPassword;
|
||||
StatusMessage = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void WizardNextFromScriptType()
|
||||
{
|
||||
@@ -327,11 +371,16 @@ public partial class MainWindowViewModel
|
||||
SetupStep = SetupStep switch
|
||||
{
|
||||
StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
|
||||
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif => StepStart,
|
||||
StepChooseWallet or StepShowSeed or StepWords or StepImportXkey or StepImportWif or StepImportAddress => StepStart,
|
||||
StepConfirmSeed => StepShowSeed,
|
||||
StepPassphrase => _wizardFlow == WizardFlowKind.Restore ? StepWords : StepConfirmSeed,
|
||||
StepScriptType => _wizardFlow == WizardFlowKind.ImportWif ? StepImportWif : StepPassphrase,
|
||||
StepPassword => _wizardFlow == WizardFlowKind.ImportXkey ? StepImportXkey : StepScriptType,
|
||||
StepPassword => _wizardFlow switch
|
||||
{
|
||||
WizardFlowKind.ImportXkey => StepImportXkey,
|
||||
WizardFlowKind.ImportAddress => StepImportAddress,
|
||||
_ => StepScriptType,
|
||||
},
|
||||
_ => StepStart,
|
||||
};
|
||||
if (SetupStep == StepStart)
|
||||
@@ -391,6 +440,14 @@ public partial class MainWindowViewModel
|
||||
(doc, account) = (d, a);
|
||||
break;
|
||||
}
|
||||
case WizardFlowKind.ImportAddress:
|
||||
{
|
||||
var addressLines = ImportAddressInput.Split('\n',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var (d, a) = WalletLoader.NewFromAddresses(addressLines, Profile);
|
||||
(doc, account) = (d, a);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
var (d, a) = WalletLoader.NewFromMnemonic(
|
||||
@@ -497,13 +554,14 @@ public partial class MainWindowViewModel
|
||||
_walletPath = path;
|
||||
_password = password;
|
||||
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = WalletNameInput = "";
|
||||
ImportXkeyInput = ImportWifInput = ImportXkeyDetectedKind = "";
|
||||
ImportXkeyInput = ImportWifInput = ImportAddressInput = ImportXkeyDetectedKind = "";
|
||||
SetupStep = StepStart;
|
||||
OnPropertyChanged(nameof(IsWatchOnlyAccount));
|
||||
|
||||
var walletKindTag = account switch
|
||||
{
|
||||
ImportedKeyAccount => " · imported",
|
||||
{ IsWatchOnly: true } => " · watch-only",
|
||||
ImportedKeyAccount => " · imported",
|
||||
_ => ""
|
||||
};
|
||||
var pathTag = !string.IsNullOrEmpty(doc.AccountPath) ? $" · m/{doc.AccountPath}" : "";
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
@@ -77,6 +75,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
// ---- keep-alive ----
|
||||
private bool _autoReconnect;
|
||||
private bool _syncFailed;
|
||||
private bool _resumeRecovering;
|
||||
private readonly DispatcherTimer _keepAliveTimer;
|
||||
|
||||
// ---- server UI sync ----
|
||||
@@ -129,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) =>
|
||||
@@ -161,28 +163,102 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
private bool _keepAliveRunning;
|
||||
|
||||
private async System.Threading.Tasks.Task KeepAliveTickAsync()
|
||||
{
|
||||
if (IsSyncing)
|
||||
// Re-entrancy guard: without it, a ping stuck on a half-open socket (see
|
||||
// below) would let every subsequent 20s tick pile up another concurrent
|
||||
// ping/reconnect attempt on top of it.
|
||||
if (IsSyncing || _keepAliveRunning)
|
||||
return;
|
||||
if (_client is { IsConnected: true })
|
||||
_keepAliveRunning = true;
|
||||
try
|
||||
{
|
||||
// If the wallet is open and the last sync failed, retry automatically.
|
||||
if (_syncFailed && _account is not null)
|
||||
if (_client is { IsConnected: true } client)
|
||||
{
|
||||
// If the wallet is open and the last sync failed, retry automatically.
|
||||
if (_syncFailed && _account is not null)
|
||||
{
|
||||
await ConnectAndSync();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// A "half-open" TCP connection (remote end gone with no FIN/RST
|
||||
// ever delivered — the common outcome of Android Doze/mobile-radio
|
||||
// suspend killing the route silently) never fails the write, so
|
||||
// PingAsync would otherwise await a response that never arrives.
|
||||
// Bound it explicitly instead of relying on it to throw.
|
||||
using var timeoutCts = new System.Threading.CancellationTokenSource(System.TimeSpan.FromSeconds(8));
|
||||
await client.PingAsync(timeoutCts.Token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// TcpClient.Connected only reflects the last known socket state, so a
|
||||
// connection killed silently while the app was suspended still reports
|
||||
// IsConnected == true. A failed/timed-out ping is the only reliable
|
||||
// signal here: tear the dead client down so the next tick reconnects
|
||||
// instead of retrying forever on a dead socket.
|
||||
await DisconnectAsync();
|
||||
if (_autoReconnect)
|
||||
{
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
await ConnectAndSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_autoReconnect)
|
||||
{
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
await ConnectAndSync();
|
||||
return;
|
||||
}
|
||||
try { await _client.PingAsync(); }
|
||||
catch { }
|
||||
}
|
||||
else if (_autoReconnect)
|
||||
finally
|
||||
{
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
await ConnectAndSync();
|
||||
_keepAliveRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forces an immediate connection health check, bypassing the 20s timer.
|
||||
/// Called when the app resumes from background/lock screen, since the socket may
|
||||
/// have died silently while suspended and the UI would otherwise show a stale
|
||||
/// "connected" state until the next scheduled tick (or forever, if it never fires
|
||||
/// during Doze).</summary>
|
||||
public async System.Threading.Tasks.Task CheckConnectionOnResumeAsync()
|
||||
{
|
||||
if (IsSyncing)
|
||||
{
|
||||
// A sync in progress when the phone locked may be stuck awaiting a
|
||||
// response on a socket that died silently while suspended (same
|
||||
// half-open-TCP scenario as the keep-alive ping, but sync requests have
|
||||
// no timeout of their own). Cancel it and tear the client down instead
|
||||
// of leaving it hung forever. _resumeRecovering tells ConnectAndSync's
|
||||
// error handling that this interruption is self-inflicted and expected,
|
||||
// so it shows "reconnecting" and restarts immediately instead of
|
||||
// flashing a scary error message and waiting for the next keep-alive tick.
|
||||
_resumeRecovering = true;
|
||||
ConnectionStatus = Loc.Tr("conn.reconnecting");
|
||||
ConnectionStatusShort = Loc.Tr("conn.reconnecting");
|
||||
_syncCts.Cancel();
|
||||
// Deliberately not DisconnectAsync() here: it also nulls _synchronizer,
|
||||
// which still holds whatever this sync already downloaded/verified (e.g.
|
||||
// thousands of Merkle proofs on a large wallet). Nulling it before the
|
||||
// cancelled ConnectAndSync unwinds would discard that progress, forcing a
|
||||
// full restart instead of a resume. Only tear down the dead socket here;
|
||||
// ConnectAndSync's own cancellation handling persists the partial cache
|
||||
// (PersistPartialTxCache) before it recreates the synchronizer — same
|
||||
// sequencing already used for the "server changed mid-sync" case.
|
||||
if (_client is { } deadClient)
|
||||
{
|
||||
_client = null;
|
||||
try { await deadClient.DisposeAsync(); } catch { }
|
||||
}
|
||||
return;
|
||||
}
|
||||
await KeepAliveTickAsync();
|
||||
}
|
||||
|
||||
// ---- wallet lifecycle ----
|
||||
|
||||
[RelayCommand]
|
||||
@@ -191,6 +267,7 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_walletLock?.Dispose();
|
||||
_walletLock = null;
|
||||
_ = _client?.DisposeAsync().AsTask();
|
||||
DisposeWitnesses();
|
||||
_client = null;
|
||||
_synchronizer = null;
|
||||
_autoReconnect = false;
|
||||
@@ -201,11 +278,14 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
_lastTransactions = null;
|
||||
_pendingSend = null;
|
||||
HasPendingSend = false;
|
||||
PendingPsbtBase64 = "";
|
||||
OnPropertyChanged(nameof(IsWatchOnlyAccount));
|
||||
History.Clear();
|
||||
Contacts.Clear();
|
||||
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"];
|
||||
@@ -102,7 +102,8 @@ public sealed class TransactionDetailsViewModel
|
||||
{
|
||||
if (d.Confirmations <= 0)
|
||||
return loc["tx.status.mempool"];
|
||||
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
||||
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
|
||||
}
|
||||
|
||||
private string Signed(long sats)
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<!-- ============ SETUP WIZARD (§15): one step at a time ============ -->
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding IsSetupVisible}">
|
||||
<StackPanel MaxWidth="560" Margin="24,40" Spacing="18"
|
||||
HorizontalAlignment="Center">
|
||||
HorizontalAlignment="Stretch">
|
||||
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"/>
|
||||
|
||||
@@ -99,6 +99,9 @@
|
||||
<Button Content="{Binding Loc[wiz.importwif.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding WizardStartImportWifCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.importaddress.btn]}" FontSize="16"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding WizardStartImportAddressCommand}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Step: choose wallet (multiple files present) -->
|
||||
@@ -212,6 +215,19 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Step: import watch-only addresses (no key at all) -->
|
||||
<StackPanel IsVisible="{Binding IsStepImportAddress}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.importaddress.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding Loc[wiz.importaddress.hint]}" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<TextBox PlaceholderText="{Binding Loc[wiz.importaddress.placeholder]}"
|
||||
Text="{Binding ImportAddressInput}" AcceptsReturn="True" Height="80"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
|
||||
<Button Content="{Binding Loc[wiz.next]}" Classes="accent"
|
||||
Command="{Binding WizardNextFromImportAddressCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Step: choose script/address type -->
|
||||
<StackPanel IsVisible="{Binding IsStepScriptType}" Spacing="12">
|
||||
<TextBlock Text="{Binding Loc[wiz.scripttype.title]}" FontSize="18" FontWeight="Bold"/>
|
||||
@@ -317,6 +333,9 @@
|
||||
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding VerifyingText}" Foreground="#FCD34D"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding VerifyingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
|
||||
Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
@@ -373,13 +392,20 @@
|
||||
<DataTemplate x:DataType="vm:HistoryRow">
|
||||
<Panel Cursor="Hand">
|
||||
<!-- Desktop: 3 fixed columns -->
|
||||
<Grid ColumnDefinitions="90,160,*"
|
||||
<Grid ColumnDefinitions="90,160,*,Auto"
|
||||
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding Txid}"
|
||||
FontFamily="monospace" FontSize="12"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
|
||||
IsVisible="{Binding !Verified}"/>
|
||||
<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"
|
||||
@@ -389,6 +415,12 @@
|
||||
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
|
||||
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
|
||||
Foreground="#FCD34D" FontSize="11"
|
||||
IsVisible="{Binding !Verified}"/>
|
||||
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unanchored]}"
|
||||
Foreground="#FB923C" FontSize="11"
|
||||
IsVisible="{Binding Unanchored}"/>
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
@@ -420,7 +452,7 @@
|
||||
|
||||
<!-- ── DESKTOP: Recipient | Amount side-by-side ── -->
|
||||
<Grid IsVisible="{Binding IsDesktop}"
|
||||
RowDefinitions="Auto,Auto,Auto" RowSpacing="14">
|
||||
RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="14">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||
<!-- Recipient card -->
|
||||
@@ -479,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>
|
||||
@@ -491,11 +533,29 @@
|
||||
<SelectableTextBlock Text="{Binding SendPreview}"
|
||||
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<StackPanel Spacing="6"
|
||||
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
|
||||
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
|
||||
AcceptsReturn="True" Height="90"/>
|
||||
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||
Content="{Binding Loc[send.psbt.copy]}"
|
||||
Click="OnCopyPsbtClick"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Watch-only notice -->
|
||||
<TextBlock Grid.Row="2" Text="{Binding Loc[send.watchonly.hint]}"
|
||||
IsVisible="{Binding IsWatchOnlyAccount}"
|
||||
TextWrapping="Wrap" FontSize="12"
|
||||
Foreground="{DynamicResource WarningBrush}"/>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,*" ColumnSpacing="14">
|
||||
<Button Grid.Column="0" Content="{Binding Loc[send.prepare]}"
|
||||
Command="{Binding PrepareSendCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
@@ -583,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>
|
||||
@@ -595,9 +665,26 @@
|
||||
<SelectableTextBlock Text="{Binding SendPreview}"
|
||||
TextWrapping="Wrap" FontSize="13" Classes="mono"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"/>
|
||||
<StackPanel Spacing="6"
|
||||
IsVisible="{Binding PendingPsbtBase64, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Loc[send.psbt.label]}" Classes="label"/>
|
||||
<TextBox Text="{Binding PendingPsbtBase64}" IsReadOnly="True"
|
||||
FontFamily="monospace" FontSize="11" TextWrapping="Wrap"
|
||||
AcceptsReturn="True" Height="90"/>
|
||||
<Button Content="{Binding Loc[send.psbt.copy]}"
|
||||
Click="OnCopyPsbtClick"
|
||||
MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Watch-only notice -->
|
||||
<TextBlock Text="{Binding Loc[send.watchonly.hint]}"
|
||||
IsVisible="{Binding IsWatchOnlyAccount}"
|
||||
TextWrapping="Wrap" FontSize="12"
|
||||
Foreground="{DynamicResource WarningBrush}"/>
|
||||
|
||||
<!-- Action buttons: primary (Confirm) prominent, secondary below -->
|
||||
<Button Content="{Binding Loc[send.confirm]}" Classes="accent"
|
||||
Command="{Binding ConfirmSendCommand}"
|
||||
@@ -959,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"/>
|
||||
@@ -984,7 +1071,7 @@
|
||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||
MaxWidth="360" Margin="16"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||
<StackPanel Margin="24" Spacing="14">
|
||||
<TextBlock Text="{Binding Loc[addr.privkey.prompt.title]}"
|
||||
FontSize="16" FontWeight="Bold"/>
|
||||
@@ -1325,7 +1412,7 @@
|
||||
<Border Background="{DynamicResource OverlayCardBrush}"
|
||||
BorderBrush="{DynamicResource BorderSubtleBrush}" BorderThickness="1" CornerRadius="8"
|
||||
MaxWidth="500" Margin="16"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Center">
|
||||
<ScrollViewer MaxHeight="620">
|
||||
<StackPanel Margin="24" Spacing="14">
|
||||
<TextBlock Text="{Binding Loc[walletinfo.title]}"
|
||||
@@ -1502,6 +1589,9 @@
|
||||
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
|
||||
IsChecked="{Binding IsLangDe, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
|
||||
<RadioButton GroupName="lang" Content="中文" Margin="0,0,14,4"
|
||||
IsChecked="{Binding IsLangZh, Mode=OneWay}"
|
||||
Command="{Binding SetLanguageCommand}" CommandParameter="zh"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1585,8 +1675,9 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- Tab: Donate -->
|
||||
<TabItem Header="{Binding Loc[help.tab.donate]}">
|
||||
<!-- Tab: Donate (needs an open wallet to send from) -->
|
||||
<TabItem Header="{Binding Loc[help.tab.donate]}"
|
||||
IsVisible="{Binding IsWalletOpen}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||
<TextBlock Text="{Binding Loc[donate.desc]}" TextWrapping="Wrap"
|
||||
|
||||
@@ -102,6 +102,17 @@ public partial class MainView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCopyPsbtClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel vm || string.IsNullOrEmpty(vm.PendingPsbtBase64))
|
||||
return;
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard)
|
||||
{
|
||||
await clipboard.SetTextAsync(vm.PendingPsbtBase64);
|
||||
vm.NotifyPsbtCopied();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainWindowViewModel vm)
|
||||
@@ -138,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));
|
||||
@@ -146,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;
|
||||
}
|
||||
}
|
||||
+79
-19
@@ -18,6 +18,7 @@ try
|
||||
["create", .. var rest] => Create(rest),
|
||||
["restore", var words, .. var rest] => Restore(words, rest),
|
||||
["restore-xpub", var xpub, .. var rest] => RestoreXpub(xpub, rest),
|
||||
["restore-address", var addrs, .. var rest] => RestoreAddress(addrs, rest),
|
||||
["info", .. var rest] => Info(rest),
|
||||
["sync", .. var rest] => await Sync(rest),
|
||||
["send", .. var rest] => await Send(rest),
|
||||
@@ -27,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;
|
||||
@@ -90,11 +92,36 @@ 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;
|
||||
}
|
||||
|
||||
static int RestoreAddress(string addrsText, string[] o)
|
||||
{
|
||||
var profile = Profile(o);
|
||||
var addresses = addrsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("At least one address is required.");
|
||||
return 1;
|
||||
}
|
||||
WalletDocument doc;
|
||||
try
|
||||
{
|
||||
(doc, _) = WalletLoader.NewFromAddresses(addresses, profile);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
var path = WalletPath(o, profile);
|
||||
WalletStore.Save(doc, path, CliPassword.ForNewWallet(o));
|
||||
Console.WriteLine($"Watch-only wallet saved to {path} ({addresses.Length} address(es), cannot sign)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int Info(string[] o)
|
||||
{
|
||||
var (doc, account, path) = OpenWallet(o);
|
||||
@@ -104,8 +131,9 @@ static int Info(string[] o)
|
||||
Console.WriteLine($"xpub: {doc.AccountXpub}");
|
||||
if (doc.Cache is { } cache)
|
||||
{
|
||||
Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendable"
|
||||
Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
|
||||
+ (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
|
||||
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
|
||||
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
|
||||
@@ -131,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(
|
||||
@@ -139,16 +174,25 @@ static async Task<int> Sync(string[] o)
|
||||
doc.Cache?.BlockHeaders,
|
||||
doc.Cache?.NextReceiveIndex ?? 0,
|
||||
doc.Cache?.NextChangeIndex ?? 0,
|
||||
net);
|
||||
var result = await sync.SyncOnceAsync();
|
||||
net,
|
||||
doc.Cache?.AnchoredDownTo);
|
||||
SyncResult result;
|
||||
try { result = await sync.SyncOnceAsync(); }
|
||||
finally
|
||||
{
|
||||
foreach (var w in witnesses)
|
||||
await w.DisposeAsync();
|
||||
}
|
||||
|
||||
var (rawHex, verifiedAt, blockHeaders) = sync.ExportCaches(net);
|
||||
var (rawHex, verifiedAt, blockHeaders, anchoredDownTo) = sync.ExportCaches(net);
|
||||
doc.Cache = new SyncCache
|
||||
{
|
||||
TipHeight = result.TipHeight,
|
||||
ConfirmedSats = result.ConfirmedSats,
|
||||
UnconfirmedSats = result.UnconfirmedSats,
|
||||
ImmatureSats = result.ImmatureSats,
|
||||
PendingVerificationSats = result.PendingVerificationSats,
|
||||
SpendableSats = result.SpendableSats,
|
||||
NextReceiveIndex = result.NextReceiveIndex,
|
||||
NextChangeIndex = result.NextChangeIndex,
|
||||
History = [.. result.History],
|
||||
@@ -157,11 +201,13 @@ static async Task<int> Sync(string[] o)
|
||||
RawTxHex = rawHex,
|
||||
VerifiedAt = verifiedAt,
|
||||
BlockHeaders = blockHeaders,
|
||||
AnchoredDownTo = anchoredDownTo,
|
||||
};
|
||||
WalletStore.Save(doc, path, Opt(o, "--password"));
|
||||
WalletStore.Save(doc, path, CliPassword.Current);
|
||||
|
||||
Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendable"
|
||||
Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
|
||||
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
|
||||
+ (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
|
||||
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
|
||||
Console.WriteLine($"History ({result.History.Count}):");
|
||||
foreach (var tx in result.History)
|
||||
@@ -185,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);
|
||||
@@ -287,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;
|
||||
@@ -302,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);
|
||||
}
|
||||
|
||||
@@ -329,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)
|
||||
@@ -345,13 +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)
|
||||
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);
|
||||
}
|
||||
@@ -48,8 +48,9 @@ public static class ChainProfiles
|
||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||
],
|
||||
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one
|
||||
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||
// RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
|
||||
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
|
||||
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||
// bounds how far back a forged header chain must be walked to be caught, since
|
||||
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
|
||||
Checkpoints =
|
||||
@@ -78,6 +79,7 @@ public static class ChainProfiles
|
||||
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
|
||||
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
|
||||
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
|
||||
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,19 @@ public static class DerivationPaths
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort reverse mapping from an already-known address to a ScriptKind, used to
|
||||
/// label pure address imports (no derivation involved, so this is informational only).
|
||||
/// </summary>
|
||||
public static ScriptKind KindFor(BitcoinAddress address) => address switch
|
||||
{
|
||||
BitcoinWitPubKeyAddress => ScriptKind.NativeSegwit,
|
||||
TaprootAddress => ScriptKind.Taproot,
|
||||
BitcoinWitScriptAddress => ScriptKind.NativeSegwitMultisig,
|
||||
BitcoinScriptAddress => ScriptKind.WrappedSegwit,
|
||||
_ => ScriptKind.Legacy,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Account path relative to the root: purpose'/coin'/account' (§4.2).
|
||||
/// coin_type is taken from the profile (746 mainnet, 1 testnet).
|
||||
|
||||
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
|
||||
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
|
||||
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||
|
||||
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
|
||||
public readonly record struct HeaderRangeResponse(int Count, string Hex);
|
||||
|
||||
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
|
||||
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||
|
||||
@@ -82,6 +85,23 @@ public static class ElectrumApi
|
||||
return r.GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
|
||||
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
|
||||
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
|
||||
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
|
||||
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
|
||||
/// callers must loop until the full range is covered.
|
||||
/// </summary>
|
||||
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
|
||||
int startHeight, int count, CancellationToken ct = default)
|
||||
{
|
||||
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
|
||||
return new HeaderRangeResponse(
|
||||
r.GetProperty("count").GetInt32(),
|
||||
r.GetProperty("hex").GetString()!);
|
||||
}
|
||||
|
||||
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -36,8 +36,33 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
// single segment; this gate avoids flooding the server with thousands of
|
||||
// simultaneous requests on large wallets → no bursts of -101/-102 nor
|
||||
// connection drops. Writes still stay pipelined up to this degree.
|
||||
private const int MaxInFlight = 32;
|
||||
private readonly SemaphoreSlim _inFlight = new(MaxInFlight, MaxInFlight);
|
||||
// Configurable per connection (see ConnectAsync): the right value trades off
|
||||
// initial-sync throughput against how aggressively a given server tolerates
|
||||
// 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;
|
||||
|
||||
@@ -49,19 +74,25 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
public event Action<string, JsonElement>? NotificationReceived;
|
||||
public event Action<Exception?>? Disconnected;
|
||||
|
||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl)
|
||||
private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl,
|
||||
int maxInFlight, TimeSpan requestTimeout, int maxLineBytes)
|
||||
{
|
||||
_requestTimeout = requestTimeout;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_tcp = tcp;
|
||||
_stream = stream;
|
||||
Host = host;
|
||||
Port = port;
|
||||
UseSsl = useSsl;
|
||||
_inFlight = new SemaphoreSlim(maxInFlight, maxInFlight);
|
||||
_readLoop = Task.Run(ReadLoopAsync);
|
||||
_writeLoop = Task.Run(WriteLoopAsync);
|
||||
}
|
||||
|
||||
public static async Task<ElectrumClient> ConnectAsync(string host, int port, bool useSsl,
|
||||
CertificatePinStore? pins = null, CancellationToken ct = default)
|
||||
CertificatePinStore? pins = null, CancellationToken ct = default,
|
||||
int maxInFlight = DefaultMaxInFlight, TimeSpan? requestTimeout = null,
|
||||
TimeSpan? handshakeTimeout = null, int maxLineBytes = DefaultMaxLineBytes)
|
||||
{
|
||||
var tcp = new TcpClient { NoDelay = true };
|
||||
try
|
||||
@@ -90,8 +121,10 @@ public sealed class ElectrumClient : IAsyncDisposable
|
||||
stream = ssl;
|
||||
}
|
||||
|
||||
var client = new ElectrumClient(tcp, stream, host, port, useSsl);
|
||||
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
|
||||
@@ -101,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.
|
||||
@@ -113,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",
|
||||
@@ -128,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
|
||||
@@ -185,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
|
||||
{
|
||||
@@ -205,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();
|
||||
@@ -212,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})");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,23 @@ public sealed class SyncResult
|
||||
/// <see cref="ConfirmedSats"/>.
|
||||
/// </summary>
|
||||
public required long ImmatureSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
|
||||
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
|
||||
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
|
||||
/// (an immature coinbase can also be unverified), so never subtract both from
|
||||
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
|
||||
/// </summary>
|
||||
public required long PendingVerificationSats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
|
||||
/// now — the true spendable balance. Computed directly from the same gate coin selection
|
||||
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
|
||||
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
|
||||
/// </summary>
|
||||
public required long SpendableSats { get; init; }
|
||||
public required int NextReceiveIndex { get; init; }
|
||||
public required int NextChangeIndex { get; init; }
|
||||
public required IReadOnlyList<CachedTx> History { get; init; }
|
||||
@@ -46,13 +63,66 @@ 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"/>/
|
||||
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
|
||||
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
|
||||
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
|
||||
/// only completes once verification is fully done, for callers that need the final state.
|
||||
/// </summary>
|
||||
public event Action<SyncResult>? PartialResult;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
|
||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||
|
||||
// txids known confirmed (height > 0) as of download time, independent of whether their
|
||||
// Merkle proof has been verified yet — lets ExportCaches persist raw tx bytes for a
|
||||
// confirmed transaction interrupted before verification, instead of forcing a
|
||||
// re-download on the next sync just because _verifiedAtHeight hasn't caught up. Every
|
||||
// entry here has txHeights[txid] > 0 at the time it was recorded, i.e. server-confirmed;
|
||||
// unconfirmed (mempool/RBF-able) transactions are deliberately never added.
|
||||
private readonly ConcurrentDictionary<string, byte> _confirmedTxids = new();
|
||||
|
||||
// Concurrent: written incrementally by individual merkle-verification tasks as they
|
||||
// complete (§7.4 progressive verification), not just once after they all finish.
|
||||
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
|
||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
||||
|
||||
// checkpoint height -> highest height already proven to hash-chain back to it
|
||||
// (in-memory only: cheap to recompute from _headerFetches, no need to persist).
|
||||
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
|
||||
// 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
|
||||
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
|
||||
// serializing them costs nothing that matters.
|
||||
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
|
||||
|
||||
// Max headers requested per blockchain.block.headers call. The server may return fewer
|
||||
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
|
||||
private const int HeaderBatchSize = 2016;
|
||||
|
||||
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||
// discovery — already-used addresses are fetched in a single burst instead of
|
||||
@@ -70,16 +140,37 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
Dictionary<int, string>? blockHeaders,
|
||||
int knownReceiveIndex,
|
||||
int knownChangeIndex,
|
||||
Network network)
|
||||
Network network,
|
||||
Dictionary<int, int>? anchoredDownTo = null)
|
||||
{
|
||||
foreach (var (txid, hex) in rawTxHex)
|
||||
{
|
||||
_txCache.TryAdd(txid, Transaction.Parse(hex, network));
|
||||
foreach (var (txid, height) in verifiedAt)
|
||||
if (!_verifiedAtHeight.ContainsKey(txid))
|
||||
_verifiedAtHeight[txid] = height;
|
||||
// ExportCaches only ever wrote confirmed transactions here (see its own
|
||||
// filter), so every preloaded entry is safe to mark confirmed too.
|
||||
_confirmedTxids.TryAdd(txid, 0);
|
||||
}
|
||||
// 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 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 (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;
|
||||
}
|
||||
@@ -91,10 +182,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
/// </summary>
|
||||
public (Dictionary<string, string> RawTxHex,
|
||||
Dictionary<string, int> VerifiedAt,
|
||||
Dictionary<int, string> BlockHeaders)
|
||||
Dictionary<int, string> BlockHeaders,
|
||||
Dictionary<int, int> AnchoredDownTo)
|
||||
ExportCaches(Network network)
|
||||
{
|
||||
var rawHex = _verifiedAtHeight.Keys
|
||||
var rawHex = _confirmedTxids.Keys
|
||||
.Where(_txCache.ContainsKey)
|
||||
.ToDictionary(txid => txid, txid => _txCache[txid].ToHex());
|
||||
|
||||
@@ -105,12 +197,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
if (task.IsCompletedSuccessfully)
|
||||
headers[height] = task.Result;
|
||||
|
||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers);
|
||||
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers,
|
||||
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.
|
||||
@@ -162,7 +256,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
foreach (var item in historyByAddress.Values.SelectMany(h => h))
|
||||
txHeights[item.TxHash] = item.Height;
|
||||
|
||||
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
||||
// 4. Download missing transactions — needed to compute amounts/UTXOs locally.
|
||||
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
|
||||
// wallet with thousands of transactions, downloads finish in seconds while proofs can
|
||||
// take much longer over a high-latency link, and the wallet has everything it needs to
|
||||
// show balance/history the moment downloads are done. Verification then continues in
|
||||
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
|
||||
// while coin selection stays locked out of any UTXO until its own proof is checked
|
||||
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
|
||||
// cannot get a fabricated balance spent just because it was shown early.
|
||||
var network = PalladiumNetworks.For(account.Profile.Kind);
|
||||
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
|
||||
var toVerify = txHeights
|
||||
@@ -170,47 +272,101 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
&& (!_verifiedAtHeight.TryGetValue(kv.Key, out var h) || h != kv.Value))
|
||||
.ToList();
|
||||
|
||||
// Total/already-cached counts (not just this session's downloads): on a sync resumed
|
||||
// after an interruption, `missing` is often empty because everything was already
|
||||
// fetched last time (see _confirmedTxids/PreloadCaches) — reporting against the total
|
||||
// shows "n/n transactions" immediately instead of a misleading "0/0" before jumping
|
||||
// straight to proof verification.
|
||||
var totalTx = txHeights.Count;
|
||||
var alreadyCached = totalTx - missing.Count;
|
||||
|
||||
string DownloadVerifyStatus(int downloaded, int verified) =>
|
||||
$"transactions {downloaded}/{totalTx}, proofs {verified}/{toVerify.Count}…";
|
||||
|
||||
if (missing.Count > 0 || toVerify.Count > 0)
|
||||
Progress?.Invoke(DownloadVerifyStatus(alreadyCached, 0));
|
||||
|
||||
var dlDone = 0;
|
||||
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
|
||||
var dlDone = 0;
|
||||
var merkDone = 0;
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
if (txHeights[txid] > 0)
|
||||
_confirmedTxids.TryAdd(txid, 0);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
if (n % 50 == 0 || n == missing.Count)
|
||||
Progress?.Invoke(DownloadVerifyStatus(alreadyCached + n, 0));
|
||||
}, ct)));
|
||||
|
||||
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||
SyncResult BuildSnapshot() =>
|
||||
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
|
||||
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
|
||||
var merkDone = 0;
|
||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
// Anchor first: on a checkpointed height this fills _headerFetches[height]
|
||||
// via the batched range fetch (§7.3), so the header lookup below is a cache
|
||||
// hit instead of a second individual blockchain.block.header RPC per tx —
|
||||
// halves round-trips for this stage on mainnet, where it matters most on
|
||||
// high-latency mobile links. Falls back to an individual fetch when no
|
||||
// checkpoint covers this height (testnet/regtest today).
|
||||
try { await AnchorToCheckpointAsync(height, ct); }
|
||||
catch (CrossCheckInconclusiveException)
|
||||
{
|
||||
var raw = await client.GetTransactionAsync(txid, ct);
|
||||
_txCache[txid] = Transaction.Parse(raw, network);
|
||||
var n = Interlocked.Increment(ref dlDone);
|
||||
if (n % 50 == 0 || n == missing.Count)
|
||||
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…");
|
||||
}, ct));
|
||||
// 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;
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||
_verifiedAtHeight[txid] = height;
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
if (n % 50 == 0 || n == toVerify.Count)
|
||||
Progress?.Invoke(DownloadVerifyStatus(totalTx, n));
|
||||
if (n % PartialResultBatchSize == 0)
|
||||
PartialResult?.Invoke(BuildSnapshot());
|
||||
}, ct)).ToList();
|
||||
|
||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||
{
|
||||
var (txid, height) = kv;
|
||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||
var headerTask = _headerFetches.GetOrAdd(height,
|
||||
h => client.GetBlockHeaderAsync(h, ct));
|
||||
var proof = await proofTask;
|
||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||
await AnchorToCheckpointAsync(height, ct);
|
||||
if (!MerkleProof.Verify(
|
||||
uint256.Parse(txid), proof.Pos,
|
||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||
throw new SpvVerificationException(
|
||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||
var n = Interlocked.Increment(ref merkDone);
|
||||
if (n % 50 == 0 || n == toVerify.Count)
|
||||
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||
}, ct));
|
||||
if (merkTasks.Count > 0)
|
||||
await Task.WhenAll(merkTasks);
|
||||
|
||||
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
||||
foreach (var (txid, height) in toVerify)
|
||||
_verifiedAtHeight[txid] = height;
|
||||
}
|
||||
return BuildSnapshot();
|
||||
}
|
||||
|
||||
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
|
||||
// every single verified proof would make the background verification phase itself O(n²) for
|
||||
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
|
||||
// visibly without that cost.
|
||||
private const int PartialResultBatchSize = 200;
|
||||
|
||||
private bool IsTxVerified(string txid, int height) =>
|
||||
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
|
||||
|
||||
/// <summary>
|
||||
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
|
||||
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
|
||||
/// transaction downloads finish (proofs still pending), and again as verification progresses,
|
||||
/// each time reflecting whichever transactions have been proof-checked so far.
|
||||
/// </summary>
|
||||
private SyncResult BuildResult(
|
||||
int tipHeight,
|
||||
List<TrackedAddress> tracked,
|
||||
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
|
||||
Dictionary<string, int> txHeights,
|
||||
int nextReceive,
|
||||
int nextChange)
|
||||
{
|
||||
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
|
||||
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
|
||||
|
||||
// 6. Local UTXO reconstruction.
|
||||
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
|
||||
@@ -222,6 +378,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
var utxos = new List<CachedUtxo>();
|
||||
foreach (var (txid, tx) in transactions)
|
||||
{
|
||||
var height = txHeights[txid];
|
||||
var verifiedTx = IsTxVerified(txid, height);
|
||||
for (var vout = 0; vout < tx.Outputs.Count; vout++)
|
||||
{
|
||||
var output = tx.Outputs[vout];
|
||||
@@ -237,8 +395,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
Address = addr.Address.ToString(),
|
||||
IsChange = addr.IsChange,
|
||||
AddressIndex = addr.Index,
|
||||
Height = txHeights[txid],
|
||||
Height = height,
|
||||
IsCoinbase = tx.IsCoinBase,
|
||||
Verified = verifiedTx,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -247,6 +406,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
var history = new List<CachedTx>();
|
||||
foreach (var (txid, tx) in transactions)
|
||||
{
|
||||
var height = txHeights[txid];
|
||||
var received = tx.Outputs
|
||||
.Where(o => byScript.ContainsKey(o.ScriptPubKey))
|
||||
.Sum(o => o.Value.Satoshi);
|
||||
@@ -257,9 +417,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
history.Add(new CachedTx
|
||||
{
|
||||
Txid = txid,
|
||||
Height = txHeights[txid],
|
||||
Height = height,
|
||||
DeltaSats = received - sentSats,
|
||||
Verified = verified[txid],
|
||||
Verified = IsTxVerified(txid, height),
|
||||
});
|
||||
}
|
||||
history.Sort((a, b) =>
|
||||
@@ -286,12 +446,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
|
||||
return new SyncResult
|
||||
{
|
||||
TipHeight = tip.Height,
|
||||
TipHeight = tipHeight,
|
||||
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
|
||||
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
|
||||
ImmatureSats = utxos.Where(u =>
|
||||
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile))
|
||||
u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile))
|
||||
.Sum(u => u.ValueSats),
|
||||
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
|
||||
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
|
||||
NextReceiveIndex = nextReceive,
|
||||
NextChangeIndex = nextChange,
|
||||
History = history,
|
||||
@@ -315,30 +477,140 @@ 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);
|
||||
|
||||
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||
.Select(async h => BlockHeaderInfo.Parse(
|
||||
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct)))));
|
||||
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>
|
||||
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
|
||||
/// is present in <see cref="_headerFetches"/>, downloading gaps with
|
||||
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
|
||||
/// </summary>
|
||||
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
|
||||
{
|
||||
await _headerRangeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var h = fromHeight;
|
||||
while (h <= toHeightInclusive)
|
||||
{
|
||||
if (_headerFetches.ContainsKey(h)) { h++; continue; }
|
||||
|
||||
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
|
||||
var range = await client.GetBlockHeadersAsync(h, requested, ct);
|
||||
if (range.Count == 0)
|
||||
throw new SpvVerificationException(
|
||||
$"Server returned no headers starting at height {h}: server is not trustworthy.");
|
||||
|
||||
for (var i = 0; i < range.Count; i++)
|
||||
{
|
||||
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
|
||||
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
|
||||
}
|
||||
h += range.Count;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_headerRangeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -414,7 +686,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
return (firstUnused, tracked, history);
|
||||
}
|
||||
|
||||
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||
// Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
|
||||
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
|
||||
// rather than guessed at from wall-clock numbers alone.
|
||||
private int _busyRetries;
|
||||
|
||||
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||
{
|
||||
var delay = 200;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
@@ -423,13 +700,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
catch (ElectrumServerException ex)
|
||||
when (IsBusy(ex) && attempt < 7)
|
||||
{
|
||||
ReportBusyRetry(delay);
|
||||
await Task.Delay(delay, ct);
|
||||
delay = Math.Min(delay * 2, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||
private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
||||
{
|
||||
var delay = 200;
|
||||
for (var attempt = 0; ; attempt++)
|
||||
@@ -438,12 +716,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
||||
catch (ElectrumServerException ex)
|
||||
when (IsBusy(ex) && attempt < 7)
|
||||
{
|
||||
ReportBusyRetry(delay);
|
||||
await Task.Delay(delay, ct);
|
||||
delay = Math.Min(delay * 2, 5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportBusyRetry(int delayMs)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _busyRetries);
|
||||
if (n == 1 || n % 20 == 0)
|
||||
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
|
||||
}
|
||||
|
||||
private static bool IsBusy(ElectrumServerException ex) =>
|
||||
ex.Message.Contains("-102") ||
|
||||
ex.Message.Contains("-101") ||
|
||||
@@ -453,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;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ public sealed class WalletDocument
|
||||
/// <summary>Imported WIF keys (in plaintext in the document — must be encrypted!).</summary>
|
||||
public List<string>? WifKeys { get; set; }
|
||||
|
||||
/// <summary>Watch-only addresses with no associated private key (pure address import).</summary>
|
||||
public List<string>? WatchAddresses { get; set; }
|
||||
|
||||
/// <summary>Gap limit for address scanning (§5), configurable.</summary>
|
||||
public int GapLimit { get; set; } = 20;
|
||||
|
||||
@@ -98,6 +101,16 @@ public sealed class SyncCache
|
||||
|
||||
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
|
||||
public long ImmatureSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
|
||||
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
|
||||
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
|
||||
/// </summary>
|
||||
public long PendingVerificationSats { get; set; }
|
||||
|
||||
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
|
||||
public long SpendableSats { get; set; }
|
||||
public int NextReceiveIndex { get; set; }
|
||||
public int NextChangeIndex { get; set; }
|
||||
public List<CachedTx> History { get; set; } = [];
|
||||
@@ -124,6 +137,14 @@ public sealed class SyncCache
|
||||
/// subsequent syncs.
|
||||
/// </summary>
|
||||
public Dictionary<int, string>? BlockHeaders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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>? AnchoredDownTo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
|
||||
@@ -141,6 +162,13 @@ public sealed class CachedTx
|
||||
public required string Txid { get; set; }
|
||||
public int Height { get; set; }
|
||||
public long DeltaSats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once this tx's Merkle proof has actually been checked against a
|
||||
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
|
||||
/// be pending its own proof while background verification catches up). Always
|
||||
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -155,4 +183,11 @@ public sealed class CachedUtxo
|
||||
public int Height { get; set; }
|
||||
public bool IsCoinbase { get; set; }
|
||||
public bool Frozen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
|
||||
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
|
||||
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
|
||||
/// </summary>
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
@@ -37,4 +37,12 @@ public static class WalletStore
|
||||
File.WriteAllText(tmp, content);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
|
||||
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
|
||||
/// transactions/headers), where the synchronous version would block the UI.
|
||||
/// </summary>
|
||||
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
|
||||
Task.Run(() => Save(doc, path, password));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public sealed class BuiltTransaction
|
||||
/// </summary>
|
||||
public sealed class TransactionFactory(IWalletAccount account)
|
||||
{
|
||||
private const int MaxStandardTransactionVirtualSize = 100_000;
|
||||
|
||||
private Network Network => PalladiumNetworks.For(account.Profile.Kind);
|
||||
|
||||
/// <summary>
|
||||
@@ -68,9 +70,9 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (immature.Count > 0)
|
||||
{
|
||||
var best = immature.Max(u => u.Confirmations(tipHeight));
|
||||
var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
|
||||
var threshold = profile.CoinbaseMaturity + 1;
|
||||
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
|
||||
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). ");
|
||||
}
|
||||
|
||||
var underConf = utxos.Where(u =>
|
||||
@@ -78,20 +80,96 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
|
||||
if (underConf.Count > 0)
|
||||
{
|
||||
var best = underConf.Max(u => u.Confirmations(tipHeight));
|
||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). ");
|
||||
var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
|
||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). ");
|
||||
}
|
||||
|
||||
var unverified = utxos.Where(u =>
|
||||
!u.Frozen && u.Height > 0 && !u.Verified &&
|
||||
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
|
||||
if (unverified.Count > 0)
|
||||
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
|
||||
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
|
||||
|
||||
throw new WalletSpendException(reasons.Length > 0
|
||||
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
|
||||
: "No spendable UTXOs selected.");
|
||||
}
|
||||
|
||||
var coins = spendable.Select(u => new Coin(
|
||||
var ordered = spendable
|
||||
.OrderByDescending(u => u.ValueSats)
|
||||
.ThenBy(u => u.Height)
|
||||
.ThenBy(u => u.Txid, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Vout)
|
||||
.ToList();
|
||||
|
||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||
|
||||
if (sendAll)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BuildWithSelectedUtxos(
|
||||
ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count);
|
||||
}
|
||||
catch (TransactionTooLargeException ex)
|
||||
{
|
||||
throw new WalletSpendException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
NotEnoughFundsException? lastInsufficientFunds = null;
|
||||
TransactionTooLargeException? tooLarge = null;
|
||||
BuiltTransaction? best = null;
|
||||
var low = 1;
|
||||
var high = ordered.Count;
|
||||
while (low <= high)
|
||||
{
|
||||
var count = low + ((high - low) / 2);
|
||||
try
|
||||
{
|
||||
best = BuildWithSelectedUtxos(
|
||||
ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex,
|
||||
sendAll: false, totalSpendableCount: ordered.Count);
|
||||
high = count - 1;
|
||||
}
|
||||
catch (NotEnoughFundsException ex)
|
||||
{
|
||||
lastInsufficientFunds = ex;
|
||||
low = count + 1;
|
||||
}
|
||||
catch (TransactionTooLargeException ex)
|
||||
{
|
||||
tooLarge = ex;
|
||||
high = count - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (best is not null)
|
||||
return best;
|
||||
|
||||
if (tooLarge is not null)
|
||||
throw new WalletSpendException(tooLarge.Message);
|
||||
|
||||
throw new WalletSpendException(lastInsufficientFunds is null
|
||||
? "Insufficient funds."
|
||||
: $"Insufficient funds: {lastInsufficientFunds.Message}");
|
||||
}
|
||||
|
||||
private BuiltTransaction BuildWithSelectedUtxos(
|
||||
IReadOnlyList<CachedUtxo> selectedUtxos,
|
||||
IReadOnlyDictionary<string, Transaction> transactions,
|
||||
BitcoinAddress destination,
|
||||
long amountSats,
|
||||
FeeRate feeRate,
|
||||
int changeIndex,
|
||||
bool sendAll,
|
||||
int totalSpendableCount)
|
||||
{
|
||||
var coins = selectedUtxos.Select(u => new Coin(
|
||||
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
|
||||
transactions[u.Txid].Outputs[u.Vout])).ToList();
|
||||
|
||||
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
|
||||
var builder = Network.CreateTransactionBuilder();
|
||||
builder.SetVersion(2);
|
||||
// RBF sequence to allow fee bumping (§6.6).
|
||||
@@ -107,7 +185,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
|
||||
if (!account.IsWatchOnly)
|
||||
{
|
||||
builder.AddKeys(spendable
|
||||
builder.AddKeys(selectedUtxos
|
||||
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
||||
.OfType<Key>()
|
||||
.ToArray());
|
||||
@@ -118,11 +196,27 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
{
|
||||
tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
|
||||
}
|
||||
catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// NBitcoin's coin selector refuses to assemble a combination over the standard size
|
||||
// cap itself and reports it through NotEnoughFundsException rather than ever handing
|
||||
// back an oversized transaction — the GetVirtualSize() check below is unreachable for
|
||||
// this case and exists only as a defense-in-depth net for other NBitcoin versions.
|
||||
throw new TransactionTooLargeException(
|
||||
BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll));
|
||||
}
|
||||
catch (NotEnoughFundsException) when (!sendAll)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotEnoughFundsException ex)
|
||||
{
|
||||
throw new WalletSpendException($"Insufficient funds: {ex.Message}");
|
||||
}
|
||||
|
||||
if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize)
|
||||
throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize()));
|
||||
|
||||
if (!account.IsWatchOnly)
|
||||
{
|
||||
if (!builder.Verify(tx, out TransactionPolicyError[] errors))
|
||||
@@ -140,6 +234,21 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTooLargeMessage(
|
||||
int selectedInputCount,
|
||||
int totalSpendableCount,
|
||||
bool sendAll,
|
||||
int? actualVirtualSize = null)
|
||||
{
|
||||
var prefix = sendAll
|
||||
? "Send-all cannot fit in one standard transaction"
|
||||
: "Transaction cannot fit in one standard transaction";
|
||||
var size = actualVirtualSize is { } vsize ? $"{vsize} vB exceeds" : "Estimated size exceeds";
|
||||
|
||||
return $"{prefix}: {size} the {MaxStandardTransactionVirtualSize} vB standard relay limit " +
|
||||
$"with {selectedInputCount}/{totalSpendableCount} spendable input(s). Send a smaller amount or consolidate in multiple smaller transactions.";
|
||||
}
|
||||
|
||||
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
|
||||
{
|
||||
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
|
||||
@@ -147,6 +256,8 @@ public sealed class TransactionFactory(IWalletAccount account)
|
||||
.Sum(c => (Money)c.Amount);
|
||||
return inputSum - tx.Outputs.Sum(o => o.Value);
|
||||
}
|
||||
|
||||
private sealed class TransactionTooLargeException(string message) : Exception(message);
|
||||
}
|
||||
|
||||
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
|
||||
|
||||
@@ -18,7 +18,13 @@ public static class UtxoSpendability
|
||||
public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
|
||||
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
|
||||
|
||||
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</summary>
|
||||
/// <summary>
|
||||
/// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle
|
||||
/// proof has actually been checked. Without the <see cref="CachedUtxo.Verified"/> gate a
|
||||
/// malicious server could report a fake confirmed UTXO and have it spent before background
|
||||
/// verification ever caught the forgery.
|
||||
/// </summary>
|
||||
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
|
||||
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||
!utxo.Frozen && utxo.Height > 0 && utxo.Verified
|
||||
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||
}
|
||||
@@ -51,7 +51,15 @@ public static class WalletLoader
|
||||
return new ImportedKeyAccount(entries, kind, profile);
|
||||
}
|
||||
|
||||
// 4. Watch-only from xpub
|
||||
// 4. Watch-only imported addresses (no keys, no HD derivation)
|
||||
if (doc.WatchAddresses is { Count: > 0 } watchAddresses)
|
||||
{
|
||||
var entries = watchAddresses.Select(a =>
|
||||
(BitcoinAddress.Create(a.Trim(), network), (Key?)null)).ToList();
|
||||
return new ImportedKeyAccount(entries, kind, profile);
|
||||
}
|
||||
|
||||
// 5. Watch-only from xpub
|
||||
if (doc.AccountXpub is null)
|
||||
throw new InvalidDataException("Wallet file has no xpub and no seed.");
|
||||
if (!Slip132.TryDecodePublic(doc.AccountXpub, profile, out var xpub, out _))
|
||||
@@ -170,4 +178,47 @@ public static class WalletLoader
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the document from one or more plain addresses, with no private key at all
|
||||
/// (pure watch-only import — the account can never sign, unlike xpub- or WIF-based accounts
|
||||
/// which can be upgraded later by supplying the matching key). ScriptKind is informational
|
||||
/// only here (no derivation happens from a fixed address list) and is auto-detected from the
|
||||
/// first address unless <paramref name="kindOverride"/> is given.
|
||||
/// </summary>
|
||||
public static (WalletDocument Doc, ImportedKeyAccount Account) NewFromAddresses(
|
||||
IReadOnlyList<string> addresses, ChainProfile profile, ScriptKind? kindOverride = null)
|
||||
{
|
||||
if (addresses.Count == 0)
|
||||
throw new InvalidDataException("At least one address is required.");
|
||||
|
||||
var network = PalladiumNetworks.For(profile.Kind);
|
||||
var entries = new List<(BitcoinAddress, Key?)>();
|
||||
var addressStrings = new List<string>();
|
||||
|
||||
foreach (var raw in addresses)
|
||||
{
|
||||
BitcoinAddress addr;
|
||||
try
|
||||
{
|
||||
addr = BitcoinAddress.Create(raw.Trim(), network);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid address: {ex.Message}");
|
||||
}
|
||||
entries.Add((addr, null));
|
||||
addressStrings.Add(raw.Trim());
|
||||
}
|
||||
|
||||
var kind = kindOverride ?? DerivationPaths.KindFor(entries[0].Item1);
|
||||
var account = new ImportedKeyAccount(entries, kind, profile);
|
||||
var doc = new WalletDocument
|
||||
{
|
||||
Network = profile.NetName,
|
||||
ScriptKind = kind.ToString(),
|
||||
WatchAddresses = addressStrings,
|
||||
};
|
||||
return (doc, account);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user